diff --git a/AGENTS.md b/AGENTS.md index 462ed9f..755325c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,12 +112,18 @@ the pattern to copy: it returns a locale-formatted `String?` (using locale-aware formatting via platform APIs, not translated text), and each UI layer wraps it with its own localized "Updated %1$s" string. -`webHtmlApp` is the one UI layer that deliberately breaks this pattern: its -`Strings.kt` hardcodes English rather than calling `stringResource()`, -because moko-resources' Compose integration (`moko-resourcesCompose`) pulls -in `compose.foundation`/`compose.ui` transitively - exactly the dependency -`webHtmlApp` exists to avoid (see "Module layout" above). Not a template to -follow elsewhere; a one-off tradeoff specific to that module. +`webHtmlApp` is the one UI layer that deliberately breaks this pattern: it +can't call `stringResource()` because moko-resources' Compose integration +(`moko-resourcesCompose`) pulls in `compose.foundation`/`compose.ui` +transitively - exactly the dependency `webHtmlApp` exists to avoid (see +"Module layout" above). Its `Strings.kt` is instead **generated** at build +time (`:webHtmlApp:generateStrings`, in `webHtmlApp/build.gradle.kts`) by +parsing `shared/.../moko-resources/base/strings.xml` directly, so that file +stays the single source of truth instead of a manually maintained, +silently driftable duplicate - edit the XML, not the generated file. Only +covers the base (English) locale, since it skips moko's per-locale +resolution entirely. Not a template to follow elsewhere; a one-off +tradeoff specific to that module. ## Locale-aware formatting diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 6689a00..f8f2f5e 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -274,7 +274,6 @@ struct ContentView: View { unselectedCurrencies: state.unselectedCurrencies, pricedCurrencyCodes: state.pricedCurrencyCodes, sourceName: state.sourceName, - localeCurrencyCode: state.localeCurrencyCode, selectedCount: state.selectedCurrencyCodes.count, onToggle: { viewModel.onFiatCurrencyToggled($0) }, onReset: { viewModel.onSelectedCurrenciesReset() } @@ -404,7 +403,7 @@ private struct NumericField: View { } private func currencyFieldLabel(for code: String) -> String { - if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code) { + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) { return "\(flag) \(code)" } return code diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index eaf6987..a0c2d5b 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -7,7 +7,6 @@ struct CurrencyPickerSheet: View { let unselectedCurrencies: [CurrencyInfo] let pricedCurrencyCodes: [String] let sourceName: String - let localeCurrencyCode: String? let selectedCount: Int let onToggle: (String) -> Void let onReset: () -> Void @@ -142,19 +141,11 @@ struct CurrencyPickerSheet: View { } private func currencyLabel(for info: CurrencyInfo) -> String { - let text: String - if info.code == localeCurrencyCode { - text = IosLocalizationKt.localizedFormattedString( - resource: MR.strings.shared.currency_option_label_local, - args: [info.code, info.displayName] - ) - } else { - text = IosLocalizationKt.localizedFormattedString( - resource: MR.strings.shared.currency_option_label, - args: [info.code, info.displayName] - ) - } - if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) { + let text = IosLocalizationKt.localizedFormattedString( + resource: MR.strings.shared.currency_option_label, + args: [info.code, info.displayName] + ) + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) { return "\(flag) \(text)" } return text diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt index 7c59a8e..5fdf0c1 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt @@ -26,7 +26,6 @@ data class IosConverterState( val unselectedCurrencies: List, val selectedCurrencyCodes: List, val pricedCurrencyCodes: List, - val localeCurrencyCode: String?, val defaultCurrencyCode: String, val sourceName: String, val isManualSource: Boolean, @@ -81,7 +80,6 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState unselectedCurrencies = unselectedCurrencies(), selectedCurrencyCodes = selectedFiatCurrencies, pricedCurrencyCodes = pricedCurrencyCodes.toList(), - localeCurrencyCode = localeCurrencyCode, defaultCurrencyCode = defaultCurrencyCode, sourceName = sourceName, isManualSource = isManualSource, diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 2129553..7797437 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -10,7 +10,6 @@ Current Currency Not priced by %1$s %1$s - %2$s - %1$s - %2$s Options for %1$s %1$s to Sats Done diff --git a/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index 1fcb778..7edcb97 100644 --- a/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -605,7 +605,6 @@ private fun CurrencyPickerScreen( isSelected = true, isPriced = state.isPriced(currentCurrency.code), sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = null, ) } @@ -625,7 +624,6 @@ private fun CurrencyPickerScreen( isSelected = true, isPriced = state.isPriced(info.code), sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -649,7 +647,6 @@ private fun CurrencyPickerScreen( isSelected = false, isPriced = true, sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -671,7 +668,6 @@ private fun CurrencyPickerScreen( isSelected = false, isPriced = false, sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -689,14 +685,9 @@ private fun CurrencyRow( isSelected: Boolean, isPriced: Boolean, sourceName: String, - localeCurrencyCode: String?, onClick: (() -> Unit)?, ) { - val text = if (info.code == localeCurrencyCode) { - stringResource(MR.strings.currency_option_label_local, info.code, info.displayName) - } else { - stringResource(MR.strings.currency_option_label, info.code, info.displayName) - } + val text = stringResource(MR.strings.currency_option_label, info.code, info.displayName) val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text Row( modifier = Modifier diff --git a/webHtmlApp/build.gradle.kts b/webHtmlApp/build.gradle.kts index b130487..06687aa 100644 --- a/webHtmlApp/build.gradle.kts +++ b/webHtmlApp/build.gradle.kts @@ -1,9 +1,92 @@ +import org.w3c.dom.Element +import javax.xml.parsers.DocumentBuilderFactory + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } +// webHtmlApp can't use moko-resources' Compose integration (stringResource()) without pulling +// in compose.foundation/ui transitively - exactly what this module exists to avoid (see +// AGENTS.md's "Module layout"). Generating a plain Strings.kt from the same strings.xml moko +// reads keeps that file as the single source of truth without a manually maintained, silently +// driftable duplicate. Only covers the base (English) locale - see AGENTS.md. +val stringsXmlFile = rootProject.file("shared/src/commonMain/moko-resources/base/strings.xml") + +val generateStrings by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated/strings") + // Captured as a plain local (rather than read from the doLast closure directly) so the task + // action doesn't hold a reference to the build script object itself, which the configuration + // cache refuses to serialize. + val xmlFile = stringsXmlFile + inputs.file(xmlFile) + outputs.dir(outputDir) + + doLast { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile) + val stringNodes = document.getElementsByTagName("string") + val placeholderPattern = Regex("""%(\d+)\$([sd])""") + + fun String.toCamelCase(): String = split("_").mapIndexed { index, word -> + if (index == 0) word else word.replaceFirstChar { it.uppercase() } + }.joinToString("") + + val source = buildString { + appendLine("// GENERATED FILE - do not edit directly.") + appendLine("// Regenerated by :webHtmlApp:generateStrings from") + appendLine("// shared/src/commonMain/moko-resources/base/strings.xml - edit that file instead.") + appendLine("package xyz.tyiu.satsprice") + appendLine() + appendLine("object Strings {") + for (i in 0 until stringNodes.length) { + val element = stringNodes.item(i) as Element + val propertyName = element.getAttribute("name").toCamelCase() + // moko/Android string-resource XML escapes a literal apostrophe as \' inside the + // (unquoted) text content - unescape that before anything else so it doesn't get + // mistaken for a Kotlin escape sequence below. + val rawValue = element.textContent.replace("\\'", "'") + + val placeholders = placeholderPattern.findAll(rawValue) + .map { it.groupValues[1].toInt() to it.groupValues[2] } + .distinct() + .sortedBy { it.first } + .toList() + + // Swap each %N$s/%N$d placeholder for a control-character sentinel first, so the + // literal '$' in "%1$s" itself doesn't get caught by the blanket '$' -> "\$" + // escaping below - then escape, then turn the sentinels into "${argN}". + var withSentinels = rawValue + for ((index, _) in placeholders) { + withSentinels = withSentinels.replace(Regex("""%$index\$[sd]"""), "$index") + } + val escaped = withSentinels + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + var templateBody = escaped + for ((index, _) in placeholders) { + templateBody = templateBody.replace("$index", "\${arg$index}") + } + + if (placeholders.isEmpty()) { + appendLine(" const val $propertyName: String = \"$templateBody\"") + } else { + val params = placeholders.joinToString(", ") { (index, type) -> + "arg$index: ${if (type == "d") "Int" else "String"}" + } + appendLine(" fun $propertyName($params): String = \"$templateBody\"") + } + } + appendLine("}") + } + + val outputFile = outputDir.get().file("xyz/tyiu/satsprice/Strings.kt").asFile + outputFile.parentFile.mkdirs() + outputFile.writeText(source) + } +} + // Compose HTML (org.jetbrains.compose.html:html-core) only publishes js and jvm target // variants as of Compose Multiplatform 1.11.1 - no wasmJs artifact exists yet, unlike the // Skia-based Compose Multiplatform UI toolkit webApp uses (which targets both). So this @@ -15,11 +98,14 @@ kotlin { } sourceSets { - commonMain.dependencies { - implementation(project(":shared")) + commonMain { + kotlin.srcDir(generateStrings) + dependencies { + implementation(project(":shared")) - implementation(libs.compose.runtime) - implementation(libs.compose.html.core) + implementation(libs.compose.runtime) + implementation(libs.compose.html.core) + } } } } diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt index 686d92a..22654f1 100644 --- a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt +++ b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt @@ -71,8 +71,8 @@ fun HtmlApp() { } else { Button(attrs = { classes("icon-button") - attr("aria-label", Strings.refresh) - attr("title", Strings.refresh) + attr("aria-label", Strings.refreshContentDescription) + attr("title", Strings.refreshContentDescription) onClick { viewModel.refresh() } }) { Text("↻") } } @@ -223,7 +223,7 @@ private fun CurrencyRowMenu( Div({ classes("menu-anchor") }) { Button(attrs = { classes("icon-button") - attr("aria-label", Strings.currencyOptions(code)) + attr("aria-label", Strings.currencyOptionsContentDescription(code)) attr("aria-haspopup", "true") attr("aria-expanded", if (expanded) "true" else "false") onClick { expanded = true } @@ -235,12 +235,15 @@ private fun CurrencyRowMenu( onClick { expanded = false } }) Div({ classes("menu-popup") }) { - MenuItem(Strings.moveToTop, enabled = canMoveUp) { expanded = false; onMoveToTop() } - MenuItem(Strings.moveUp, enabled = canMoveUp) { expanded = false; onMoveUp() } - MenuItem(Strings.moveDown, enabled = canMoveDown) { expanded = false; onMoveDown() } - MenuItem(Strings.moveToBottom, enabled = canMoveDown) { expanded = false; onMoveToBottom() } + MenuItem(Strings.moveCurrencyToTopContentDescription, enabled = canMoveUp) { expanded = false; onMoveToTop() } + MenuItem(Strings.moveCurrencyUpContentDescription, enabled = canMoveUp) { expanded = false; onMoveUp() } + MenuItem(Strings.moveCurrencyDownContentDescription, enabled = canMoveDown) { expanded = false; onMoveDown() } + MenuItem(Strings.moveCurrencyToBottomContentDescription, enabled = canMoveDown) { + expanded = false + onMoveToBottom() + } if (canRemove) { - MenuItem(Strings.remove, destructive = true) { expanded = false; onRemove() } + MenuItem(Strings.removeCurrencyContentDescription, destructive = true) { expanded = false; onRemove() } } } } diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt index 4d2b69c..e031042 100644 --- a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt +++ b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt @@ -30,8 +30,8 @@ internal fun HtmlCurrencyPicker( if (showResetConfirmation) { Div({ classes("modal-overlay") }) { Div({ classes("modal") }) { - H2 { Text(Strings.resetConfirmationTitle) } - Div { Text(Strings.resetConfirmationMessage(state.defaultCurrencyCode)) } + H2 { Text(Strings.resetSelectedCurrenciesConfirmationTitle) } + Div { Text(Strings.resetSelectedCurrenciesConfirmationMessage(state.defaultCurrencyCode)) } Div({ classes("row", "modal-actions") }) { Button(attrs = { onClick { showResetConfirmation = false } }) { Text(Strings.cancel) } Button(attrs = { @@ -40,7 +40,7 @@ internal fun HtmlCurrencyPicker( showResetConfirmation = false onReset() } - }) { Text(Strings.resetButton) } + }) { Text(Strings.resetSelectedCurrenciesButton) } } } } @@ -51,7 +51,9 @@ internal fun HtmlCurrencyPicker( H2 { Text(Strings.currenciesSectionTitle) } Div({ classes("row") }) { if (state.selectedFiatCurrencies.size > 1) { - Button(attrs = { onClick { showResetConfirmation = true } }) { Text(Strings.resetButton) } + Button(attrs = { onClick { showResetConfirmation = true } }) { + Text(Strings.resetSelectedCurrenciesButton) + } } Button(attrs = { onClick { onDone() } }) { Text(Strings.done) } } diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/Strings.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/Strings.kt deleted file mode 100644 index 1de6e96..0000000 --- a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/Strings.kt +++ /dev/null @@ -1,45 +0,0 @@ -package xyz.tyiu.satsprice - -/** - * English-only string constants mirroring shared/src/commonMain/moko-resources/base/strings.xml. - * moko-resources-compose's `stringResource()` pulls in Compose Foundation/UI (for its - * `LocalComposition`/`Modifier` plumbing) transitively, which would drag Skia-oriented - * dependencies into a module whose whole point is avoiding them, so this DOM build skips - * moko-resources rather than fight that dependency and stays English-only as a result. - */ -object Strings { - const val addCurrency = "Add currency" - const val bitcoinSectionTitle = "Bitcoin" - const val btcLabel = "BTC" - fun btcToCurrency(code: String) = "BTC to $code" - const val cancel = "Cancel" - const val clearSearch = "Clear search" - const val currenciesSectionTitle = "Currencies" - fun currenciesSelectedCount(count: Int) = "$count selected" - const val currentCurrencySectionTitle = "Current Currency" - fun currencyNotPriced(sourceName: String) = "Not priced by $sourceName" - fun currencyOptions(code: String) = "Options for $code" - fun currencyOptionLabel(code: String, displayName: String) = "$code - $displayName" - fun currencyToSats(code: String) = "$code to Sats" - const val done = "Done" - fun exceedsMaxSupply(maxSupplyText: String) = "Exceeds the $maxSupplyText maximum supply" - const val loadingRatesStatus = "Loading rates…" - const val moveDown = "Move down" - const val moveToBottom = "Move to bottom" - const val moveToTop = "Move to top" - const val moveUp = "Move up" - const val priceSource = "Price Source" - const val pricedCurrenciesSectionTitle = "Priced Currencies" - const val refresh = "Refresh" - const val remove = "Remove" - const val resetButton = "Reset" - fun resetConfirmationMessage(code: String) = - "This keeps $code, but removes every other currency you've added." - const val resetConfirmationTitle = "Remove all selected currencies?" - const val retry = "Retry" - const val satsLabel = "Sats" - const val searchCurrenciesPlaceholder = "Search currencies" - const val selectedCurrenciesSectionTitle = "Selected Currencies" - const val unpricedCurrenciesSectionTitle = "Unpriced Currencies" - fun updatedStatus(time: String) = "Updated $time" -} diff --git a/webHtmlApp/src/webMain/resources/index.html b/webHtmlApp/src/webMain/resources/index.html index 947d817..e7ae23c 100644 --- a/webHtmlApp/src/webMain/resources/index.html +++ b/webHtmlApp/src/webMain/resources/index.html @@ -3,7 +3,7 @@ - SatsPrice (DOM) + SatsPrice