Always show all currencies, flagging ones the active source doesn't price
Previously a currency the active price source had no rate for was hidden entirely, so switching sources (e.g. to CoinGecko, which quotes far fewer fiat currencies than Coinbase) could silently drop it from the picker and even from the user's own selection. Every system currency is now always offered. ConverterUiState tracks which codes the latest fetch actually priced, and both UIs (Compose and the native SwiftUI screen, since iOS/macOS don't render Compose UI at all) show a "Not priced by <source>" indicator for the rest, with the amount field emptied and disabled rather than showing a stale or meaningless value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
This commit is contained in:
@@ -37,6 +37,9 @@ fun ConverterUiState.selectedOtherCurrencies(): List<CurrencyInfo> =
|
||||
fun ConverterUiState.unselectedCurrencies(): List<CurrencyInfo> =
|
||||
availableFiatCurrencies.filterNot { it.code in selectedFiatCurrencies }
|
||||
|
||||
/** Whether the active price source quotes a rate for [code] — every currency is listed regardless. */
|
||||
fun ConverterUiState.isPriced(code: String): Boolean = code in pricedCurrencyCodes
|
||||
|
||||
private fun Instant.toDateTimeString(): String {
|
||||
val local = toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
return local.toString().substringBefore('.').replace('T', ' ')
|
||||
|
||||
@@ -290,12 +290,20 @@ fun PriceScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val isPriced = state.isPriced(code)
|
||||
OutlinedTextField(
|
||||
value = state.fiatAmounts[code].orEmpty(),
|
||||
value = if (isPriced) state.fiatAmounts[code].orEmpty() else "",
|
||||
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
|
||||
label = { Text(code) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
enabled = isPriced,
|
||||
isError = !isPriced,
|
||||
supportingText = if (isPriced) {
|
||||
null
|
||||
} else {
|
||||
{ Text(stringResource(MR.strings.currency_not_priced, state.sourceName)) }
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
CurrencyRowMenu(
|
||||
@@ -422,6 +430,8 @@ private fun CurrencyPickerScreen(
|
||||
CurrencyRow(
|
||||
info = state.currentCurrency(),
|
||||
isSelected = true,
|
||||
isPriced = state.isPriced(state.currentCurrency().code),
|
||||
sourceName = state.sourceName,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = null,
|
||||
)
|
||||
@@ -439,6 +449,8 @@ private fun CurrencyPickerScreen(
|
||||
CurrencyRow(
|
||||
info = info,
|
||||
isSelected = true,
|
||||
isPriced = state.isPriced(info.code),
|
||||
sourceName = state.sourceName,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = { onToggle(info.code) },
|
||||
)
|
||||
@@ -458,6 +470,8 @@ private fun CurrencyPickerScreen(
|
||||
CurrencyRow(
|
||||
info = info,
|
||||
isSelected = false,
|
||||
isPriced = state.isPriced(info.code),
|
||||
sourceName = state.sourceName,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = { onToggle(info.code) },
|
||||
)
|
||||
@@ -473,6 +487,8 @@ private fun CurrencyPickerScreen(
|
||||
private fun CurrencyRow(
|
||||
info: CurrencyInfo,
|
||||
isSelected: Boolean,
|
||||
isPriced: Boolean,
|
||||
sourceName: String,
|
||||
localeCurrencyCode: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
@@ -489,7 +505,16 @@ private fun CurrencyRow(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label)
|
||||
Column {
|
||||
Text(label)
|
||||
if (!isPriced) {
|
||||
Text(
|
||||
stringResource(MR.strings.currency_not_priced, sourceName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
|
||||
@@ -44,6 +44,7 @@ data class ConverterUiState(
|
||||
val fiatAmounts: Map<String, String> = emptyMap(),
|
||||
val rateDisplays: Map<String, String> = emptyMap(),
|
||||
val availableFiatCurrencies: List<CurrencyInfo> = emptyList(),
|
||||
val pricedCurrencyCodes: Set<String> = emptySet(),
|
||||
val localeCurrencyCode: String? = null,
|
||||
val defaultCurrencyCode: String = "USD",
|
||||
val sourceName: String = "",
|
||||
@@ -85,6 +86,13 @@ class PriceViewModel(
|
||||
private val _uiState = MutableStateFlow(
|
||||
ConverterUiState(
|
||||
selectedFiatCurrencies = listOf(defaultCurrencyCode),
|
||||
// Every system currency is offered regardless of whether the active source prices
|
||||
// it — CurrencyRow/OutlinedTextField indicate unpriced ones instead of hiding them,
|
||||
// so switching sources doesn't silently drop currencies from the user's selection.
|
||||
availableFiatCurrencies = systemCurrencyList.let { list ->
|
||||
val (matching, rest) = list.partition { it.code == defaultCurrencyCode }
|
||||
matching + rest
|
||||
},
|
||||
localeCurrencyCode = localCurrencyCode,
|
||||
defaultCurrencyCode = defaultCurrencyCode,
|
||||
sourceName = coinbaseSource.displayName,
|
||||
@@ -143,34 +151,12 @@ class PriceViewModel(
|
||||
val newRates = currentSource.getRates("BTC")
|
||||
rates = newRates
|
||||
exchangeRateStore.saveRates(currentSource.id, newRates)
|
||||
var selectionToPersist: List<String>? = null
|
||||
_uiState.update { state ->
|
||||
val available = systemCurrencyList
|
||||
.filter { newRates.rates.containsKey(it.code) }
|
||||
.let { list ->
|
||||
val (matching, rest) = list.partition { it.code == defaultCurrencyCode }
|
||||
matching + rest
|
||||
}
|
||||
val availableCodes = available.map { it.code }.toSet()
|
||||
// defaultCurrencyCode is the pinned, non-removable "Current Currency" and must
|
||||
// always be present, even if it briefly lacks a rate; everything else is
|
||||
// dropped once its rate disappears. Filtering (rather than re-deriving) keeps
|
||||
// whatever order the user picked via onFiatCurrenciesReordered.
|
||||
val filtered = state.selectedFiatCurrencies.filter { it == defaultCurrencyCode || it in availableCodes }
|
||||
val selection = if (defaultCurrencyCode in filtered) filtered else listOf(defaultCurrencyCode) + filtered
|
||||
selectionToPersist = selection
|
||||
recomputeFromKnownField(
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = null,
|
||||
availableFiatCurrencies = available,
|
||||
selectedFiatCurrencies = selection,
|
||||
lastUpdated = newRates.fetchedAt,
|
||||
),
|
||||
state.copy(isLoading = false, errorMessage = null, lastUpdated = newRates.fetchedAt),
|
||||
newRates,
|
||||
)
|
||||
}
|
||||
selectionToPersist?.let { persistSelectionIfChanged(it) }
|
||||
} catch (e: Exception) {
|
||||
// Not localized: this shared ViewModel has no platform Context to resolve a moko-resources
|
||||
// string on Android, and no locale-aware synchronous resolution path that works everywhere.
|
||||
@@ -313,10 +299,11 @@ class PriceViewModel(
|
||||
val rateDisplays = (state.selectedFiatCurrencies + state.defaultCurrencyCode).distinct().associateWith { code ->
|
||||
rates.rates[code]?.let { formatAmountFixed(it, decimalDigitsFor(code)) } ?: ""
|
||||
}
|
||||
val withRateInfo = state.copy(pricedCurrencyCodes = rates.rates.keys, rateDisplays = rateDisplays)
|
||||
|
||||
if (btcValue == null) return state.copy(rateDisplays = rateDisplays)
|
||||
if (btcValue == null) return withRateInfo
|
||||
|
||||
return state.copy(
|
||||
return withRateInfo.copy(
|
||||
btcAmount = if (btc != null) state.btcAmount else formatAmount(btcValue, 8),
|
||||
satsAmount = if (sats != null) state.satsAmount else formatAmount(CurrencyConverter.btcToSats(btcValue), 0),
|
||||
fiatAmounts = state.selectedFiatCurrencies.associateWith { code ->
|
||||
@@ -328,7 +315,6 @@ class PriceViewModel(
|
||||
?: state.fiatAmounts[code].orEmpty()
|
||||
}
|
||||
},
|
||||
rateDisplays = rateDisplays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user