Persist exchange rates, selected currencies, and price source via SQLDelight
Adds a SQLDelight-backed AppDatabase (Android, Desktop, iOS/macOS) so the converter survives restarts: the last-known rates per price source, the user's selected fiat currencies (with order preserved), and the last-used price source all reload before the first network fetch completes. Web isn't a shipped platform and SQLDelight's driver there needs a worker plus a wasm sqlite binary, so it gets a lighter localStorage-backed implementation of the same store interfaces instead. Android needs an app Context for its driver, wired via a new SatsPriceApplication. iOS/macOS need libsqlite3 linked explicitly (Swift's autolinking only covers Shared.framework itself, not its C library dependencies), plus two extension-loading symbols marked optional since macOS's system sqlite build omits them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
import xyz.tyiu.satsprice.data.ExchangeRates
|
||||
|
||||
/** Persists the last known [ExchangeRates] fetched from each price source, keyed by [ExchangeRateSource.id]. */
|
||||
interface ExchangeRateStore {
|
||||
suspend fun loadLastKnownRates(sourceId: String): ExchangeRates?
|
||||
suspend fun saveRates(sourceId: String, rates: ExchangeRates)
|
||||
}
|
||||
|
||||
expect fun createExchangeRateStore(): ExchangeRateStore
|
||||
@@ -0,0 +1,9 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
/** Persists the user's selected fiat currency codes, preserving the order they're displayed in. */
|
||||
interface SelectedCurrenciesStore {
|
||||
suspend fun loadSelectedCurrencies(): List<String>
|
||||
suspend fun saveSelectedCurrencies(codes: List<String>)
|
||||
}
|
||||
|
||||
expect fun createSelectedCurrenciesStore(): SelectedCurrenciesStore
|
||||
@@ -0,0 +1,9 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
/** Persists the id of the last price source the user selected. */
|
||||
interface SelectedSourceStore {
|
||||
suspend fun loadSelectedSourceId(): String?
|
||||
suspend fun saveSelectedSourceId(sourceId: String)
|
||||
}
|
||||
|
||||
expect fun createSelectedSourceStore(): SelectedSourceStore
|
||||
@@ -0,0 +1,68 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.tyiu.satsprice.data.ExchangeRates
|
||||
import xyz.tyiu.satsprice.db.AppDatabase
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Shared by every platform that opens a real [AppDatabase] (Android, Desktop, iOS/macOS) — only
|
||||
* driver construction differs per platform, so that's the only expect/actual boundary needed.
|
||||
* Web isn't a shipped platform (see README's Supported Platforms) and gets an in-memory fallback
|
||||
* instead of a real driver.
|
||||
*/
|
||||
internal class SqlDelightExchangeRateStore(private val database: AppDatabase) : ExchangeRateStore {
|
||||
override suspend fun loadLastKnownRates(sourceId: String): ExchangeRates? = withContext(Dispatchers.Default) {
|
||||
val rows = database.exchangeRateQueries.selectForSource(sourceId).executeAsList()
|
||||
val first = rows.firstOrNull() ?: return@withContext null
|
||||
ExchangeRates(
|
||||
base = first.base,
|
||||
rates = rows.associate { it.currencyCode to BigDecimal.parseString(it.rate) },
|
||||
fetchedAt = Instant.fromEpochMilliseconds(first.fetchedAt),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun saveRates(sourceId: String, rates: ExchangeRates) = withContext(Dispatchers.Default) {
|
||||
database.exchangeRateQueries.transaction {
|
||||
database.exchangeRateQueries.deleteForSource(sourceId)
|
||||
rates.rates.forEach { (currencyCode, rate) ->
|
||||
database.exchangeRateQueries.insertRate(
|
||||
sourceId = sourceId,
|
||||
base = rates.base,
|
||||
currencyCode = currencyCode,
|
||||
rate = rate.toStringExpanded(),
|
||||
fetchedAt = rates.fetchedAt.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlDelightSelectedCurrenciesStore(private val database: AppDatabase) : SelectedCurrenciesStore {
|
||||
override suspend fun loadSelectedCurrencies(): List<String> = withContext(Dispatchers.Default) {
|
||||
database.selectedCurrencyQueries.selectAllOrdered().executeAsList().map { it.code }
|
||||
}
|
||||
|
||||
override suspend fun saveSelectedCurrencies(codes: List<String>) = withContext(Dispatchers.Default) {
|
||||
database.selectedCurrencyQueries.transaction {
|
||||
database.selectedCurrencyQueries.deleteAll()
|
||||
codes.forEachIndexed { index, code ->
|
||||
database.selectedCurrencyQueries.insertSelected(code = code, position = index.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlDelightSelectedSourceStore(private val database: AppDatabase) : SelectedSourceStore {
|
||||
override suspend fun loadSelectedSourceId(): String? = withContext(Dispatchers.Default) {
|
||||
database.selectedSourceQueries.select().executeAsOneOrNull()?.sourceId
|
||||
}
|
||||
|
||||
override suspend fun saveSelectedSourceId(sourceId: String) = withContext(Dispatchers.Default) {
|
||||
database.selectedSourceQueries.transaction {
|
||||
database.selectedSourceQueries.upsert(sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ import xyz.tyiu.satsprice.data.ExchangeRateSource
|
||||
import xyz.tyiu.satsprice.data.ExchangeRates
|
||||
import xyz.tyiu.satsprice.data.ManualExchangeRateSource
|
||||
import xyz.tyiu.satsprice.data.createHttpClient
|
||||
import xyz.tyiu.satsprice.data.db.ExchangeRateStore
|
||||
import xyz.tyiu.satsprice.data.db.SelectedCurrenciesStore
|
||||
import xyz.tyiu.satsprice.data.db.SelectedSourceStore
|
||||
import xyz.tyiu.satsprice.data.db.createExchangeRateStore
|
||||
import xyz.tyiu.satsprice.data.db.createSelectedCurrenciesStore
|
||||
import xyz.tyiu.satsprice.data.db.createSelectedSourceStore
|
||||
import xyz.tyiu.satsprice.domain.CurrencyConverter
|
||||
import xyz.tyiu.satsprice.domain.formatAmount
|
||||
import xyz.tyiu.satsprice.domain.formatAmountFixed
|
||||
@@ -59,6 +65,9 @@ class PriceViewModel(
|
||||
private val manualSource: ManualExchangeRateSource = ManualExchangeRateSource(),
|
||||
private val coinbaseSource: ExchangeRateSource = CoinbaseExchangeRateSource(httpClient),
|
||||
private val coinGeckoSource: ExchangeRateSource = CoinGeckoExchangeRateSource(httpClient),
|
||||
private val exchangeRateStore: ExchangeRateStore = createExchangeRateStore(),
|
||||
private val selectedCurrenciesStore: SelectedCurrenciesStore = createSelectedCurrenciesStore(),
|
||||
private val selectedSourceStore: SelectedSourceStore = createSelectedSourceStore(),
|
||||
) : ViewModel() {
|
||||
|
||||
private val sources: List<ExchangeRateSource> = listOf(coinbaseSource, coinGeckoSource, manualSource)
|
||||
@@ -84,10 +93,25 @@ class PriceViewModel(
|
||||
val uiState: StateFlow<ConverterUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var rates: ExchangeRates? = null
|
||||
private var lastPersistedSelection: List<String>? = null
|
||||
|
||||
init {
|
||||
manualSource.currencyCode = defaultCurrencyCode
|
||||
viewModelScope.launch {
|
||||
selectedCurrenciesStore.loadSelectedCurrencies().takeIf { it.isNotEmpty() }?.let { persisted ->
|
||||
lastPersistedSelection = persisted
|
||||
val selection = if (defaultCurrencyCode in persisted) persisted else listOf(defaultCurrencyCode) + persisted
|
||||
_uiState.update { it.copy(selectedFiatCurrencies = selection) }
|
||||
}
|
||||
selectedSourceStore.loadSelectedSourceId()
|
||||
?.let { id -> sources.firstOrNull { it.id == id } }
|
||||
?.let { persisted ->
|
||||
currentSource = persisted
|
||||
_uiState.update {
|
||||
it.copy(sourceName = persisted.displayName, isManualSource = persisted === manualSource)
|
||||
}
|
||||
}
|
||||
seedFromCache(currentSource)
|
||||
while (isActive) {
|
||||
refresh()
|
||||
delay(AUTO_REFRESH_INTERVAL_MILLIS)
|
||||
@@ -95,6 +119,19 @@ class PriceViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the last known rates for [source] immediately, before its network fetch completes. */
|
||||
private suspend fun seedFromCache(source: ExchangeRateSource) {
|
||||
val cached = exchangeRateStore.loadLastKnownRates(source.id) ?: return
|
||||
rates = cached
|
||||
_uiState.update { state -> recomputeFromKnownField(state.copy(lastUpdated = cached.fetchedAt), cached) }
|
||||
}
|
||||
|
||||
private fun persistSelectionIfChanged(selection: List<String>) {
|
||||
if (selection == lastPersistedSelection) return
|
||||
lastPersistedSelection = selection
|
||||
viewModelScope.launch { selectedCurrenciesStore.saveSelectedCurrencies(selection) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (currentSource === manualSource && manualSource.rate == null) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = null) }
|
||||
@@ -105,6 +142,8 @@ class PriceViewModel(
|
||||
try {
|
||||
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) }
|
||||
@@ -119,6 +158,7 @@ class PriceViewModel(
|
||||
// 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,
|
||||
@@ -130,6 +170,7 @@ class PriceViewModel(
|
||||
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.
|
||||
@@ -157,6 +198,7 @@ class PriceViewModel(
|
||||
val newState = state.copy(selectedFiatCurrencies = newSelection)
|
||||
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
||||
}
|
||||
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +216,7 @@ class PriceViewModel(
|
||||
val newState = state.copy(selectedFiatCurrencies = newSelection)
|
||||
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
||||
}
|
||||
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
|
||||
}
|
||||
|
||||
fun onSourceSelected(displayName: String) {
|
||||
@@ -187,7 +230,11 @@ class PriceViewModel(
|
||||
rateDisplays = emptyMap(),
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
selectedSourceStore.saveSelectedSourceId(selected.id)
|
||||
seedFromCache(selected)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun onManualRateChanged(value: String) {
|
||||
|
||||
Reference in New Issue
Block a user