Generate webHtmlApp's strings, collapse a redundant string key
Strings.kt is now generated at build time (:webHtmlApp:generateStrings, in webHtmlApp/build.gradle.kts) by parsing shared's strings.xml directly, rather than a hand-maintained duplicate that could silently drift from it. Names are mechanical camelCase of the XML key, so a few call sites got more verbose (e.g. moveUp -> moveCurrencyUpContentDescription) in exchange for zero manual sync. Only covers the base English locale. Also collapses currency_option_label_local into currency_option_label: the two had identical English text and existed only as a hook for a future translation to phrase the user's own currency differently, which no locale currently does. Removes the info.code == localeCurrencyCode branching (and the now-pointless localeCurrencyCode parameter/field) from all three UI implementations - PriceScreen.kt, CurrencyPickerSheet.swift, and HtmlCurrencyPicker.kt - plus IosConverterState.localeCurrencyCode, which existed solely to feed that branch on the Swift side. Fixes a real regression surfaced while verifying the above by actually building the Xcode project (not done since currencyFlagEmoji() gained its supportsFlagEmoji parameter): Kotlin/Native doesn't generate default-parameter overloads for Swift/ObjC-exported functions, so both Swift call sites needed the argument passed explicitly, via the correct exported facade name for an expect/actual pair - the actual file's (SystemCurrencies_appleKt), not the common one (SystemCurrenciesKt). Also drops "(DOM)" from webHtmlApp's page title. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,6 @@ data class IosConverterState(
|
||||
val unselectedCurrencies: List<CurrencyInfo>,
|
||||
val selectedCurrencyCodes: List<String>,
|
||||
val pricedCurrencyCodes: List<String>,
|
||||
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,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<string name="current_currency_section_title">Current Currency</string>
|
||||
<string name="currency_not_priced">Not priced by %1$s</string>
|
||||
<string name="currency_option_label">%1$s - %2$s</string>
|
||||
<string name="currency_option_label_local">%1$s - %2$s</string>
|
||||
<string name="currency_options_content_description">Options for %1$s</string>
|
||||
<string name="currency_to_sats">%1$s to Sats</string>
|
||||
<string name="done">Done</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SatsPrice (DOM)</title>
|
||||
<title>SatsPrice</title>
|
||||
<meta name="description" content="SatsPrice converts Bitcoin, satoshis, and fiat currency in real time — Compose HTML build.">
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
|
||||
Reference in New Issue
Block a user