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:
2026-09-08 09:34:21 +03:00
co-authored by Claude Sonnet 5
parent 8a73b140c4
commit 14298d450e
20 changed files with 506 additions and 3 deletions
@@ -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) {