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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
+91
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user