Add a Compose HTML (DOM) web app and split Compose UI out of :shared

webHtmlApp is a new web build using Compose HTML instead of Compose
Multiplatform UI's Skia/canvas renderer - real <select>/<input>/<button>
elements, an anchored popup menu for reordering/removing currencies
(Compose HTML has no built-in DropdownMenu), and locale-independent
English strings (moko-resources' Compose integration pulls in
compose.foundation/ui, which this module exists to avoid).

Realizing the bundle-size win needed splitting sharedUi out of shared:
the Compose Multiplatform Gradle plugin bundles Skiko's several-MB web
runtime into any js/wasmJs target whose resolved dependencies contain
org.jetbrains.compose.ui:ui anywhere, and shared previously declared it
directly for the other (Skia-based) web app's UI. shared is now pure
business/data logic with no Compose dependency; sharedUi holds App()/
PriceScreen() for androidApp, desktopApp, and webApp to render.
PriceViewModel's ViewModel supertype and CurrencyConverter's BigDecimal
params moved from implementation() to api() in shared, since they're
part of its public surface that webHtmlApp now touches directly.

Also drops the @js-joda/timezone dependency from both web builds: the
app only ever calls TimeZone.currentSystemDefault(), which resolves to
a synthetic zone backed by the native Date offset on Kotlin/JS and
never touches the tz database - confirmed empirically on both js and
wasmJs targets. Cuts webApp's js bundle by ~950 KB and webHtmlApp's by
~800 KB.

currencyFlagEmoji() gained an explicit supportsFlagEmoji parameter
(defaulting to the existing expect/actual, false on web) so webHtmlApp
can opt in - Compose HTML renders real DOM text, so flag emoji work
fine there even though they don't on Compose Multiplatform's canvas.

website/serve-local.sh now builds and serves webHtmlApp instead of
webApp's wasmJs build, so it no longer mirrors what's actually deployed
to production; docs updated to reflect the new module layout and that
divergence, and the Pages workflow's path trigger now includes
sharedUi/** so UI changes there still redeploy the site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 18:35:23 +03:00
co-authored by Claude Sonnet 5
parent 97e3a34476
commit 188536867d
31 changed files with 970 additions and 72 deletions
+61
View File
@@ -0,0 +1,61 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// The Skia-based Compose Multiplatform UI (App()/PriceScreen()) that androidApp, desktopApp,
// and webApp render. Split out of :shared so :shared itself stays free of compose.ui - the
// Compose Multiplatform Gradle plugin bundles the several-MB Skiko web runtime into *any*
// js/wasmJs target whose resolved dependency graph contains org.jetbrains.compose.ui:ui
// anywhere, and webHtmlApp's DOM-only build needs to avoid that.
kotlin {
jvm()
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
android {
namespace = "xyz.tyiu.satsprice.sharedui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.uiTooling)
}
commonMain.dependencies {
api(project(":shared"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.moko.resourcesCompose)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -0,0 +1,6 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
actual val screenHorizontalPadding: Dp = 16.dp
@@ -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,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,753 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.currencyFlagEmoji
import xyz.tyiu.satsprice.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.formatAmount
import xyz.tyiu.satsprice.domain.groupDigits
import xyz.tyiu.satsprice.domain.localizedDecimalSeparator
import xyz.tyiu.satsprice.matchesCurrencySearch
import xyz.tyiu.satsprice.shared.MR
private val SectionColors
@Composable get() = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
private val SectionHeaderStyle
@Composable get() = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.primary,
letterSpacing = 1.2.sp,
)
/** Displays a plain-digit amount field's value with locale-appropriate digit grouping, without touching it. */
private object DigitGroupingTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val grouped = groupDigits(text.text)
val decimalSeparator = localizedDecimalSeparator()
// Everything grouping inserts is neither a digit, the sign, nor the (possibly localized,
// e.g. "," in de-DE) decimal point — so anything else is an inserted grouping separator
// to skip, whatever character the locale actually uses for it.
fun isGroupingSeparator(char: Char) = !char.isDigit() && char != '-' && char.toString() != decimalSeparator
val offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
var originalSeen = 0
for ((index, char) in grouped.withIndex()) {
if (originalSeen == offset) return index
if (!isGroupingSeparator(char)) originalSeen++
}
return grouped.length
}
override fun transformedToOriginal(offset: Int): Int =
grouped.take(offset.coerceIn(0, grouped.length)).count { !isGroupingSeparator(it) }
}
return TransformedText(AnnotatedString(grouped), offsetMapping)
}
}
@Composable
fun PriceScreen(
viewModel: PriceViewModel = viewModel { PriceViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var showCurrencyPicker by remember { mutableStateOf(false) }
if (showCurrencyPicker) {
CurrencyPickerScreen(
state = state,
onToggle = viewModel::onFiatCurrencyToggled,
onReset = viewModel::onSelectedCurrenciesReset,
onDone = { showCurrencyPicker = false },
)
return
}
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),
) {
Text(
"SatsPrice",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
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,
)
}
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.btc_to_currency, state.defaultCurrencyCode)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.weight(1f),
)
} else {
val rate = state.defaultCurrencyRate()
OutlinedTextField(
value = rate,
onValueChange = {},
readOnly = true,
enabled = rate.isNotEmpty(),
label = { Text(stringResource(MR.strings.btc_to_currency, state.defaultCurrencyCode)) },
singleLine = true,
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.weight(1f),
)
}
if (!state.isManualSource) {
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),
)
}
}
}
}
if (state.isManualSource) {
OutlinedTextField(
value = state.manualSatsPerCurrencyInput,
onValueChange = viewModel::onManualSatsPerCurrencyChanged,
label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.fillMaxWidth(),
)
} else {
val oneCurrencyToSats = state.oneCurrencyToSats()
OutlinedTextField(
value = oneCurrencyToSats,
onValueChange = {},
readOnly = true,
enabled = oneCurrencyToSats.isNotEmpty(),
label = { Text(stringResource(MR.strings.currency_to_sats, state.defaultCurrencyCode)) },
singleLine = true,
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.fillMaxWidth(),
)
}
val statusLine = if (state.isManualSource) {
null
} else {
state.lastUpdatedDateTime()?.let { stringResource(MR.strings.updated_status, it) }
?: stringResource(MR.strings.loading_rates_status)
}
if (statusLine != null) {
Text(
text = statusLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
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,
visualTransformation = DigitGroupingTransformation,
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,
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.fillMaxWidth(),
)
if (exceedsMaxSupply) {
// Locale-grouped (e.g. "21,000,000 BTC" in en-US, "21.000.000 BTC" in
// de-DE) since it's substituted into the string, then searched for
// verbatim below to turn it into a link — the two always match exactly.
val maxSupplyText = groupDigits(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) + " BTC"
val warning = stringResource(MR.strings.exceeds_max_supply, maxSupplyText)
val linkStart = warning.indexOf(maxSupplyText)
val annotatedWarning = if (linkStart < 0) {
AnnotatedString(warning)
} else {
// Set explicitly (and identically) for every interaction state, since
// LinkAnnotation otherwise renders in the theme's link/accent color
// rather than inheriting the surrounding warning text's color.
val linkStyle = SpanStyle(
color = MaterialTheme.colorScheme.error,
textDecoration = TextDecoration.Underline,
)
buildAnnotatedString {
append(warning.substring(0, linkStart))
withLink(
LinkAnnotation.Clickable(
tag = "max_supply",
styles = TextLinkStyles(
style = linkStyle,
focusedStyle = linkStyle,
hoveredStyle = linkStyle,
pressedStyle = linkStyle,
),
) {
viewModel.onBtcAmountChanged(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0))
},
) {
append(maxSupplyText)
}
append(warning.substring(linkStart + maxSupplyText.length))
}
}
Text(
text = annotatedWarning,
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)
if (!state.isManualSource) {
OutlinedButton(onClick = { showCurrencyPicker = true }) {
Text(
if (state.selectedFiatCurrencies.size <= 1) {
stringResource(MR.strings.add_currency)
} else {
stringResource(MR.strings.currencies_selected_count, state.selectedFiatCurrencies.size)
},
)
}
}
}
val displayedCurrencies = if (state.isManualSource) {
listOf(state.defaultCurrencyCode)
} else {
state.selectedFiatCurrencies
}
displayedCurrencies.forEachIndexed { index, code ->
key(code) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val isPriced = state.isPriced(code)
val fieldLabel = currencyFlagEmoji(code)?.let { flag -> "$flag $code" } ?: code
OutlinedTextField(
value = if (isPriced) state.fiatAmounts[code].orEmpty() else "",
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
label = { Text(fieldLabel) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
enabled = isPriced,
isError = !isPriced && !state.isManualSource,
supportingText = if (isPriced || state.isManualSource) {
null
} else {
{ Text(stringResource(MR.strings.currency_not_priced, state.sourceName)) }
},
visualTransformation = DigitGroupingTransformation,
modifier = Modifier.weight(1f),
)
if (displayedCurrencies.size > 1) {
CurrencyRowMenu(
code = code,
canMoveUp = index > 0,
canMoveDown = index < displayedCurrencies.lastIndex,
canRemove = code != state.defaultCurrencyCode,
onMoveUp = {
viewModel.onFiatCurrenciesReordered(
state.selectedFiatCurrencies.moved(index, index - 1),
)
},
onMoveDown = {
viewModel.onFiatCurrenciesReordered(
state.selectedFiatCurrencies.moved(index, index + 1),
)
},
onMoveToTop = {
viewModel.onFiatCurrenciesReordered(
state.selectedFiatCurrencies.moved(index, 0),
)
},
onMoveToBottom = {
viewModel.onFiatCurrenciesReordered(
state.selectedFiatCurrencies.moved(index, state.selectedFiatCurrencies.lastIndex),
)
},
onRemove = { viewModel.onFiatCurrencyToggled(code) },
)
}
}
}
}
}
}
}
}
}
private fun List<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
@Composable
private fun CurrencyRowMenu(
code: String,
canMoveUp: Boolean,
canMoveDown: Boolean,
canRemove: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onMoveToTop: () -> Unit,
onMoveToBottom: () -> Unit,
onRemove: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(MR.strings.currency_options_content_description, code),
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.move_currency_to_top_content_description)) },
leadingIcon = { Icon(Icons.Default.KeyboardDoubleArrowUp, contentDescription = null) },
enabled = canMoveUp,
onClick = {
expanded = false
onMoveToTop()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.move_currency_up_content_description)) },
leadingIcon = { Icon(Icons.Default.KeyboardArrowUp, contentDescription = null) },
enabled = canMoveUp,
onClick = {
expanded = false
onMoveUp()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.move_currency_down_content_description)) },
leadingIcon = { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) },
enabled = canMoveDown,
onClick = {
expanded = false
onMoveDown()
},
)
DropdownMenuItem(
text = { Text(stringResource(MR.strings.move_currency_to_bottom_content_description)) },
leadingIcon = { Icon(Icons.Default.KeyboardDoubleArrowDown, contentDescription = null) },
enabled = canMoveDown,
onClick = {
expanded = false
onMoveToBottom()
},
)
if (canRemove) {
DropdownMenuItem(
text = { Text(stringResource(MR.strings.remove_currency_content_description)) },
leadingIcon = { Icon(Icons.Default.Close, contentDescription = null) },
onClick = {
expanded = false
onRemove()
},
)
}
}
}
}
/**
* A full-screen takeover (rather than a dropdown) matching the previous Skip-based app's
* dedicated currency selection screen: a pinned, non-removable "Current Currency", the other
* currencies the user added (removable), and the remaining ones available to add.
*/
@Composable
private fun CurrencyPickerScreen(
state: ConverterUiState,
onToggle: (String) -> Unit,
onReset: () -> Unit,
onDone: () -> Unit,
) {
var showResetConfirmation by remember { mutableStateOf(false) }
if (showResetConfirmation) {
AlertDialog(
onDismissRequest = { showResetConfirmation = false },
title = { Text(stringResource(MR.strings.reset_selected_currencies_confirmation_title)) },
text = {
Text(stringResource(MR.strings.reset_selected_currencies_confirmation_message, state.defaultCurrencyCode))
},
confirmButton = {
TextButton(
onClick = {
showResetConfirmation = false
onReset()
},
) { Text(stringResource(MR.strings.reset_selected_currencies_button)) }
},
dismissButton = {
TextButton(onClick = { showResetConfirmation = false }) { Text(stringResource(MR.strings.cancel)) }
},
)
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = screenHorizontalPadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(MR.strings.currencies_section_title), style = MaterialTheme.typography.headlineSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.selectedFiatCurrencies.size > 1) {
TextButton(onClick = { showResetConfirmation = true }) {
Text(stringResource(MR.strings.reset_selected_currencies_button))
}
}
TextButton(onClick = onDone) { Text(stringResource(MR.strings.done)) }
}
}
var searchQuery by remember { mutableStateOf("") }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text(stringResource(MR.strings.search_currencies_placeholder)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = if (searchQuery.isNotEmpty()) {
{
IconButton(onClick = { searchQuery = "" }) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(MR.strings.clear_search_content_description),
)
}
}
} else {
null
},
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = screenHorizontalPadding),
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = screenHorizontalPadding, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val currentCurrency = state.currentCurrency()
val selectedOthers = state.selectedOtherCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
val unselected = state.unselectedCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
if (matchesCurrencySearch(currentCurrency, searchQuery)) {
Column {
Text(
stringResource(MR.strings.current_currency_section_title).uppercase(),
style = SectionHeaderStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
CurrencyRow(
info = currentCurrency,
isSelected = true,
isPriced = state.isPriced(currentCurrency.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = null,
)
}
}
if (selectedOthers.isNotEmpty()) {
Column {
Text(
stringResource(MR.strings.selected_currencies_section_title).uppercase(),
style = SectionHeaderStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
selectedOthers.forEach { info ->
key(info.code) {
CurrencyRow(
info = info,
isSelected = true,
isPriced = state.isPriced(info.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
}
}
}
val (unselectedPriced, unselectedUnpriced) = unselected.partition { state.isPriced(it.code) }
if (unselectedPriced.isNotEmpty()) {
Column {
Text(
stringResource(MR.strings.priced_currencies_section_title).uppercase(),
style = SectionHeaderStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
unselectedPriced.forEach { info ->
key(info.code) {
CurrencyRow(
info = info,
isSelected = false,
isPriced = true,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
}
}
}
if (unselectedUnpriced.isNotEmpty()) {
Column {
Text(
stringResource(MR.strings.unpriced_currencies_section_title).uppercase(),
style = SectionHeaderStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
unselectedUnpriced.forEach { info ->
key(info.code) {
CurrencyRow(
info = info,
isSelected = false,
isPriced = false,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
}
}
}
}
}
}
}
@Composable
private fun CurrencyRow(
info: CurrencyInfo,
isSelected: Boolean,
isPriced: Boolean,
sourceName: String,
localeCurrencyCode: String?,
onClick: (() -> Unit)?,
) {
val text = 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)
}
val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text
Row(
modifier = Modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(label)
if (!isPriced) {
Text(
stringResource(MR.strings.currency_not_priced, sourceName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@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,6 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
actual val screenHorizontalPadding: Dp = 24.dp
@@ -0,0 +1,6 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
actual val screenHorizontalPadding: Dp = 16.dp