From b0f3744851df1fe5f3d16a9186a1931c513e4d40 Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Tue, 8 Sep 2026 17:19:48 +0300 Subject: [PATCH] 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 " 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 Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp --- iosApp/iosApp/ContentView.swift | 21 ++++- iosApp/iosApp/CurrencyPickerSheet.swift | 17 +++- .../xyz/tyiu/satsprice/IosPriceViewModel.kt | 2 + .../satsprice/ui/ConverterUiStateDisplay.kt | 3 + .../xyz/tyiu/satsprice/ui/PriceScreen.kt | 29 +++++- .../xyz/tyiu/satsprice/ui/PriceViewModel.kt | 38 +++----- .../moko-resources/base/strings.xml | 1 + .../PriceViewModelCurrencyAvailabilityTest.kt | 91 +++++++++++++++++++ 8 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/ui/PriceViewModelCurrencyAvailabilityTest.kt diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 136e248..2ba0464 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -129,6 +129,8 @@ struct ContentView: View { keyboardType: .decimalPad, sanitize: sanitizeDecimalInput, onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) }, + isPriced: state.pricedCurrencyCodes.contains(row.code), + sourceName: state.sourceName, onMoveUp: index > 0 ? { var codes = state.fiatRows.map(\.code) codes.move(fromOffsets: [index], toOffset: index - 1) @@ -164,6 +166,8 @@ struct ContentView: View { currentCurrency: state.currentCurrency, selectedOtherCurrencies: state.selectedOtherCurrencies, unselectedCurrencies: state.unselectedCurrencies, + pricedCurrencyCodes: state.pricedCurrencyCodes, + sourceName: state.sourceName, localeCurrencyCode: state.localeCurrencyCode, onToggle: { viewModel.onFiatCurrencyToggled($0) } ) @@ -202,6 +206,8 @@ struct ContentView: View { keyboardType: NumericFieldKeyboard, sanitize: @escaping (String) -> String, onChange: @escaping (String) -> Void, + isPriced: Bool = true, + sourceName: String = "", onMoveUp: (() -> Void)? = nil, onMoveDown: (() -> Void)? = nil ) -> some View { @@ -226,16 +232,27 @@ struct ContentView: View { .buttonStyle(.borderless) } #endif - Text(label) + VStack(alignment: .leading) { + Text(label) + if !isPriced { + Text(IosLocalizationKt.localizedFormattedString( + resource: MR.strings.shared.currency_not_priced, + args: [sourceName] + )) + .font(.caption2) + .foregroundColor(.red) + } + } Spacer() NumericField( placeholder: "", - value: value, + value: isPriced ? value : "", keyboardType: keyboardType, sanitize: sanitize, onChange: onChange, alignment: .trailing ) + .disabled(!isPriced) } } } diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index 3f65493..8228af1 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -5,6 +5,8 @@ struct CurrencyPickerSheet: View { let currentCurrency: CurrencyInfo let selectedOtherCurrencies: [CurrencyInfo] let unselectedCurrencies: [CurrencyInfo] + let pricedCurrencyCodes: [String] + let sourceName: String let localeCurrencyCode: String? let onToggle: (String) -> Void @@ -51,9 +53,20 @@ struct CurrencyPickerSheet: View { @ViewBuilder private func currencyRow(for info: CurrencyInfo, isSelected: Bool, onTap: (() -> Void)?) -> some View { + let isPriced = pricedCurrencyCodes.contains(info.code) let content = HStack { - Text(currencyLabel(for: info)) - .foregroundColor(.primary) + VStack(alignment: .leading) { + Text(currencyLabel(for: info)) + .foregroundColor(.primary) + if !isPriced { + Text(IosLocalizationKt.localizedFormattedString( + resource: MR.strings.shared.currency_not_priced, + args: [sourceName] + )) + .font(.caption) + .foregroundColor(.red) + } + } Spacer() if isSelected { Image(systemName: "checkmark") diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt index d94c589..e3eccd4 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt @@ -24,6 +24,7 @@ data class IosConverterState( val selectedOtherCurrencies: List, val unselectedCurrencies: List, val selectedCurrencyCodes: List, + val pricedCurrencyCodes: List, 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, diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/ConverterUiStateDisplay.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/ConverterUiStateDisplay.kt index ca488e2..fcf1a64 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/ConverterUiStateDisplay.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/ConverterUiStateDisplay.kt @@ -37,6 +37,9 @@ fun ConverterUiState.selectedOtherCurrencies(): List = fun ConverterUiState.unselectedCurrencies(): List = 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', ' ') diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index 01d0214..374808d 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt index ca02cce..78cab3c 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt @@ -44,6 +44,7 @@ data class ConverterUiState( val fiatAmounts: Map = emptyMap(), val rateDisplays: Map = emptyMap(), val availableFiatCurrencies: List = emptyList(), + val pricedCurrencyCodes: Set = 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? = 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, ) } } diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 1d68886..f3446fa 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -6,6 +6,7 @@ Currencies %1$d selected Current Currency + Not priced by %1$s %1$s - %2$s %1$s - %2$s Options for %1$s diff --git a/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/ui/PriceViewModelCurrencyAvailabilityTest.kt b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/ui/PriceViewModelCurrencyAvailabilityTest.kt new file mode 100644 index 0000000..fe1f98d --- /dev/null +++ b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/ui/PriceViewModelCurrencyAvailabilityTest.kt @@ -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, +) : 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 = emptyList() + override suspend fun saveSelectedCurrencies(codes: List) = 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") + } +}