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:
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user