Replace Skip implementation with Kotlin Multiplatform implementation

This commit is contained in:
2026-09-02 22:11:32 +03:00
parent 902b683779
commit abf7cf2e83
184 changed files with 7163 additions and 2449 deletions
@@ -0,0 +1,19 @@
package xyz.tyiu.satsprice
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import xyz.tyiu.satsprice.ui.PriceScreen
@Composable
@Preview
fun App() {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(),
) {
PriceScreen()
}
}
@@ -0,0 +1,7 @@
package xyz.tyiu.satsprice
interface Platform {
val name: String
}
expect fun getPlatform(): Platform
@@ -0,0 +1,12 @@
package xyz.tyiu.satsprice
data class CurrencyInfo(val code: String, val displayName: String)
/** All ISO 4217 currencies the current platform knows about, with localized display names. */
expect fun systemCurrencies(): List<CurrencyInfo>
/** The currency associated with the user's current locale, if the platform can determine one. */
expect fun localeCurrencyCode(): String?
/** The conventional number of decimal places for [code] (e.g. 2 for USD, 0 for JPY, 3 for BHD). */
expect fun currencyDecimalDigits(code: String): Int
@@ -0,0 +1,46 @@
package xyz.tyiu.satsprice.data
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonPrimitive
import xyz.tyiu.satsprice.systemCurrencies
import kotlin.time.Clock
@Serializable
internal data class CoinGeckoSimplePriceResponse(
val bitcoin: Map<String, JsonElement> = emptyMap(),
)
class CoinGeckoExchangeRateSource(
private val httpClient: HttpClient,
) : ExchangeRateSource {
override val id: String = "coingecko"
override val displayName: String = "CoinGecko"
private val requestedVsCurrencies: String by lazy {
systemCurrencies().joinToString(",") { it.code.lowercase() }
}
override suspend fun getRates(base: String): ExchangeRates {
val response: CoinGeckoSimplePriceResponse = httpClient
.get("https://api.coingecko.com/api/v3/simple/price") {
parameter("ids", "bitcoin")
parameter("vs_currencies", requestedVsCurrencies)
parameter("precision", "full")
}
.body()
return ExchangeRates(
base = "BTC",
rates = response.bitcoin
.mapKeys { (code, _) -> code.uppercase() }
.mapValues { (_, value) -> BigDecimal.parseString(value.jsonPrimitive.content) },
fetchedAt = Clock.System.now(),
)
}
}
@@ -0,0 +1,41 @@
package xyz.tyiu.satsprice.data
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Serializable
internal data class CoinbaseExchangeRatesResponse(
val data: CoinbaseExchangeRatesData,
)
@Serializable
internal data class CoinbaseExchangeRatesData(
val currency: String,
val rates: Map<String, String>,
)
class CoinbaseExchangeRateSource(
private val httpClient: HttpClient,
) : ExchangeRateSource {
override val id: String = "coinbase"
override val displayName: String = "Coinbase"
override suspend fun getRates(base: String): ExchangeRates {
val response: CoinbaseExchangeRatesResponse = httpClient
.get("https://api.coinbase.com/v2/exchange-rates") {
parameter("currency", base)
}
.body()
return ExchangeRates(
base = response.data.currency,
rates = response.data.rates.mapValues { (_, value) -> BigDecimal.parseString(value) },
fetchedAt = Clock.System.now(),
)
}
}
@@ -0,0 +1,22 @@
package xyz.tyiu.satsprice.data
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlin.time.Instant
/**
* A source of BTC exchange rates. Implementations fetch rates for one unit of [base]
* expressed in various currency codes, so new providers can be added without touching
* conversion or UI code.
*/
interface ExchangeRateSource {
val id: String
val displayName: String
suspend fun getRates(base: String = "BTC"): ExchangeRates
}
data class ExchangeRates(
val base: String,
val rates: Map<String, BigDecimal>,
val fetchedAt: Instant,
)
@@ -0,0 +1,21 @@
package xyz.tyiu.satsprice.data
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun createHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 10_000
}
}
@@ -0,0 +1,22 @@
package xyz.tyiu.satsprice.data
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlin.time.Clock
/** A source backed by a rate the user types in themselves, rather than a network fetch. */
class ManualExchangeRateSource : ExchangeRateSource {
override val id: String = "manual"
override val displayName: String = "Manual"
var currencyCode: String = "USD"
var rate: BigDecimal? = null
override suspend fun getRates(base: String): ExchangeRates {
val value = rate ?: throw IllegalStateException("Enter a BTC price in $currencyCode below")
return ExchangeRates(
base = "BTC",
rates = mapOf(currencyCode to value),
fetchedAt = Clock.System.now(),
)
}
}
@@ -0,0 +1,47 @@
package xyz.tyiu.satsprice.domain
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.DecimalMode
import com.ionspin.kotlin.bignum.decimal.RoundingMode
import xyz.tyiu.satsprice.data.ExchangeRates
/**
* Pure conversion math between BTC, Sats, and a fiat currency, given a base-BTC
* [ExchangeRates] snapshot (1 BTC = rates[currency] units of that currency).
*
* Uses arbitrary-precision [BigDecimal] throughout (rather than Double) so
* conversions don't lose precision or overflow for very large amounts.
*/
object CurrencyConverter {
val SATS_PER_BTC: BigDecimal = BigDecimal.fromLong(100_000_000)
/** The hard-capped maximum number of bitcoin that will ever exist. */
val MAX_BTC_SUPPLY: BigDecimal = BigDecimal.fromLong(21_000_000)
/** [MAX_BTC_SUPPLY] expressed in Sats. */
val MAX_SATS_SUPPLY: BigDecimal = MAX_BTC_SUPPLY.multiply(SATS_PER_BTC)
/** Divisions aren't guaranteed to terminate, so cap at a generous 50 significant digits. */
private val DIVISION_MODE = DecimalMode(
decimalPrecision = 50,
roundingMode = RoundingMode.ROUND_HALF_AWAY_FROM_ZERO,
)
fun btcToSats(btc: BigDecimal): BigDecimal = btc.multiply(SATS_PER_BTC)
fun satsToBtc(sats: BigDecimal): BigDecimal = sats.divide(SATS_PER_BTC, DIVISION_MODE)
fun btcToFiat(btc: BigDecimal, rates: ExchangeRates, currency: String): BigDecimal? =
rates.rates[currency]?.let { rate -> btc.multiply(rate) }
fun fiatToBtc(fiat: BigDecimal, rates: ExchangeRates, currency: String): BigDecimal? =
rates.rates[currency]
?.takeIf { rate -> !rate.isZero() }
?.let { rate -> fiat.divide(rate, DIVISION_MODE) }
fun satsToFiat(sats: BigDecimal, rates: ExchangeRates, currency: String): BigDecimal? =
btcToFiat(satsToBtc(sats), rates, currency)
fun fiatToSats(fiat: BigDecimal, rates: ExchangeRates, currency: String): BigDecimal? =
fiatToBtc(fiat, rates, currency)?.let { btc -> btcToSats(btc) }
}
@@ -0,0 +1,66 @@
package xyz.tyiu.satsprice.domain
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import com.ionspin.kotlin.bignum.decimal.RoundingMode
private val PLAIN_DECIMAL_INPUT = Regex("""-?\d+(\.\d+)?""")
/** Parses a plain decimal string (as typed into an amount field), or null if incomplete/invalid. */
fun String.toBigDecimalOrNull(): BigDecimal? {
if (!PLAIN_DECIMAL_INPUT.matches(this)) return null
return try {
BigDecimal.parseString(this)
} catch (e: Exception) {
null
}
}
/** Strips a raw text-field edit down to digits and at most one decimal point. */
fun sanitizeDecimalInput(raw: String): String {
val sb = StringBuilder()
var seenDot = false
for (c in raw) {
when {
c.isDigit() -> sb.append(c)
c == '.' && !seenDot -> {
sb.append(c)
seenDot = true
}
}
}
return sb.toString()
}
/** Strips a raw text-field edit down to digits only (for whole-unit fields like Sats). */
fun sanitizeIntegerInput(raw: String): String = raw.filter { it.isDigit() }
/** Formats [value] with up to [maxDecimals] decimal places, trimming trailing zeros. */
fun formatAmount(value: BigDecimal, maxDecimals: Int): String {
val rounded = value.roundToDigitPositionAfterDecimalPoint(
digitPosition = maxDecimals.toLong(),
roundingMode = RoundingMode.ROUND_HALF_AWAY_FROM_ZERO,
)
val text = rounded.toStringExpanded()
if ('.' !in text) return text
val trimmed = text.trimEnd('0').trimEnd('.')
return when {
trimmed.isEmpty() || trimmed == "-" -> "0"
else -> trimmed
}
}
/** Formats [value] with exactly [decimals] decimal places, zero-padded rather than trimmed. */
fun formatAmountFixed(value: BigDecimal, decimals: Int): String {
val rounded = value.roundToDigitPositionAfterDecimalPoint(
digitPosition = decimals.toLong(),
roundingMode = RoundingMode.ROUND_HALF_AWAY_FROM_ZERO,
)
val text = rounded.toStringExpanded()
if (decimals <= 0) return text.substringBefore('.')
val whole = text.substringBefore('.')
val fraction = text.substringAfter('.', "")
return "$whole.${fraction.padEnd(decimals, '0')}"
}
@@ -0,0 +1,29 @@
package xyz.tyiu.satsprice.ui
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import xyz.tyiu.satsprice.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.toBigDecimalOrNull
import kotlin.time.Instant
/** Derived display strings/flags shared between the Compose UI and the iOS SwiftUI bridge. */
fun ConverterUiState.statusLine(): String {
val updated = lastUpdated?.let { "updated ${it.toDateTimeString()}" } ?: "loading rates…"
return if (sourceName.isEmpty()) updated else "via $sourceName, $updated"
}
fun ConverterUiState.exceedsMaxSupply(): Boolean {
val btcOverCap = btcAmount.toBigDecimalOrNull()?.let { it > CurrencyConverter.MAX_BTC_SUPPLY } ?: false
val satsOverCap = satsAmount.toBigDecimalOrNull()?.let { it > CurrencyConverter.MAX_SATS_SUPPLY } ?: false
return btcOverCap || satsOverCap
}
/** The current "1 BTC = ?" rate in [ConverterUiState.defaultCurrencyCode], as a plain number. */
fun ConverterUiState.defaultCurrencyRate(): String =
rateDisplays[defaultCurrencyCode]?.takeIf { it.isNotEmpty() } ?: ""
private fun Instant.toDateTimeString(): String {
val local = toLocalDateTime(TimeZone.currentSystemDefault())
return local.toString().substringBefore('.').replace('T', ' ')
}
@@ -0,0 +1,10 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.ui.unit.Dp
/**
* Horizontal inset from the screen edge to the section cards. Differs per platform because
* touch-target density and window scale differ enough that a shared constant looks wrong on
* at least one of them (e.g. Android phones want a tighter edge than a desktop window does).
*/
expect val screenHorizontalPadding: Dp
@@ -0,0 +1,311 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import dev.icerock.moko.resources.compose.stringResource
import xyz.tyiu.satsprice.CurrencyInfo
import xyz.tyiu.satsprice.shared.MR
private val SectionColors
@Composable get() = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
@Composable
fun PriceScreen(
viewModel: PriceViewModel = viewModel { PriceViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.verticalScroll(rememberScrollState())
.padding(horizontal = screenHorizontalPadding, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
Text("SatsPrice", style = MaterialTheme.typography.headlineMedium)
Text(
text = state.statusLine(),
style = MaterialTheme.typography.bodySmall,
)
}
Card(modifier = Modifier.fillMaxWidth(), colors = SectionColors) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(MR.strings.price_source), style = MaterialTheme.typography.bodyMedium)
DropdownSelector(
selectedLabel = state.sourceName,
options = viewModel.availableSources,
optionLabel = { it },
onSelected = viewModel::onSourceSelected,
)
}
Text(
text = stringResource(MR.strings.btc_to_currency, state.defaultCurrencyCode),
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (state.isManualSource) {
OutlinedTextField(
value = state.manualRateInput,
onValueChange = viewModel::onManualRateChanged,
label = { Text(stringResource(MR.strings.rate_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f),
)
} else {
val rate = state.defaultCurrencyRate()
if (rate.isNotEmpty()) {
Text(
text = rate,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
IconButton(onClick = { viewModel.refresh() }) {
Icon(
Icons.Default.Refresh,
contentDescription = stringResource(MR.strings.refresh_content_description),
)
}
}
}
}
}
state.errorMessage?.let { message ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(message, color = MaterialTheme.colorScheme.onErrorContainer)
TextButton(onClick = { viewModel.refresh() }) { Text(stringResource(MR.strings.retry)) }
}
}
}
val exceedsMaxSupply = state.exceedsMaxSupply()
Card(modifier = Modifier.fillMaxWidth(), colors = SectionColors) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(stringResource(MR.strings.bitcoin_section_title), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.satsAmount,
onValueChange = viewModel::onSatsAmountChanged,
label = { Text(stringResource(MR.strings.sats_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
isError = exceedsMaxSupply,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.btcAmount,
onValueChange = viewModel::onBtcAmountChanged,
label = { Text(stringResource(MR.strings.btc_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
isError = exceedsMaxSupply,
modifier = Modifier.fillMaxWidth(),
)
if (exceedsMaxSupply) {
Text(
text = stringResource(MR.strings.exceeds_max_supply),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
Card(modifier = Modifier.fillMaxWidth(), colors = SectionColors) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(MR.strings.currencies_section_title), style = MaterialTheme.typography.titleMedium)
MultiCurrencySelector(
selectedCodes = state.selectedFiatCurrencies,
options = state.availableFiatCurrencies,
localeCurrencyCode = state.localeCurrencyCode,
onToggle = viewModel::onFiatCurrencyToggled,
)
}
state.selectedFiatCurrencies.forEach { code ->
key(code) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.fiatAmounts[code].orEmpty(),
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
label = { Text(code) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { viewModel.onFiatCurrencyToggled(code) }) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(
MR.strings.remove_currency_content_description,
code,
),
)
}
}
}
}
}
}
}
}
}
@Composable
private fun MultiCurrencySelector(
selectedCodes: List<String>,
options: List<CurrencyInfo>,
localeCurrencyCode: String?,
onToggle: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Text(
if (selectedCodes.isEmpty()) {
stringResource(MR.strings.add_currency)
} else {
stringResource(MR.strings.currencies_selected_count, selectedCodes.size)
},
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { info ->
val checked = info.code in selectedCodes
val label = if (info.code == localeCurrencyCode) {
stringResource(MR.strings.currency_option_label_local, info.code, info.displayName)
} else {
stringResource(MR.strings.currency_option_label, info.code, info.displayName)
}
DropdownMenuItem(
text = { Text(label) },
leadingIcon = { Checkbox(checked = checked, onCheckedChange = { onToggle(info.code) }) },
onClick = { onToggle(info.code) },
)
}
}
}
}
@Composable
private fun <T> DropdownSelector(
selectedLabel: String,
options: List<T>,
optionLabel: (T) -> String,
onSelected: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) {
Text(selectedLabel)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,264 @@
package xyz.tyiu.satsprice.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import io.ktor.client.HttpClient
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import xyz.tyiu.satsprice.CurrencyInfo
import xyz.tyiu.satsprice.currencyDecimalDigits
import xyz.tyiu.satsprice.data.CoinbaseExchangeRateSource
import xyz.tyiu.satsprice.data.CoinGeckoExchangeRateSource
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.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.formatAmount
import xyz.tyiu.satsprice.domain.formatAmountFixed
import xyz.tyiu.satsprice.domain.sanitizeDecimalInput
import xyz.tyiu.satsprice.domain.sanitizeIntegerInput
import xyz.tyiu.satsprice.domain.toBigDecimalOrNull
import xyz.tyiu.satsprice.localeCurrencyCode
import xyz.tyiu.satsprice.systemCurrencies
import kotlin.time.Instant
private const val AUTO_REFRESH_INTERVAL_MILLIS = 60_000L
data class ConverterUiState(
val btcAmount: String = "1",
val satsAmount: String = formatAmount(CurrencyConverter.SATS_PER_BTC, 0),
val selectedFiatCurrencies: List<String> = listOf("USD"),
val fiatAmounts: Map<String, String> = emptyMap(),
val rateDisplays: Map<String, String> = emptyMap(),
val availableFiatCurrencies: List<CurrencyInfo> = emptyList(),
val localeCurrencyCode: String? = null,
val defaultCurrencyCode: String = "USD",
val sourceName: String = "",
val isManualSource: Boolean = false,
val manualRateInput: String = "",
val isLoading: Boolean = true,
val errorMessage: String? = null,
val lastUpdated: Instant? = null,
)
private sealed interface EditedField {
data object Btc : EditedField
data object Sats : EditedField
data class Fiat(val code: String) : EditedField
}
class PriceViewModel(
httpClient: HttpClient = createHttpClient(),
private val manualSource: ManualExchangeRateSource = ManualExchangeRateSource(),
private val coinbaseSource: ExchangeRateSource = CoinbaseExchangeRateSource(httpClient),
private val coinGeckoSource: ExchangeRateSource = CoinGeckoExchangeRateSource(httpClient),
) : ViewModel() {
private val sources: List<ExchangeRateSource> = listOf(coinbaseSource, coinGeckoSource, manualSource)
val availableSources: List<String> = sources.map { it.displayName }
private var currentSource: ExchangeRateSource = coinbaseSource
private val systemCurrencyList: List<CurrencyInfo> by lazy { systemCurrencies() }
private val localCurrencyCode: String? by lazy {
localeCurrencyCode()?.takeIf { code -> systemCurrencyList.any { it.code == code } }
}
private val defaultCurrencyCode: String by lazy { localCurrencyCode ?: "USD" }
private val decimalDigitsCache = mutableMapOf<String, Int>()
private fun decimalDigitsFor(code: String) = decimalDigitsCache.getOrPut(code) { currencyDecimalDigits(code) }
private val _uiState = MutableStateFlow(
ConverterUiState(
selectedFiatCurrencies = listOf(defaultCurrencyCode),
localeCurrencyCode = localCurrencyCode,
defaultCurrencyCode = defaultCurrencyCode,
sourceName = coinbaseSource.displayName,
),
)
val uiState: StateFlow<ConverterUiState> = _uiState.asStateFlow()
private var rates: ExchangeRates? = null
init {
manualSource.currencyCode = defaultCurrencyCode
viewModelScope.launch {
while (isActive) {
refresh()
delay(AUTO_REFRESH_INTERVAL_MILLIS)
}
}
}
fun refresh() {
if (currentSource === manualSource && manualSource.rate == null) {
_uiState.update { it.copy(isLoading = false, errorMessage = null) }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val newRates = currentSource.getRates("BTC")
rates = newRates
_uiState.update { state ->
val available = systemCurrencyList
.filter { newRates.rates.containsKey(it.code) }
.let { list ->
val (matching, rest) = list.partition { it.code == defaultCurrencyCode }
matching + rest
}
val availableCodes = available.map { it.code }.toSet()
val selection = state.selectedFiatCurrencies.filter { it in availableCodes }
.ifEmpty { listOfNotNull(available.firstOrNull()?.code) }
recomputeFromKnownField(
state.copy(
isLoading = false,
errorMessage = null,
availableFiatCurrencies = available,
selectedFiatCurrencies = selection,
lastUpdated = newRates.fetchedAt,
),
newRates,
)
}
} 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.
_uiState.update {
it.copy(isLoading = false, errorMessage = e.message ?: "Could not fetch exchange rates")
}
}
}
}
fun onBtcAmountChanged(value: String) = updateAmount(EditedField.Btc, sanitizeDecimalInput(value))
fun onSatsAmountChanged(value: String) = updateAmount(EditedField.Sats, sanitizeIntegerInput(value))
fun onFiatAmountChanged(code: String, value: String) =
updateAmount(EditedField.Fiat(code), sanitizeDecimalInput(value))
fun onFiatCurrencyToggled(code: String) {
_uiState.update { state ->
val newSelection = if (code in state.selectedFiatCurrencies) {
state.selectedFiatCurrencies - code
} else {
state.selectedFiatCurrencies + code
}
val newState = state.copy(selectedFiatCurrencies = newSelection)
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
}
}
fun onSourceSelected(displayName: String) {
val selected = sources.firstOrNull { it.displayName == displayName } ?: return
currentSource = selected
rates = null
_uiState.update {
it.copy(
sourceName = selected.displayName,
isManualSource = selected === manualSource,
rateDisplays = emptyMap(),
)
}
refresh()
}
fun onManualRateChanged(value: String) {
val sanitized = sanitizeDecimalInput(value)
_uiState.update { it.copy(manualRateInput = sanitized) }
sanitized.toBigDecimalOrNull()?.let { parsed ->
manualSource.rate = parsed
refresh()
}
}
private fun updateAmount(field: EditedField, rawValue: String) {
_uiState.update { state ->
val currentValue = when (field) {
EditedField.Btc -> state.btcAmount
EditedField.Sats -> state.satsAmount
is EditedField.Fiat -> state.fiatAmounts[field.code].orEmpty()
}
// Some platform text fields (notably SwiftUI's) can re-fire the binding's setter with
// the field's current, unchanged text just from gaining focus. Treat that as a no-op
// rather than re-deriving every other field from this one's rounded display value.
if (rawValue == currentValue) return@update state
val withRaw = state.withField(field, rawValue)
val amount = rawValue.toBigDecimalOrNull()
val currentRates = rates
if (amount == null || currentRates == null) return@update withRaw
val btcValue: BigDecimal? = when (field) {
EditedField.Btc -> amount
EditedField.Sats -> CurrencyConverter.satsToBtc(amount)
is EditedField.Fiat -> CurrencyConverter.fiatToBtc(amount, currentRates, field.code)
}
if (btcValue == null) return@update withRaw
withRaw.copy(
btcAmount = if (field == EditedField.Btc) withRaw.btcAmount else formatAmount(btcValue, 8),
satsAmount = if (field == EditedField.Sats) {
withRaw.satsAmount
} else {
formatAmount(CurrencyConverter.btcToSats(btcValue), 0)
},
fiatAmounts = withRaw.fiatAmounts + state.selectedFiatCurrencies
.filterNot { code -> field is EditedField.Fiat && field.code == code }
.associateWith { code ->
CurrencyConverter.btcToFiat(btcValue, currentRates, code)
?.let { formatAmountFixed(it, decimalDigitsFor(code)) }
?: withRaw.fiatAmounts[code].orEmpty()
},
)
}
}
private fun ConverterUiState.withField(field: EditedField, value: String): ConverterUiState = when (field) {
EditedField.Btc -> copy(btcAmount = value)
EditedField.Sats -> copy(satsAmount = value)
is EditedField.Fiat -> copy(fiatAmounts = fiatAmounts + (field.code to value))
}
/** Recomputes every other field from whichever field currently holds a valid number. */
private fun recomputeFromKnownField(state: ConverterUiState, rates: ExchangeRates): ConverterUiState {
val btc = state.btcAmount.toBigDecimalOrNull()
val sats = state.satsAmount.toBigDecimalOrNull()
var sourceFiatCode: String? = null
val btcValue: BigDecimal? = when {
btc != null -> btc
sats != null -> CurrencyConverter.satsToBtc(sats)
else -> state.selectedFiatCurrencies.firstNotNullOfOrNull { code ->
state.fiatAmounts[code]?.toBigDecimalOrNull()
?.let { amount -> CurrencyConverter.fiatToBtc(amount, rates, code) }
?.also { sourceFiatCode = code }
}
}
val rateDisplays = (state.selectedFiatCurrencies + state.defaultCurrencyCode).distinct().associateWith { code ->
rates.rates[code]?.let { formatAmountFixed(it, decimalDigitsFor(code)) } ?: ""
}
if (btcValue == null) return state.copy(rateDisplays = rateDisplays)
return state.copy(
btcAmount = if (btc != null) state.btcAmount else formatAmount(btcValue, 8),
satsAmount = if (sats != null) state.satsAmount else formatAmount(CurrencyConverter.btcToSats(btcValue), 0),
fiatAmounts = state.selectedFiatCurrencies.associateWith { code ->
if (code == sourceFiatCode) {
state.fiatAmounts[code].orEmpty()
} else {
CurrencyConverter.btcToFiat(btcValue, rates, code)
?.let { formatAmountFixed(it, decimalDigitsFor(code)) }
?: state.fiatAmounts[code].orEmpty()
}
},
rateDisplays = rateDisplays,
)
}
}
@@ -0,0 +1,18 @@
<resources>
<string name="price_source">Price Source</string>
<string name="btc_to_currency">1 BTC to %1$s</string>
<string name="rate_label">Rate</string>
<string name="refresh_content_description">Refresh</string>
<string name="retry">Retry</string>
<string name="bitcoin_section_title">Bitcoin</string>
<string name="sats_label">Sats</string>
<string name="btc_label">BTC</string>
<string name="exceeds_max_supply">Exceeds the 21,000,000 BTC maximum supply</string>
<string name="currencies_section_title">Currencies</string>
<string name="add_currency">Add currency</string>
<string name="currencies_selected_count">%1$d selected</string>
<string name="remove_currency_content_description">Remove %1$s</string>
<string name="currency_option_label">%1$s - %2$s</string>
<string name="currency_option_label_local">%1$s - %2$s (Local)</string>
<string name="done">Done</string>
</resources>