Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.stypox.dicio.skills.timer.TimerInfo
import org.stypox.dicio.skills.translation.TranslationInfo
import org.stypox.dicio.skills.weather.WeatherInfo
import org.stypox.dicio.skills.joke.JokeInfo
import org.stypox.dicio.skills.unit_conversion.UnitConversionInfo
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -55,6 +56,7 @@ class SkillHandler @Inject constructor(
JokeInfo,
ListeningInfo(dataStore),
TranslationInfo,
UnitConversionInfo,
)

private val fallbackSkillInfoList = listOf(
Expand Down
344 changes: 344 additions & 0 deletions app/src/main/kotlin/org/stypox/dicio/skills/unit_conversion/Unit.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.stypox.dicio.skills.unit_conversion

import android.content.Context
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.Skill
import org.dicio.skill.skill.SkillInfo
import org.stypox.dicio.R
import org.stypox.dicio.sentences.Sentences

object UnitConversionInfo : SkillInfo("unit_conversion") {
override fun name(context: Context) =
context.getString(R.string.skill_name_unit_conversion)

override fun sentenceExample(context: Context) =
context.getString(R.string.skill_sentence_example_unit_conversion)

@Composable
override fun icon() =
rememberVectorPainter(Icons.Default.SwapHoriz)

override fun isAvailable(ctx: SkillContext): Boolean {
return Sentences.UnitConversion[ctx.sentencesLanguage] != null &&
ctx.parserFormatter != null
}

override fun build(ctx: SkillContext): Skill<*> {
return UnitConversionSkill(
UnitConversionInfo,
Sentences.UnitConversion[ctx.sentencesLanguage]!!
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.stypox.dicio.skills.unit_conversion

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.SkillOutput
import org.stypox.dicio.R
import org.stypox.dicio.io.graphical.Headline
import org.stypox.dicio.io.graphical.Subtitle
import org.stypox.dicio.util.getString
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols

sealed interface UnitConversionOutput : SkillOutput {
data class Success(
val inputValue: Double,
val sourceUnit: Unit,
val targetUnit: Unit,
val result: Double
) : UnitConversionOutput {

private fun formatNumber(value: Double): String {
// Use appropriate precision based on magnitude
val symbols = DecimalFormatSymbols().apply {
groupingSeparator = ','
decimalSeparator = '.'
}

return when {
value == 0.0 -> "0"
Math.abs(value) >= 1000000 -> {
DecimalFormat("#,##0.##E0", symbols).format(value)
}
Math.abs(value) >= 1 -> {
DecimalFormat("#,##0.####", symbols).format(value)
}
Math.abs(value) >= 0.01 -> {
DecimalFormat("0.####", symbols).format(value)
}
else -> {
DecimalFormat("0.######E0", symbols).format(value)
}
}
}

private fun getUnitDisplayName(unit: Unit, value: Double, resources: android.content.res.Resources): String {
// Prefer abbreviations for some units, full names for others
return when (unit.type) {
UnitType.DIGITAL_STORAGE, UnitType.ENERGY, UnitType.POWER, UnitType.PRESSURE -> {
// Use abbreviations
unit.abbreviations.firstOrNull() ?: ""
}
else -> {
// Use full names from resources - choose singular or plural array
val usePlural = Math.abs(value) != 1.0
val arrayResId = if (usePlural) unit.pluralNamesResId else unit.singularNamesResId
resources.getStringArray(arrayResId).firstOrNull() ?: ""
}
}
}

override fun getSpeechOutput(ctx: SkillContext): String {
val inputStr = formatNumber(inputValue)
val resultStr = formatNumber(result)
val sourceUnitName = getUnitDisplayName(sourceUnit, inputValue, ctx.android.resources)
val targetUnitName = getUnitDisplayName(targetUnit, result, ctx.android.resources)

return ctx.getString(
R.string.skill_unit_conversion_result,
inputStr,
sourceUnitName,
resultStr,
targetUnitName
)
}

@Composable
override fun GraphicalOutput(ctx: SkillContext) {
Column {
Subtitle(
text = "${formatNumber(inputValue)} ${getUnitDisplayName(sourceUnit, inputValue, ctx.android.resources)}"
)
Spacer(modifier = androidx.compose.ui.Modifier.height(8.dp))
Headline(
text = "${formatNumber(result)} ${getUnitDisplayName(targetUnit, result, ctx.android.resources)}"
)
}
}
}

data class Error(
val message: String
) : UnitConversionOutput {
override fun getSpeechOutput(ctx: SkillContext): String {
return ctx.getString(R.string.skill_unit_conversion_error, message)
}

@Composable
override fun GraphicalOutput(ctx: SkillContext) {
Headline(text = getSpeechOutput(ctx))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package org.stypox.dicio.skills.unit_conversion

import org.dicio.numbers.unit.Number
import org.dicio.skill.context.SkillContext
import org.dicio.skill.skill.SkillInfo
import org.dicio.skill.skill.SkillOutput
import org.dicio.skill.standard.StandardRecognizerData
import org.dicio.skill.standard.StandardRecognizerSkill
import org.stypox.dicio.sentences.Sentences.UnitConversion
import org.stypox.dicio.util.ConnectionUtils
import org.json.JSONObject

class UnitConversionSkill(
correspondingSkillInfo: SkillInfo,
data: StandardRecognizerData<UnitConversion>
) : StandardRecognizerSkill<UnitConversion>(correspondingSkillInfo, data) {

override suspend fun generateOutput(ctx: SkillContext, inputData: UnitConversion): SkillOutput {
when (inputData) {
is UnitConversion.Convert -> {
// Extract target unit
val targetUnitText = inputData.targetUnit?.trim()
if (targetUnitText.isNullOrBlank()) {
return UnitConversionOutput.Error("Missing target unit")
}

val targetUnit = Unit.findUnit(targetUnitText, ctx.android.resources)
if (targetUnit == null) {
return UnitConversionOutput.Error("Unknown target unit: $targetUnitText")
}

// Parse value and source unit from the combined string
val valueWithUnitText = inputData.valueWithUnit?.trim()
if (valueWithUnitText.isNullOrBlank()) {
return UnitConversionOutput.Error("Missing value and source unit")
}

// Use number parser to extract the number and remaining text
val parsed = ctx.parserFormatter?.extractNumber(valueWithUnitText)
if (parsed == null) {
return UnitConversionOutput.Error("Could not parse value")
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
}

// Find the number in the mixed list
var value: Double? = null
val mixedList = parsed.mixedWithText
for (item in mixedList) {
if (item is Number) {
value = if (item.isDecimal) {
item.decimalValue()
} else {
item.integerValue().toDouble()
}
break
}
}

if (value == null) {
// Fallback: check if the text starts with "a " or "an " (e.g., "a gallon")
val normalized = valueWithUnitText.lowercase().trim()
if (normalized.startsWith("a ") || normalized.startsWith("an ")) {
value = 1.0
} else {
return UnitConversionOutput.Error("Could not parse the number value")
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
}
}

// Extract source unit from the remaining text
// The mixedList contains the number and text parts, we need to find unit names
val sourceUnit = findUnitInText(valueWithUnitText, ctx.android.resources)
if (sourceUnit == null) {
return UnitConversionOutput.Error("Could not identify source unit in: $valueWithUnitText")
}

if (sourceUnit.type != targetUnit.type) {
return UnitConversionOutput.Error(
"Cannot convert between ${sourceUnit.type.name.lowercase()} and ${targetUnit.type.name.lowercase()}"
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
)
}

// Perform conversion
val result = if (sourceUnit.type == UnitType.CURRENCY) {
// Currency conversion via API
convertCurrency(value, sourceUnit, targetUnit)
} else {
// Standard unit conversion
Unit.convert(value, sourceUnit, targetUnit)
}
Comment thread
tylxr59 marked this conversation as resolved.

if (result == null) {
return UnitConversionOutput.Error("Conversion failed")
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
}

return UnitConversionOutput.Success(
inputValue = value,
sourceUnit = sourceUnit,
targetUnit = targetUnit,
result = result
)
}
}
}

/**
* Convert currency using the Frankfurter API.
* API format: https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR
* Returns the converted amount with 5 decimal precision, or null if the conversion fails.
*/
private fun convertCurrency(amount: Double, fromCurrency: Unit, toCurrency: Unit): Double? {
if (fromCurrency.type != UnitType.CURRENCY || toCurrency.type != UnitType.CURRENCY) {
return null
}

// Extract ISO codes from the unit abbreviations (first abbreviation is the ISO code)
val baseCurrency = fromCurrency.abbreviations.firstOrNull() ?: return null
val targetCurrency = toCurrency.abbreviations.firstOrNull() ?: return null

return try {
// Build API URL
val apiUrl = "https://api.frankfurter.dev/v1/latest?base=$baseCurrency&symbols=$targetCurrency"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to move the url to a companion object. Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be useful if/when we want to change providers in the future


// Fetch exchange rate from API
val response: JSONObject = ConnectionUtils.getPageJson(apiUrl)

// Extract the exchange rate from the response
// Response format: {"amount":1.0,"base":"USD","date":"2025-12-16","rates":{"EUR":0.84918}}
val rates = response.getJSONObject("rates")
val exchangeRate = rates.getDouble(targetCurrency)
Comment thread
tylxr59 marked this conversation as resolved.
Outdated

// Calculate converted amount with 5 decimal precision
val result = amount * exchangeRate
String.format("%.5f", result).toDouble()
} catch (e: Exception) {
// Return null on any error (network failure, API error, parsing error)
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
null
}
}

private fun findUnitInText(text: String, resources: android.content.res.Resources): Unit? {
val normalizedText = text.lowercase()

// Try to find a unit by checking if any unit name or abbreviation appears in the text
// Sort by length descending to match longer unit names first (e.g., "square meter" before "meter")
val allUnits = Unit.values().sortedByDescending { unit ->
try {
val singularNames = resources.getStringArray(unit.singularNamesResId)
val pluralNames = resources.getStringArray(unit.pluralNamesResId)
(singularNames.toList() + pluralNames.toList() + unit.abbreviations).maxOfOrNull { it.length } ?: 0
} catch (e: Exception) {
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
unit.abbreviations.maxOfOrNull { it.length } ?: 0
}
}

for (unit in allUnits) {
// Check localized full names (both singular and plural)
try {
val singularNames = resources.getStringArray(unit.singularNamesResId)
for (name in singularNames) {
if (normalizedText.contains(name.lowercase())) {
return unit
}
}

val pluralNames = resources.getStringArray(unit.pluralNamesResId)
for (name in pluralNames) {
if (normalizedText.contains(name.lowercase())) {
return unit
}
}
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
} catch (e: Exception) {
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
// Resource not found, skip
}

// Check abbreviations (with word boundaries)
for (abbr in unit.abbreviations) {
// Match abbreviations as whole words or at the end
val regex = "\\b${Regex.escape(abbr.lowercase())}\\b".toRegex()
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
if (regex.containsMatchIn(normalizedText)) {
return unit
}
}
}

return null
}

private fun extractNumber(ctx: SkillContext, text: String): Double? {
// Try to parse as a number using the parser formatter
val parsed = ctx.parserFormatter?.extractNumber(text)?.mixedWithText
if (!parsed.isNullOrEmpty()) {
// Find the first Number object in the mixed list
for (item in parsed) {
if (item is Number) {
return if (item.isDecimal) {
item.decimalValue()
} else {
item.integerValue().toDouble()
}
}
}
}

// Fallback: try to parse directly as a numeric string
return try {
text.trim().toDoubleOrNull()
} catch (e: Exception) {
Comment thread
tylxr59 marked this conversation as resolved.
Outdated
null
}
}
}
Loading