Replace Skip implementation with Kotlin Multiplatform implementation
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user