-
-
Notifications
You must be signed in to change notification settings - Fork 144
Add unit conversion skill #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
c9f15d6
4414413
0148bca
59f45b8
5a34e69
488a604
873a8e2
ccccf2c
fec9b15
9550885
4c312a7
fdd673a
0a6d3d2
cfddb6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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") | ||
| } | ||
|
|
||
| // 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") | ||
|
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()}" | ||
|
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) | ||
| } | ||
|
tylxr59 marked this conversation as resolved.
|
||
|
|
||
| if (result == null) { | ||
| return UnitConversionOutput.Error("Conversion failed") | ||
|
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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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) | ||
|
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) { | ||
|
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 | ||
| } | ||
| } | ||
|
tylxr59 marked this conversation as resolved.
Outdated
|
||
| } catch (e: Exception) { | ||
|
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() | ||
|
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) { | ||
|
tylxr59 marked this conversation as resolved.
Outdated
|
||
| null | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.