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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
This commit is contained in:
2026-09-10 11:16:35 +03:00
co-authored by Claude Sonnet 5
parent bf91abdd12
commit 43b66b6665
5 changed files with 73 additions and 12 deletions
+16 -7
View File
@@ -105,13 +105,22 @@ struct ContentView: View {
} }
} }
if !state.oneCurrencyToSats.isEmpty { HStack {
HStack { Text(IosLocalizationKt.localizedFormattedString(
Text(IosLocalizationKt.localizedFormattedString( resource: MR.strings.shared.currency_to_sats,
resource: MR.strings.shared.currency_to_sats, args: [state.defaultCurrencyCode]
args: [state.defaultCurrencyCode] ))
)) Spacer()
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)) Text(NumberFormatKt.groupDigits(value: state.oneCurrencyToSats))
} }
} }
+4
View File
@@ -46,4 +46,8 @@ final class ConverterViewModel: ObservableObject {
func onManualRateChanged(_ value: String) { func onManualRateChanged(_ value: String) {
bridge.onManualRateChanged(value: value) bridge.onManualRateChanged(value: value)
} }
func onManualSatsPerCurrencyChanged(_ value: String) {
bridge.onManualSatsPerCurrencyChanged(value: value)
}
} }
@@ -31,6 +31,7 @@ data class IosConverterState(
val sourceName: String, val sourceName: String,
val isManualSource: Boolean, val isManualSource: Boolean,
val manualRateInput: String, val manualRateInput: String,
val manualSatsPerCurrencyInput: String,
val isLoading: Boolean, val isLoading: Boolean,
val errorMessage: String?, val errorMessage: String?,
val statusLine: String, val statusLine: String,
@@ -64,6 +65,7 @@ class IosPriceViewModel {
fun onFiatCurrenciesReordered(newOrder: List<String>) = viewModel.onFiatCurrenciesReordered(newOrder) fun onFiatCurrenciesReordered(newOrder: List<String>) = viewModel.onFiatCurrenciesReordered(newOrder)
fun onSourceSelected(name: String) = viewModel.onSourceSelected(name) fun onSourceSelected(name: String) = viewModel.onSourceSelected(name)
fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value) fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value)
fun onManualSatsPerCurrencyChanged(value: String) = viewModel.onManualSatsPerCurrencyChanged(value)
} }
private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState( private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState(
@@ -83,6 +85,7 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
sourceName = sourceName, sourceName = sourceName,
isManualSource = isManualSource, isManualSource = isManualSource,
manualRateInput = manualRateInput, manualRateInput = manualRateInput,
manualSatsPerCurrencyInput = manualSatsPerCurrencyInput,
isLoading = isLoading, isLoading = isLoading,
errorMessage = errorMessage, errorMessage = errorMessage,
statusLine = statusLine(), statusLine = statusLine(),
@@ -209,12 +209,23 @@ fun PriceScreen(
} }
} }
val oneCurrencyToSats = state.oneCurrencyToSats() if (state.isManualSource) {
if (oneCurrencyToSats.isNotEmpty()) { 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( OutlinedTextField(
value = oneCurrencyToSats, value = oneCurrencyToSats,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
enabled = oneCurrencyToSats.isNotEmpty(),
label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) }, label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) },
singleLine = true, singleLine = true,
visualTransformation = DigitGroupingTransformation, visualTransformation = DigitGroupingTransformation,
@@ -50,6 +50,7 @@ data class ConverterUiState(
val sourceName: String = "", val sourceName: String = "",
val isManualSource: Boolean = false, val isManualSource: Boolean = false,
val manualRateInput: String = "", val manualRateInput: String = "",
val manualSatsPerCurrencyInput: String = "",
val isLoading: Boolean = true, val isLoading: Boolean = true,
val errorMessage: String? = null, val errorMessage: String? = null,
val lastUpdated: Instant? = null, val lastUpdated: Instant? = null,
@@ -239,7 +240,12 @@ class PriceViewModel(
if (seedRate != null) { if (seedRate != null) {
manualSource.rate = seedRate manualSource.rate = seedRate
_uiState.update { _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() refresh()
@@ -260,13 +266,41 @@ class PriceViewModel(
exchangeRateStore.loadLastKnownRates(source.id)?.rates?.get(defaultCurrencyCode) 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) { fun onManualRateChanged(value: String) {
val sanitized = sanitizeDecimalInput(value) 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 — // 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 // 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. // 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() refresh()
} }