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() }