diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index cdba621..ce8ce2e 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + suspend fun saveSelectedCurrencies(codes: List) +} + +expect fun createSelectedCurrenciesStore(): SelectedCurrenciesStore diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SelectedSourceStore.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SelectedSourceStore.kt new file mode 100644 index 0000000..601649b --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SelectedSourceStore.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.kt new file mode 100644 index 0000000..1c019a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.kt @@ -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 = withContext(Dispatchers.Default) { + database.selectedCurrencyQueries.selectAllOrdered().executeAsList().map { it.code } + } + + override suspend fun saveSelectedCurrencies(codes: List) = 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) + } + } +} 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 ac77d48..ca02cce 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceViewModel.kt @@ -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 = listOf(coinbaseSource, coinGeckoSource, manualSource) @@ -84,10 +93,25 @@ class PriceViewModel( val uiState: StateFlow = _uiState.asStateFlow() private var rates: ExchangeRates? = null + private var lastPersistedSelection: List? = 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) { + 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? = 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) { diff --git a/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/ExchangeRate.sq b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/ExchangeRate.sq new file mode 100644 index 0000000..166e5ad --- /dev/null +++ b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/ExchangeRate.sq @@ -0,0 +1,18 @@ +CREATE TABLE exchangeRateEntity ( + sourceId TEXT NOT NULL, + base TEXT NOT NULL, + currencyCode TEXT NOT NULL, + rate TEXT NOT NULL, + fetchedAt INTEGER NOT NULL, + PRIMARY KEY (sourceId, currencyCode) +); + +selectForSource: +SELECT * FROM exchangeRateEntity WHERE sourceId = ?; + +deleteForSource: +DELETE FROM exchangeRateEntity WHERE sourceId = ?; + +insertRate: +INSERT OR REPLACE INTO exchangeRateEntity (sourceId, base, currencyCode, rate, fetchedAt) +VALUES (?, ?, ?, ?, ?); diff --git a/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedCurrency.sq b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedCurrency.sq new file mode 100644 index 0000000..1ac5dc0 --- /dev/null +++ b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedCurrency.sq @@ -0,0 +1,14 @@ +CREATE TABLE selectedCurrencyEntity ( + code TEXT NOT NULL PRIMARY KEY, + position INTEGER NOT NULL +); + +selectAllOrdered: +SELECT * FROM selectedCurrencyEntity ORDER BY position ASC; + +deleteAll: +DELETE FROM selectedCurrencyEntity; + +insertSelected: +INSERT INTO selectedCurrencyEntity (code, position) +VALUES (?, ?); diff --git a/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedSource.sq b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedSource.sq new file mode 100644 index 0000000..6ddeca1 --- /dev/null +++ b/shared/src/commonMain/sqldelight/xyz/tyiu/satsprice/db/SelectedSource.sq @@ -0,0 +1,11 @@ +CREATE TABLE selectedSourceEntity ( + id INTEGER NOT NULL PRIMARY KEY, + sourceId TEXT NOT NULL +); + +select: +SELECT * FROM selectedSourceEntity WHERE id = 0; + +upsert: +INSERT OR REPLACE INTO selectedSourceEntity (id, sourceId) +VALUES (0, ?); diff --git a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.jvm.kt b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.jvm.kt new file mode 100644 index 0000000..fe22205 --- /dev/null +++ b/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStores.jvm.kt @@ -0,0 +1,20 @@ +package xyz.tyiu.satsprice.data.db + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import xyz.tyiu.satsprice.db.AppDatabase +import java.io.File + +private val database: AppDatabase by lazy { + val appDir = File(System.getProperty("user.home"), ".sats-price").apply { mkdirs() } + val databaseFile = File(appDir, "sats-price.db") + val isNewDatabase = !databaseFile.exists() + val driver = JdbcSqliteDriver("jdbc:sqlite:${databaseFile.absolutePath}") + if (isNewDatabase) { + AppDatabase.Schema.create(driver) + } + AppDatabase(driver) +} + +actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database) +actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database) +actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database) diff --git a/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStoresTest.kt b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStoresTest.kt new file mode 100644 index 0000000..45c4db3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/xyz/tyiu/satsprice/data/db/SqlDelightStoresTest.kt @@ -0,0 +1,74 @@ +package xyz.tyiu.satsprice.data.db + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import kotlinx.coroutines.test.runTest +import xyz.tyiu.satsprice.data.ExchangeRates +import xyz.tyiu.satsprice.db.AppDatabase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class SqlDelightStoresTest { + + private fun newDatabase(): AppDatabase { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + AppDatabase.Schema.create(driver) + return AppDatabase(driver) + } + + @Test + fun exchangeRateStoreRoundTripsRatesPerSource() = runTest { + val store = SqlDelightExchangeRateStore(newDatabase()) + val rates = ExchangeRates( + base = "BTC", + rates = mapOf("USD" to BigDecimal.parseString("65000.12"), "EUR" to BigDecimal.parseString("60000.5")), + fetchedAt = Instant.fromEpochMilliseconds(1_700_000_000_000), + ) + + assertNull(store.loadLastKnownRates("coinbase")) + + store.saveRates("coinbase", rates) + val loaded = store.loadLastKnownRates("coinbase") + assertEquals(rates.base, loaded?.base) + assertEquals(rates.rates, loaded?.rates) + assertEquals(rates.fetchedAt, loaded?.fetchedAt) + + // A different source's cache stays independent. + assertNull(store.loadLastKnownRates("coingecko")) + + // Saving again for the same source replaces the prior rates entirely. + val updated = rates.copy(rates = mapOf("USD" to BigDecimal.parseString("70000"))) + store.saveRates("coinbase", updated) + assertEquals(updated.rates, store.loadLastKnownRates("coinbase")?.rates) + } + + @Test + fun selectedCurrenciesStoreRoundTripsOrder() = runTest { + val store = SqlDelightSelectedCurrenciesStore(newDatabase()) + + assertEquals(emptyList(), store.loadSelectedCurrencies()) + + store.saveSelectedCurrencies(listOf("USD", "EUR", "JPY")) + assertEquals(listOf("USD", "EUR", "JPY"), store.loadSelectedCurrencies()) + + // Saving replaces the whole set, including order. + store.saveSelectedCurrencies(listOf("JPY", "USD")) + assertEquals(listOf("JPY", "USD"), store.loadSelectedCurrencies()) + } + + @Test + fun selectedSourceStoreRoundTripsLastUsedSource() = runTest { + val store = SqlDelightSelectedSourceStore(newDatabase()) + + assertNull(store.loadSelectedSourceId()) + + store.saveSelectedSourceId("coinbase") + assertEquals("coinbase", store.loadSelectedSourceId()) + + // Saving again replaces the previously selected source. + store.saveSelectedSourceId("manual") + assertEquals("manual", store.loadSelectedSourceId()) + } +} diff --git a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStores.kt b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStores.kt new file mode 100644 index 0000000..331217a --- /dev/null +++ b/shared/src/webMain/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStores.kt @@ -0,0 +1,83 @@ +package xyz.tyiu.satsprice.data.db + +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import kotlinx.browser.localStorage +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import xyz.tyiu.satsprice.data.ExchangeRates +import kotlin.time.Instant + +/** + * Web isn't a shipped platform (see README's Supported Platforms) and SQLDelight's web driver + * needs a worker plus a wasm sqlite binary, so persistence here goes through the browser's + * localStorage instead of a real database — plenty for this small amount of data, and it + * survives page reloads unlike the in-memory state it replaced. + */ +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class StoredExchangeRates(val base: String, val rates: Map, val fetchedAtMillis: Long) + +private class LocalStorageExchangeRateStore : ExchangeRateStore { + override suspend fun loadLastKnownRates(sourceId: String): ExchangeRates? { + val raw = localStorage.getItem(key(sourceId)) ?: return null + val stored = try { + json.decodeFromString(raw) + } catch (e: Exception) { + return null + } + return ExchangeRates( + base = stored.base, + rates = stored.rates.mapValues { (_, value) -> BigDecimal.parseString(value) }, + fetchedAt = Instant.fromEpochMilliseconds(stored.fetchedAtMillis), + ) + } + + override suspend fun saveRates(sourceId: String, rates: ExchangeRates) { + val stored = StoredExchangeRates( + base = rates.base, + rates = rates.rates.mapValues { (_, value) -> value.toStringExpanded() }, + fetchedAtMillis = rates.fetchedAt.toEpochMilliseconds(), + ) + localStorage.setItem(key(sourceId), json.encodeToString(stored)) + } + + private fun key(sourceId: String) = "satsprice.exchangeRate.$sourceId" +} + +private class LocalStorageSelectedCurrenciesStore : SelectedCurrenciesStore { + override suspend fun loadSelectedCurrencies(): List { + val raw = localStorage.getItem(KEY) ?: return emptyList() + return try { + json.decodeFromString>(raw) + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun saveSelectedCurrencies(codes: List) { + localStorage.setItem(KEY, json.encodeToString(codes)) + } + + private companion object { + const val KEY = "satsprice.selectedCurrencies" + } +} + +private class LocalStorageSelectedSourceStore : SelectedSourceStore { + override suspend fun loadSelectedSourceId(): String? = localStorage.getItem(KEY) + + override suspend fun saveSelectedSourceId(sourceId: String) { + localStorage.setItem(KEY, sourceId) + } + + private companion object { + const val KEY = "satsprice.selectedSourceId" + } +} + +actual fun createExchangeRateStore(): ExchangeRateStore = LocalStorageExchangeRateStore() +actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = LocalStorageSelectedCurrenciesStore() +actual fun createSelectedSourceStore(): SelectedSourceStore = LocalStorageSelectedSourceStore() diff --git a/shared/src/webTest/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStoresTest.kt b/shared/src/webTest/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStoresTest.kt new file mode 100644 index 0000000..9cdaa50 --- /dev/null +++ b/shared/src/webTest/kotlin/xyz/tyiu/satsprice/data/db/LocalStorageStoresTest.kt @@ -0,0 +1,56 @@ +package xyz.tyiu.satsprice.data.db + +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import kotlinx.browser.localStorage +import kotlinx.coroutines.test.runTest +import xyz.tyiu.satsprice.data.ExchangeRates +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * A fresh store instance stands in for a page reload: since these stores are backed by + * localStorage rather than an in-memory field, one instance's writes must be visible to another. + */ +class LocalStorageStoresTest { + + @Test + fun exchangeRateStorePersistsAcrossInstances() = runTest { + localStorage.clear() + assertNull(createExchangeRateStore().loadLastKnownRates("coinbase")) + + val rates = ExchangeRates( + base = "BTC", + rates = mapOf("USD" to BigDecimal.parseString("65000.12"), "EUR" to BigDecimal.parseString("60000.5")), + fetchedAt = Instant.fromEpochMilliseconds(1_700_000_000_000), + ) + createExchangeRateStore().saveRates("coinbase", rates) + + val reloaded = createExchangeRateStore().loadLastKnownRates("coinbase") + assertEquals(rates.base, reloaded?.base) + assertEquals(rates.rates, reloaded?.rates) + assertEquals(rates.fetchedAt, reloaded?.fetchedAt) + + // A different source's cache stays independent. + assertNull(createExchangeRateStore().loadLastKnownRates("coingecko")) + } + + @Test + fun selectedCurrenciesStorePersistsAcrossInstances() = runTest { + localStorage.clear() + assertEquals(emptyList(), createSelectedCurrenciesStore().loadSelectedCurrencies()) + + createSelectedCurrenciesStore().saveSelectedCurrencies(listOf("USD", "EUR", "JPY")) + assertEquals(listOf("USD", "EUR", "JPY"), createSelectedCurrenciesStore().loadSelectedCurrencies()) + } + + @Test + fun selectedSourceStorePersistsAcrossInstances() = runTest { + localStorage.clear() + assertNull(createSelectedSourceStore().loadSelectedSourceId()) + + createSelectedSourceStore().saveSelectedSourceId("coingecko") + assertEquals("coingecko", createSelectedSourceStore().loadSelectedSourceId()) + } +}