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:
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".SatsPriceApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package xyz.tyiu.satsprice
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import xyz.tyiu.satsprice.data.db.initAndroidContext
|
||||||
|
|
||||||
|
class SatsPriceApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
initAndroidContext(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ ktor = "3.5.2"
|
|||||||
bignum = "0.3.10"
|
bignum = "0.3.10"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
mokoResources = "0.26.4"
|
mokoResources = "0.26.4"
|
||||||
|
sqldelight = "2.3.2"
|
||||||
|
kotlinxBrowser = "0.5.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
@@ -56,6 +58,11 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
|||||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||||
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
||||||
bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" }
|
bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" }
|
||||||
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
|
sqldelight-androidDriver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
|
sqldelight-sqliteDriver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||||
|
sqldelight-nativeDriver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
|
||||||
|
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -65,4 +72,5 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k
|
|||||||
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
|
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
|
||||||
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
@@ -230,6 +230,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lsqlite3",
|
||||||
|
"-Wl,-U,_sqlite3_enable_load_extension",
|
||||||
|
"-Wl,-U,_sqlite3_load_extension",
|
||||||
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
@@ -388,6 +394,12 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-lsqlite3",
|
||||||
|
"-Wl,-U,_sqlite3_enable_load_extension",
|
||||||
|
"-Wl,-U,_sqlite3_load_extension",
|
||||||
|
);
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|||||||
+17
-1
@@ -8,6 +8,7 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
alias(libs.plugins.mokoResources)
|
alias(libs.plugins.mokoResources)
|
||||||
|
alias(libs.plugins.sqldelight)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -64,6 +65,7 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.compose.uiTooling)
|
implementation(libs.compose.uiTooling)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
implementation(libs.sqldelight.androidDriver)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
@@ -82,6 +84,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.contentNegotiation)
|
implementation(libs.ktor.client.contentNegotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinxJson)
|
implementation(libs.ktor.serialization.kotlinxJson)
|
||||||
implementation(libs.bignum)
|
implementation(libs.bignum)
|
||||||
|
implementation(libs.sqldelight.runtime)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
@@ -90,9 +93,14 @@ kotlin {
|
|||||||
}
|
}
|
||||||
appleMain.dependencies {
|
appleMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
implementation(libs.ktor.client.darwin)
|
||||||
|
implementation(libs.sqldelight.nativeDriver)
|
||||||
}
|
}
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.sqldelight.sqliteDriver)
|
||||||
|
}
|
||||||
|
webMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.browser)
|
||||||
}
|
}
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
implementation(libs.wrappers.browser)
|
implementation(libs.wrappers.browser)
|
||||||
@@ -110,4 +118,12 @@ dependencies {
|
|||||||
|
|
||||||
multiplatformResources {
|
multiplatformResources {
|
||||||
resourcesPackage.set("xyz.tyiu.satsprice.shared")
|
resourcesPackage.set("xyz.tyiu.satsprice.shared")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sqldelight {
|
||||||
|
databases {
|
||||||
|
register("AppDatabase") {
|
||||||
|
packageName.set("xyz.tyiu.satsprice.db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.ExchangeRates
|
||||||
import xyz.tyiu.satsprice.data.ManualExchangeRateSource
|
import xyz.tyiu.satsprice.data.ManualExchangeRateSource
|
||||||
import xyz.tyiu.satsprice.data.createHttpClient
|
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.CurrencyConverter
|
||||||
import xyz.tyiu.satsprice.domain.formatAmount
|
import xyz.tyiu.satsprice.domain.formatAmount
|
||||||
import xyz.tyiu.satsprice.domain.formatAmountFixed
|
import xyz.tyiu.satsprice.domain.formatAmountFixed
|
||||||
@@ -59,6 +65,9 @@ class PriceViewModel(
|
|||||||
private val manualSource: ManualExchangeRateSource = ManualExchangeRateSource(),
|
private val manualSource: ManualExchangeRateSource = ManualExchangeRateSource(),
|
||||||
private val coinbaseSource: ExchangeRateSource = CoinbaseExchangeRateSource(httpClient),
|
private val coinbaseSource: ExchangeRateSource = CoinbaseExchangeRateSource(httpClient),
|
||||||
private val coinGeckoSource: ExchangeRateSource = CoinGeckoExchangeRateSource(httpClient),
|
private val coinGeckoSource: ExchangeRateSource = CoinGeckoExchangeRateSource(httpClient),
|
||||||
|
private val exchangeRateStore: ExchangeRateStore = createExchangeRateStore(),
|
||||||
|
private val selectedCurrenciesStore: SelectedCurrenciesStore = createSelectedCurrenciesStore(),
|
||||||
|
private val selectedSourceStore: SelectedSourceStore = createSelectedSourceStore(),
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val sources: List<ExchangeRateSource> = listOf(coinbaseSource, coinGeckoSource, manualSource)
|
private val sources: List<ExchangeRateSource> = listOf(coinbaseSource, coinGeckoSource, manualSource)
|
||||||
@@ -84,10 +93,25 @@ class PriceViewModel(
|
|||||||
val uiState: StateFlow<ConverterUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ConverterUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private var rates: ExchangeRates? = null
|
private var rates: ExchangeRates? = null
|
||||||
|
private var lastPersistedSelection: List<String>? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
manualSource.currencyCode = defaultCurrencyCode
|
manualSource.currencyCode = defaultCurrencyCode
|
||||||
viewModelScope.launch {
|
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) {
|
while (isActive) {
|
||||||
refresh()
|
refresh()
|
||||||
delay(AUTO_REFRESH_INTERVAL_MILLIS)
|
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() {
|
fun refresh() {
|
||||||
if (currentSource === manualSource && manualSource.rate == null) {
|
if (currentSource === manualSource && manualSource.rate == null) {
|
||||||
_uiState.update { it.copy(isLoading = false, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = false, errorMessage = null) }
|
||||||
@@ -105,6 +142,8 @@ class PriceViewModel(
|
|||||||
try {
|
try {
|
||||||
val newRates = currentSource.getRates("BTC")
|
val newRates = currentSource.getRates("BTC")
|
||||||
rates = newRates
|
rates = newRates
|
||||||
|
exchangeRateStore.saveRates(currentSource.id, newRates)
|
||||||
|
var selectionToPersist: List<String>? = null
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val available = systemCurrencyList
|
val available = systemCurrencyList
|
||||||
.filter { newRates.rates.containsKey(it.code) }
|
.filter { newRates.rates.containsKey(it.code) }
|
||||||
@@ -119,6 +158,7 @@ class PriceViewModel(
|
|||||||
// whatever order the user picked via onFiatCurrenciesReordered.
|
// whatever order the user picked via onFiatCurrenciesReordered.
|
||||||
val filtered = state.selectedFiatCurrencies.filter { it == defaultCurrencyCode || it in availableCodes }
|
val filtered = state.selectedFiatCurrencies.filter { it == defaultCurrencyCode || it in availableCodes }
|
||||||
val selection = if (defaultCurrencyCode in filtered) filtered else listOf(defaultCurrencyCode) + filtered
|
val selection = if (defaultCurrencyCode in filtered) filtered else listOf(defaultCurrencyCode) + filtered
|
||||||
|
selectionToPersist = selection
|
||||||
recomputeFromKnownField(
|
recomputeFromKnownField(
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -130,6 +170,7 @@ class PriceViewModel(
|
|||||||
newRates,
|
newRates,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
selectionToPersist?.let { persistSelectionIfChanged(it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Not localized: this shared ViewModel has no platform Context to resolve a moko-resources
|
// 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.
|
// 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)
|
val newState = state.copy(selectedFiatCurrencies = newSelection)
|
||||||
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
||||||
}
|
}
|
||||||
|
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,6 +216,7 @@ class PriceViewModel(
|
|||||||
val newState = state.copy(selectedFiatCurrencies = newSelection)
|
val newState = state.copy(selectedFiatCurrencies = newSelection)
|
||||||
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
|
||||||
}
|
}
|
||||||
|
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSourceSelected(displayName: String) {
|
fun onSourceSelected(displayName: String) {
|
||||||
@@ -187,7 +230,11 @@ class PriceViewModel(
|
|||||||
rateDisplays = emptyMap(),
|
rateDisplays = emptyMap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
refresh()
|
viewModelScope.launch {
|
||||||
|
selectedSourceStore.saveSelectedSourceId(selected.id)
|
||||||
|
seedFromCache(selected)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onManualRateChanged(value: String) {
|
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