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
@@ -24,6 +24,7 @@ data class IosConverterState(
val selectedOtherCurrencies: List<CurrencyInfo>,
val unselectedCurrencies: List<CurrencyInfo>,
val selectedCurrencyCodes: List<String>,
val pricedCurrencyCodes: List<String>,
val localeCurrencyCode: String?,
val defaultCurrencyCode: String,
val sourceName: String,
@@ -74,6 +75,7 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
selectedOtherCurrencies = selectedOtherCurrencies(),
unselectedCurrencies = unselectedCurrencies(),
selectedCurrencyCodes = selectedFiatCurrencies,
pricedCurrencyCodes = pricedCurrencyCodes.toList(),
localeCurrencyCode = localeCurrencyCode,
defaultCurrencyCode = defaultCurrencyCode,
sourceName = sourceName,
@@ -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,
)
}
}
@@ -6,6 +6,7 @@
<string name="currencies_section_title">Currencies</string>
<string name="currencies_selected_count">%1$d selected</string>
<string name="current_currency_section_title">Current Currency</string>
<string name="currency_not_priced">Not priced by %1$s</string>
<string name="currency_option_label">%1$s - %2$s</string>
<string name="currency_option_label_local">%1$s - %2$s</string>
<string name="currency_options_content_description">Options for %1$s</string>
@@ -0,0 +1,91 @@
package xyz.tyiu.satsprice.ui
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import xyz.tyiu.satsprice.data.ExchangeRateSource
import xyz.tyiu.satsprice.data.ExchangeRates
import xyz.tyiu.satsprice.data.db.ExchangeRateStore
import xyz.tyiu.satsprice.data.db.SelectedCurrenciesStore
import xyz.tyiu.satsprice.data.db.SelectedSourceStore
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Clock
private class FakeExchangeRateSource(
override val id: String,
override val displayName: String,
private val rates: Map<String, String>,
) : ExchangeRateSource {
override suspend fun getRates(base: String): ExchangeRates = ExchangeRates(
base = base,
rates = rates.mapValues { (_, value) -> BigDecimal.parseString(value) },
fetchedAt = Clock.System.now(),
)
}
private class InMemoryExchangeRateStore : ExchangeRateStore {
override suspend fun loadLastKnownRates(sourceId: String): ExchangeRates? = null
override suspend fun saveRates(sourceId: String, rates: ExchangeRates) = Unit
}
private class InMemorySelectedCurrenciesStore : SelectedCurrenciesStore {
override suspend fun loadSelectedCurrencies(): List<String> = emptyList()
override suspend fun saveSelectedCurrencies(codes: List<String>) = Unit
}
private class InMemorySelectedSourceStore : SelectedSourceStore {
override suspend fun loadSelectedSourceId(): String? = null
override suspend fun saveSelectedSourceId(sourceId: String) = Unit
}
/**
* A currency the active source doesn't price (like CoinGecko not quoting BTC in Albanian Lek)
* must still be offered rather than hidden — only its "unpriced" status should reflect that.
*
* Uses [UnconfinedTestDispatcher] directly (not `runTest`) so the ViewModel's `init` block — which
* launches a `while (isActive) { refresh(); delay(...) }` loop that runs for the ViewModel's whole
* lifetime — runs its first iteration eagerly and synchronously, then simply parks at `delay()`
* without a scheduler driving it further. `runTest`'s automatic advance-to-idle at scope exit would
* otherwise try to drain that loop forever, since it never completes on its own.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class PriceViewModelCurrencyAvailabilityTest {
@BeforeTest
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun currencyWithoutARateStillAppearsButIsMarkedUnpriced() {
val limitedSource = FakeExchangeRateSource(
id = "coingecko",
displayName = "CoinGecko",
rates = mapOf("USD" to "65000"),
)
val viewModel = PriceViewModel(
coinbaseSource = limitedSource,
coinGeckoSource = limitedSource,
exchangeRateStore = InMemoryExchangeRateStore(),
selectedCurrenciesStore = InMemorySelectedCurrenciesStore(),
selectedSourceStore = InMemorySelectedSourceStore(),
)
val state = viewModel.uiState.value
assertTrue(state.availableFiatCurrencies.any { it.code == "ALL" }, "ALL should still be listed")
assertFalse(state.isPriced("ALL"), "ALL has no rate from this source, so it should be marked unpriced")
assertTrue(state.isPriced("USD"), "USD does have a rate from this source")
}
}