Add manual currency sorting and improve currency categorization
This commit is contained in:
@@ -6,9 +6,12 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.tyiu.satsprice.ui.ConverterUiState
|
||||
import xyz.tyiu.satsprice.ui.PriceViewModel
|
||||
import xyz.tyiu.satsprice.ui.currentCurrency
|
||||
import xyz.tyiu.satsprice.ui.defaultCurrencyRate
|
||||
import xyz.tyiu.satsprice.ui.exceedsMaxSupply
|
||||
import xyz.tyiu.satsprice.ui.selectedOtherCurrencies
|
||||
import xyz.tyiu.satsprice.ui.statusLine
|
||||
import xyz.tyiu.satsprice.ui.unselectedCurrencies
|
||||
|
||||
data class FiatRow(val code: String, val amount: String, val rateDisplay: String)
|
||||
|
||||
@@ -17,7 +20,9 @@ data class IosConverterState(
|
||||
val satsAmount: String,
|
||||
val exceedsMaxSupply: Boolean,
|
||||
val fiatRows: List<FiatRow>,
|
||||
val availableCurrencies: List<CurrencyInfo>,
|
||||
val currentCurrency: CurrencyInfo,
|
||||
val selectedOtherCurrencies: List<CurrencyInfo>,
|
||||
val unselectedCurrencies: List<CurrencyInfo>,
|
||||
val selectedCurrencyCodes: List<String>,
|
||||
val localeCurrencyCode: String?,
|
||||
val defaultCurrencyCode: String,
|
||||
@@ -53,6 +58,7 @@ class IosPriceViewModel {
|
||||
fun onSatsAmountChanged(value: String) = viewModel.onSatsAmountChanged(value)
|
||||
fun onFiatAmountChanged(code: String, value: String) = viewModel.onFiatAmountChanged(code, value)
|
||||
fun onFiatCurrencyToggled(code: String) = viewModel.onFiatCurrencyToggled(code)
|
||||
fun onFiatCurrenciesReordered(newOrder: List<String>) = viewModel.onFiatCurrenciesReordered(newOrder)
|
||||
fun onSourceSelected(name: String) = viewModel.onSourceSelected(name)
|
||||
fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value)
|
||||
}
|
||||
@@ -64,7 +70,9 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
|
||||
fiatRows = selectedFiatCurrencies.map { code ->
|
||||
FiatRow(code, fiatAmounts[code].orEmpty(), rateDisplays[code].orEmpty())
|
||||
},
|
||||
availableCurrencies = availableFiatCurrencies,
|
||||
currentCurrency = currentCurrency(),
|
||||
selectedOtherCurrencies = selectedOtherCurrencies(),
|
||||
unselectedCurrencies = unselectedCurrencies(),
|
||||
selectedCurrencyCodes = selectedFiatCurrencies,
|
||||
localeCurrencyCode = localeCurrencyCode,
|
||||
defaultCurrencyCode = defaultCurrencyCode,
|
||||
|
||||
@@ -2,6 +2,7 @@ package xyz.tyiu.satsprice.ui
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import xyz.tyiu.satsprice.CurrencyInfo
|
||||
import xyz.tyiu.satsprice.domain.CurrencyConverter
|
||||
import xyz.tyiu.satsprice.domain.toBigDecimalOrNull
|
||||
import kotlin.time.Instant
|
||||
@@ -23,6 +24,19 @@ fun ConverterUiState.exceedsMaxSupply(): Boolean {
|
||||
fun ConverterUiState.defaultCurrencyRate(): String =
|
||||
rateDisplays[defaultCurrencyCode]?.takeIf { it.isNotEmpty() } ?: ""
|
||||
|
||||
/** The pinned, non-removable "Current Currency" shown in the currency picker's own section. */
|
||||
fun ConverterUiState.currentCurrency(): CurrencyInfo =
|
||||
availableFiatCurrencies.find { it.code == defaultCurrencyCode } ?: CurrencyInfo(defaultCurrencyCode, defaultCurrencyCode)
|
||||
|
||||
/** Selected currencies other than [ConverterUiState.defaultCurrencyCode], in the user's chosen order. */
|
||||
fun ConverterUiState.selectedOtherCurrencies(): List<CurrencyInfo> =
|
||||
selectedFiatCurrencies.filter { it != defaultCurrencyCode }
|
||||
.mapNotNull { code -> availableFiatCurrencies.find { it.code == code } }
|
||||
|
||||
/** Currencies not yet added, offered in the currency picker's "Currencies" section. */
|
||||
fun ConverterUiState.unselectedCurrencies(): List<CurrencyInfo> =
|
||||
availableFiatCurrencies.filterNot { it.code in selectedFiatCurrencies }
|
||||
|
||||
private fun Instant.toDateTimeString(): String {
|
||||
val local = toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
return local.toString().substringBefore('.').replace('T', ' ')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -18,11 +19,14 @@ 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.MoreVert
|
||||
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
|
||||
@@ -58,6 +62,16 @@ 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,
|
||||
onDone = { showCurrencyPicker = false },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -207,21 +221,41 @@ fun PriceScreen(
|
||||
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,
|
||||
)
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedFiatCurrencies.forEach { code ->
|
||||
state.selectedFiatCurrencies.forEachIndexed { index, code ->
|
||||
key(code) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CurrencyRowMenu(
|
||||
code = code,
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < state.selectedFiatCurrencies.lastIndex,
|
||||
canRemove = code != state.defaultCurrencyCode,
|
||||
onMoveUp = {
|
||||
viewModel.onFiatCurrenciesReordered(
|
||||
state.selectedFiatCurrencies.moved(index, index - 1),
|
||||
)
|
||||
},
|
||||
onMoveDown = {
|
||||
viewModel.onFiatCurrenciesReordered(
|
||||
state.selectedFiatCurrencies.moved(index, index + 1),
|
||||
)
|
||||
},
|
||||
onRemove = { viewModel.onFiatCurrencyToggled(code) },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.fiatAmounts[code].orEmpty(),
|
||||
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
|
||||
@@ -230,15 +264,6 @@ fun PriceScreen(
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { viewModel.onFiatCurrencyToggled(code) }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(
|
||||
MR.strings.remove_currency_content_description,
|
||||
code,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,42 +273,176 @@ fun PriceScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
|
||||
toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
|
||||
@Composable
|
||||
private fun MultiCurrencySelector(
|
||||
selectedCodes: List<String>,
|
||||
options: List<CurrencyInfo>,
|
||||
localeCurrencyCode: String?,
|
||||
onToggle: (String) -> Unit,
|
||||
private fun CurrencyRowMenu(
|
||||
code: String,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
canRemove: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit,
|
||||
onRemove: () -> 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)
|
||||
},
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(MR.strings.currency_options_content_description, code),
|
||||
)
|
||||
}
|
||||
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(stringResource(MR.strings.move_currency_up_content_description, code)) },
|
||||
leadingIcon = { Icon(Icons.Default.KeyboardArrowUp, contentDescription = null) },
|
||||
enabled = canMoveUp,
|
||||
onClick = {
|
||||
expanded = false
|
||||
onMoveUp()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.move_currency_down_content_description, code)) },
|
||||
leadingIcon = { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) },
|
||||
enabled = canMoveDown,
|
||||
onClick = {
|
||||
expanded = false
|
||||
onMoveDown()
|
||||
},
|
||||
)
|
||||
if (canRemove) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
leadingIcon = { Checkbox(checked = checked, onCheckedChange = { onToggle(info.code) }) },
|
||||
onClick = { onToggle(info.code) },
|
||||
text = { Text(stringResource(MR.strings.remove_currency_content_description, code)) },
|
||||
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,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
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)
|
||||
TextButton(onClick = onDone) { Text(stringResource(MR.strings.done)) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = screenHorizontalPadding, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val selectedOthers = state.selectedOtherCurrencies()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
stringResource(MR.strings.current_currency_section_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
CurrencyRow(
|
||||
info = state.currentCurrency(),
|
||||
isSelected = true,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedOthers.isNotEmpty()) {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(MR.strings.selected_currencies_section_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
selectedOthers.forEach { info ->
|
||||
key(info.code) {
|
||||
CurrencyRow(
|
||||
info = info,
|
||||
isSelected = true,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = { onToggle(info.code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(stringResource(MR.strings.currencies_section_title), style = MaterialTheme.typography.titleMedium)
|
||||
state.unselectedCurrencies().forEach { info ->
|
||||
key(info.code) {
|
||||
CurrencyRow(
|
||||
info = info,
|
||||
isSelected = false,
|
||||
localeCurrencyCode = state.localeCurrencyCode,
|
||||
onClick = { onToggle(info.code) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrencyRow(
|
||||
info: CurrencyInfo,
|
||||
isSelected: Boolean,
|
||||
localeCurrencyCode: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label)
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> DropdownSelector(
|
||||
selectedLabel: String,
|
||||
|
||||
@@ -113,8 +113,12 @@ class PriceViewModel(
|
||||
matching + rest
|
||||
}
|
||||
val availableCodes = available.map { it.code }.toSet()
|
||||
val selection = state.selectedFiatCurrencies.filter { it in availableCodes }
|
||||
.ifEmpty { listOfNotNull(available.firstOrNull()?.code) }
|
||||
// defaultCurrencyCode is the pinned, non-removable "Current Currency" and must
|
||||
// always be present, even if it briefly lacks a rate; everything else is
|
||||
// dropped once its rate disappears. Filtering (rather than re-deriving) keeps
|
||||
// whatever order the user picked via onFiatCurrenciesReordered.
|
||||
val filtered = state.selectedFiatCurrencies.filter { it == defaultCurrencyCode || it in availableCodes }
|
||||
val selection = if (defaultCurrencyCode in filtered) filtered else listOf(defaultCurrencyCode) + filtered
|
||||
recomputeFromKnownField(
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
@@ -141,7 +145,9 @@ class PriceViewModel(
|
||||
fun onFiatAmountChanged(code: String, value: String) =
|
||||
updateAmount(EditedField.Fiat(code), sanitizeDecimalInput(value))
|
||||
|
||||
/** [defaultCurrencyCode] is the pinned "Current Currency" and can't be removed. */
|
||||
fun onFiatCurrencyToggled(code: String) {
|
||||
if (code == defaultCurrencyCode) return
|
||||
_uiState.update { state ->
|
||||
val newSelection = if (code in state.selectedFiatCurrencies) {
|
||||
state.selectedFiatCurrencies - code
|
||||
@@ -153,6 +159,23 @@ class PriceViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders the selected currencies to [newOrder]. Any code in [newOrder] that isn't
|
||||
* currently selected is ignored, and any currently-selected code missing from [newOrder]
|
||||
* keeps its relative position at the end — callers only need to describe the reordering of
|
||||
* codes they know about (e.g. a Swift `List.onMove`'s resulting order).
|
||||
*/
|
||||
fun onFiatCurrenciesReordered(newOrder: List<String>) {
|
||||
_uiState.update { state ->
|
||||
val known = newOrder.filter { it in state.selectedFiatCurrencies }
|
||||
val missing = state.selectedFiatCurrencies.filterNot { it in known }
|
||||
val newSelection = known + missing
|
||||
if (newSelection == state.selectedFiatCurrencies) return@update state
|
||||
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
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
<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="current_currency_section_title">Current Currency</string>
|
||||
<string name="selected_currencies_section_title">Selected 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="move_currency_up_content_description">Move %1$s up</string>
|
||||
<string name="move_currency_down_content_description">Move %1$s down</string>
|
||||
<string name="currency_options_content_description">Options for %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="currency_option_label_local">%1$s - %2$s</string>
|
||||
<string name="done">Done</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user