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 android.content.Context
|
||||
|
||||
internal lateinit var androidAppContext: Context
|
||||
private set
|
||||
|
||||
/** Called once from [android.app.Application.onCreate] so the shared module can open its database. */
|
||||
fun initAndroidContext(context: Context) {
|
||||
androidAppContext = context.applicationContext
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
import xyz.tyiu.satsprice.db.AppDatabase
|
||||
|
||||
private val database: AppDatabase by lazy {
|
||||
AppDatabase(AndroidSqliteDriver(AppDatabase.Schema, androidAppContext, "sats-price.db"))
|
||||
}
|
||||
|
||||
actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database)
|
||||
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database)
|
||||
actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database)
|
||||
@@ -0,0 +1,12 @@
|
||||
package xyz.tyiu.satsprice.data.db
|
||||
|
||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
||||
import xyz.tyiu.satsprice.db.AppDatabase
|
||||
|
||||
private val database: AppDatabase by lazy {
|
||||
AppDatabase(NativeSqliteDriver(AppDatabase.Schema, "sats-price.db"))
|
||||
}
|
||||
|
||||
actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database)
|
||||
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database)
|
||||
actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?);
|
||||
@@ -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 (?, ?);
|
||||
@@ -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, ?);
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>, 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<StoredExchangeRates>(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<String> {
|
||||
val raw = localStorage.getItem(KEY) ?: return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<String>>(raw)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveSelectedCurrencies(codes: List<String>) {
|
||||
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()
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user