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") + } +}