From 43b66b6665abf729f9faf3e9beb6a5c0f6bbccc2 Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Thu, 10 Sep 2026 11:16:35 +0300 Subject: [PATCH] Keep the "currency to Sats" field visible, make it editable in Manual Always render the field now (label included) with just its value empty when unknown, rather than hiding the whole field. In Manual source mode it's now a genuine input: typing into it recomputes the "BTC to currency" rate the same way typing into that field recomputes this one. Both edit the same underlying ManualExchangeRateSource.rate via the same self-inverse division (CurrencyConverter.satsPerCurrencyUnit of a rate gives Sats, and of a Sats amount gives a rate back), tracked as a new manualSatsPerCurrencyInput field alongside the existing manualRateInput. Wired through PriceViewModel, the iOS bridge/ConverterViewModel, and both UIs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy --- iosApp/iosApp/ContentView.swift | 23 +++++++---- iosApp/iosApp/ConverterViewModel.swift | 4 ++ .../xyz/tyiu/satsprice/IosPriceViewModel.kt | 3 ++ .../xyz/tyiu/satsprice/ui/PriceScreen.kt | 15 ++++++- .../xyz/tyiu/satsprice/ui/PriceViewModel.kt | 40 +++++++++++++++++-- 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index fc5ebe3..7c7f882 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -105,13 +105,22 @@ struct ContentView: View { } } - if !state.oneCurrencyToSats.isEmpty { - HStack { - Text(IosLocalizationKt.localizedFormattedString( - resource: MR.strings.shared.currency_to_sats, - args: [state.defaultCurrencyCode] - )) - Spacer() + HStack { + Text(IosLocalizationKt.localizedFormattedString( + resource: MR.strings.shared.currency_to_sats, + args: [state.defaultCurrencyCode] + )) + Spacer() + if state.isManualSource { + NumericField( + placeholder: "", + value: state.manualSatsPerCurrencyInput, + keyboardType: .decimalPad, + sanitize: sanitizeDecimalInput, + onChange: { viewModel.onManualSatsPerCurrencyChanged($0) }, + alignment: .trailing + ) + } else if !state.oneCurrencyToSats.isEmpty { Text(NumberFormatKt.groupDigits(value: state.oneCurrencyToSats)) } } diff --git a/iosApp/iosApp/ConverterViewModel.swift b/iosApp/iosApp/ConverterViewModel.swift index 5ccb7f9..9fb7370 100644 --- a/iosApp/iosApp/ConverterViewModel.swift +++ b/iosApp/iosApp/ConverterViewModel.swift @@ -46,4 +46,8 @@ final class ConverterViewModel: ObservableObject { func onManualRateChanged(_ value: String) { bridge.onManualRateChanged(value: value) } + + func onManualSatsPerCurrencyChanged(_ value: String) { + bridge.onManualSatsPerCurrencyChanged(value: value) + } } diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt index 13e7a31..1db6127 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt @@ -31,6 +31,7 @@ data class IosConverterState( val sourceName: String, val isManualSource: Boolean, val manualRateInput: String, + val manualSatsPerCurrencyInput: String, val isLoading: Boolean, val errorMessage: String?, val statusLine: String, @@ -64,6 +65,7 @@ class IosPriceViewModel { fun onFiatCurrenciesReordered(newOrder: List) = viewModel.onFiatCurrenciesReordered(newOrder) fun onSourceSelected(name: String) = viewModel.onSourceSelected(name) fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value) + fun onManualSatsPerCurrencyChanged(value: String) = viewModel.onManualSatsPerCurrencyChanged(value) } private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState( @@ -83,6 +85,7 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState sourceName = sourceName, isManualSource = isManualSource, manualRateInput = manualRateInput, + manualSatsPerCurrencyInput = manualSatsPerCurrencyInput, isLoading = isLoading, errorMessage = errorMessage, statusLine = statusLine(), diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index c949f2e..5f7c875 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -209,12 +209,23 @@ fun PriceScreen( } } - val oneCurrencyToSats = state.oneCurrencyToSats() - if (oneCurrencyToSats.isNotEmpty()) { + if (state.isManualSource) { + OutlinedTextField( + value = state.manualSatsPerCurrencyInput, + onValueChange = viewModel::onManualSatsPerCurrencyChanged, + label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + visualTransformation = DigitGroupingTransformation, + modifier = Modifier.fillMaxWidth(), + ) + } else { + val oneCurrencyToSats = state.oneCurrencyToSats() OutlinedTextField( value = oneCurrencyToSats, onValueChange = {}, readOnly = true, + enabled = oneCurrencyToSats.isNotEmpty(), label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) }, singleLine = true, visualTransformation = DigitGroupingTransformation, diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt index b0fd052..eda76ce 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt @@ -50,6 +50,7 @@ data class ConverterUiState( val sourceName: String = "", val isManualSource: Boolean = false, val manualRateInput: String = "", + val manualSatsPerCurrencyInput: String = "", val isLoading: Boolean = true, val errorMessage: String? = null, val lastUpdated: Instant? = null, @@ -239,7 +240,12 @@ class PriceViewModel( if (seedRate != null) { manualSource.rate = seedRate _uiState.update { - it.copy(manualRateInput = formatAmount(seedRate, decimalDigitsFor(defaultCurrencyCode))) + it.copy( + manualRateInput = formatAmount(seedRate, decimalDigitsFor(defaultCurrencyCode)), + manualSatsPerCurrencyInput = CurrencyConverter.satsPerCurrencyUnit(seedRate) + ?.let { sats -> formatAmount(sats, 0) } + .orEmpty(), + ) } } refresh() @@ -260,13 +266,41 @@ class PriceViewModel( exchangeRateStore.loadLastKnownRates(source.id)?.rates?.get(defaultCurrencyCode) } + /** + * The "BTC to [defaultCurrencyCode]" and "[defaultCurrencyCode] to Sats" fields edit the same + * underlying [ManualExchangeRateSource.rate] from two different angles — entering one always + * recomputes the other, via the same self-inverse division ([CurrencyConverter.satsPerCurrencyUnit] + * of a rate is a Sats amount, and of a Sats amount is a rate). + */ fun onManualRateChanged(value: String) { val sanitized = sanitizeDecimalInput(value) - _uiState.update { it.copy(manualRateInput = sanitized) } // Also cleared (rather than left as the last valid rate) when sanitized fails to parse — // e.g. the field is emptied, or is mid-edit on an incomplete number — so refresh() then // wipes any fiat amounts computed from it, rather than leaving stale ones on screen. - manualSource.rate = sanitized.toBigDecimalOrNull() + val parsed = sanitized.toBigDecimalOrNull() + manualSource.rate = parsed + _uiState.update { + it.copy( + manualRateInput = sanitized, + manualSatsPerCurrencyInput = parsed?.let { rate -> CurrencyConverter.satsPerCurrencyUnit(rate) } + ?.let { sats -> formatAmount(sats, 0) } + .orEmpty(), + ) + } + refresh() + } + + fun onManualSatsPerCurrencyChanged(value: String) { + val sanitized = sanitizeDecimalInput(value) + val parsedSats = sanitized.toBigDecimalOrNull() + val rate = parsedSats?.let { sats -> CurrencyConverter.satsPerCurrencyUnit(sats) } + manualSource.rate = rate + _uiState.update { + it.copy( + manualSatsPerCurrencyInput = sanitized, + manualRateInput = rate?.let { formatAmount(it, decimalDigitsFor(defaultCurrencyCode)) }.orEmpty(), + ) + } refresh() }