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:
2026-09-08 17:19:48 +03:00
co-authored by Claude Sonnet 5
parent 84df992a82
commit b0f3744851
8 changed files with 170 additions and 32 deletions
@@ -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,
)
}
}