Add manual currency sorting and improve currency categorization

This commit is contained in:
2026-09-07 22:15:29 +03:00
parent deef01a58a
commit 2925c845bd
9 changed files with 348 additions and 64 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
TEAM_ID= TEAM_ID=S99A5B637C
PRODUCT_NAME=SatsPrice PRODUCT_NAME=SatsPrice
PRODUCT_BUNDLE_IDENTIFIER=xyz.tyiu.SatsPrice PRODUCT_BUNDLE_IDENTIFIER=xyz.tyiu.SatsPrice
+54 -6
View File
@@ -15,6 +15,15 @@ struct ContentView: View {
} }
} }
.navigationTitle("SatsPrice") .navigationTitle("SatsPrice")
#if os(iOS)
// macOS Lists support drag-to-reorder directly; iOS only shows reorder handles
// once edit mode is active, which this toggles.
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
#endif
} }
} }
@@ -102,7 +111,7 @@ struct ContentView: View {
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) { Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
Button( Button(
state.selectedCurrencyCodes.isEmpty state.selectedCurrencyCodes.count <= 1
? IosLocalizationKt.localizedString(resource: MR.strings.shared.add_currency) ? IosLocalizationKt.localizedString(resource: MR.strings.shared.add_currency)
: IosLocalizationKt.localizedFormattedString( : IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currencies_selected_count, resource: MR.strings.shared.currencies_selected_count,
@@ -112,14 +121,30 @@ struct ContentView: View {
showCurrencyPicker = true showCurrencyPicker = true
} }
ForEach(state.fiatRows, id: \.code) { row in ForEach(Array(state.fiatRows.enumerated()), id: \.element.code) { index, row in
amountRow( amountRow(
label: row.code, label: row.code,
value: row.amount, value: row.amount,
keyboardType: .decimalPad, keyboardType: .decimalPad,
sanitize: sanitizeDecimalInput, sanitize: sanitizeDecimalInput,
onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) } onChange: { viewModel.onFiatAmountChanged(code: row.code, value: $0) },
onMoveUp: index > 0 ? {
var codes = state.fiatRows.map(\.code)
codes.move(fromOffsets: [index], toOffset: index - 1)
viewModel.onFiatCurrenciesReordered(codes)
} : nil,
onMoveDown: index < state.fiatRows.count - 1 ? {
var codes = state.fiatRows.map(\.code)
codes.move(fromOffsets: [index], toOffset: index + 2)
viewModel.onFiatCurrenciesReordered(codes)
} : nil
) )
.deleteDisabled(row.code == state.defaultCurrencyCode)
}
.onMove { indices, newOffset in
var codes = state.fiatRows.map(\.code)
codes.move(fromOffsets: indices, toOffset: newOffset)
viewModel.onFiatCurrenciesReordered(codes)
} }
#if os(iOS) #if os(iOS)
.onDelete { indexSet in .onDelete { indexSet in
@@ -135,8 +160,9 @@ struct ContentView: View {
#endif #endif
.sheet(isPresented: $showCurrencyPicker) { .sheet(isPresented: $showCurrencyPicker) {
CurrencyPickerSheet( CurrencyPickerSheet(
currencies: state.availableCurrencies, currentCurrency: state.currentCurrency,
selectedCodes: state.selectedCurrencyCodes, selectedOtherCurrencies: state.selectedOtherCurrencies,
unselectedCurrencies: state.unselectedCurrencies,
localeCurrencyCode: state.localeCurrencyCode, localeCurrencyCode: state.localeCurrencyCode,
onToggle: { viewModel.onFiatCurrencyToggled($0) } onToggle: { viewModel.onFiatCurrencyToggled($0) }
) )
@@ -149,9 +175,31 @@ struct ContentView: View {
value: String, value: String,
keyboardType: NumericFieldKeyboard, keyboardType: NumericFieldKeyboard,
sanitize: @escaping (String) -> String, sanitize: @escaping (String) -> String,
onChange: @escaping (String) -> Void onChange: @escaping (String) -> Void,
onMoveUp: (() -> Void)? = nil,
onMoveDown: (() -> Void)? = nil
) -> some View { ) -> some View {
HStack { HStack {
#if os(macOS)
// macOS's Form isn't List-backed, so .onMove's drag-to-reorder has no effect here;
// these buttons are macOS's equivalent of iOS's Edit-mode drag handles.
if onMoveUp != nil || onMoveDown != nil {
VStack(spacing: 2) {
Button(action: { onMoveUp?() }) {
Image(systemName: "chevron.up")
.frame(width: 16, height: 10)
}
.disabled(onMoveUp == nil)
Button(action: { onMoveDown?() }) {
Image(systemName: "chevron.down")
.frame(width: 16, height: 10)
}
.disabled(onMoveDown == nil)
}
.font(.system(size: 10))
.buttonStyle(.borderless)
}
#endif
Text(label) Text(label)
Spacer() Spacer()
NumericField( NumericField(
+4
View File
@@ -35,6 +35,10 @@ final class ConverterViewModel: ObservableObject {
bridge.onFiatCurrencyToggled(code: code) bridge.onFiatCurrencyToggled(code: code)
} }
func onFiatCurrenciesReordered(_ newOrder: [String]) {
bridge.onFiatCurrenciesReordered(newOrder: newOrder)
}
func onSourceSelected(_ name: String) { func onSourceSelected(_ name: String) {
bridge.onSourceSelected(name: name) bridge.onSourceSelected(name: name)
} }
+36 -13
View File
@@ -2,8 +2,9 @@ import Shared
import SwiftUI import SwiftUI
struct CurrencyPickerSheet: View { struct CurrencyPickerSheet: View {
let currencies: [CurrencyInfo] let currentCurrency: CurrencyInfo
let selectedCodes: [String] let selectedOtherCurrencies: [CurrencyInfo]
let unselectedCurrencies: [CurrencyInfo]
let localeCurrencyCode: String? let localeCurrencyCode: String?
let onToggle: (String) -> Void let onToggle: (String) -> Void
@@ -11,18 +12,22 @@ struct CurrencyPickerSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List(currencies, id: \.code) { info in List {
Button { Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.current_currency_section_title)) {
onToggle(info.code) currencyRow(for: currentCurrency, isSelected: true, onTap: nil)
} label: {
HStack {
Text(currencyLabel(for: info))
.foregroundColor(.primary)
Spacer()
if selectedCodes.contains(info.code) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
} }
if !selectedOtherCurrencies.isEmpty {
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.selected_currencies_section_title)) {
ForEach(selectedOtherCurrencies, id: \.code) { info in
currencyRow(for: info, isSelected: true, onTap: { onToggle(info.code) })
}
}
}
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
ForEach(unselectedCurrencies, id: \.code) { info in
currencyRow(for: info, isSelected: false, onTap: { onToggle(info.code) })
} }
} }
} }
@@ -44,6 +49,24 @@ struct CurrencyPickerSheet: View {
#endif #endif
} }
@ViewBuilder
private func currencyRow(for info: CurrencyInfo, isSelected: Bool, onTap: (() -> Void)?) -> some View {
let content = HStack {
Text(currencyLabel(for: info))
.foregroundColor(.primary)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
if let onTap {
Button(action: onTap) { content }
} else {
content
}
}
private func currencyLabel(for info: CurrencyInfo) -> String { private func currencyLabel(for info: CurrencyInfo) -> String {
if info.code == localeCurrencyCode { if info.code == localeCurrencyCode {
return IosLocalizationKt.localizedFormattedString( return IosLocalizationKt.localizedFormattedString(
@@ -6,9 +6,12 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import xyz.tyiu.satsprice.ui.ConverterUiState import xyz.tyiu.satsprice.ui.ConverterUiState
import xyz.tyiu.satsprice.ui.PriceViewModel import xyz.tyiu.satsprice.ui.PriceViewModel
import xyz.tyiu.satsprice.ui.currentCurrency
import xyz.tyiu.satsprice.ui.defaultCurrencyRate import xyz.tyiu.satsprice.ui.defaultCurrencyRate
import xyz.tyiu.satsprice.ui.exceedsMaxSupply import xyz.tyiu.satsprice.ui.exceedsMaxSupply
import xyz.tyiu.satsprice.ui.selectedOtherCurrencies
import xyz.tyiu.satsprice.ui.statusLine import xyz.tyiu.satsprice.ui.statusLine
import xyz.tyiu.satsprice.ui.unselectedCurrencies
data class FiatRow(val code: String, val amount: String, val rateDisplay: String) data class FiatRow(val code: String, val amount: String, val rateDisplay: String)
@@ -17,7 +20,9 @@ data class IosConverterState(
val satsAmount: String, val satsAmount: String,
val exceedsMaxSupply: Boolean, val exceedsMaxSupply: Boolean,
val fiatRows: List<FiatRow>, val fiatRows: List<FiatRow>,
val availableCurrencies: List<CurrencyInfo>, val currentCurrency: CurrencyInfo,
val selectedOtherCurrencies: List<CurrencyInfo>,
val unselectedCurrencies: List<CurrencyInfo>,
val selectedCurrencyCodes: List<String>, val selectedCurrencyCodes: List<String>,
val localeCurrencyCode: String?, val localeCurrencyCode: String?,
val defaultCurrencyCode: String, val defaultCurrencyCode: String,
@@ -53,6 +58,7 @@ class IosPriceViewModel {
fun onSatsAmountChanged(value: String) = viewModel.onSatsAmountChanged(value) fun onSatsAmountChanged(value: String) = viewModel.onSatsAmountChanged(value)
fun onFiatAmountChanged(code: String, value: String) = viewModel.onFiatAmountChanged(code, value) fun onFiatAmountChanged(code: String, value: String) = viewModel.onFiatAmountChanged(code, value)
fun onFiatCurrencyToggled(code: String) = viewModel.onFiatCurrencyToggled(code) fun onFiatCurrencyToggled(code: String) = viewModel.onFiatCurrencyToggled(code)
fun onFiatCurrenciesReordered(newOrder: List<String>) = viewModel.onFiatCurrenciesReordered(newOrder)
fun onSourceSelected(name: String) = viewModel.onSourceSelected(name) fun onSourceSelected(name: String) = viewModel.onSourceSelected(name)
fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value) fun onManualRateChanged(value: String) = viewModel.onManualRateChanged(value)
} }
@@ -64,7 +70,9 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
fiatRows = selectedFiatCurrencies.map { code -> fiatRows = selectedFiatCurrencies.map { code ->
FiatRow(code, fiatAmounts[code].orEmpty(), rateDisplays[code].orEmpty()) FiatRow(code, fiatAmounts[code].orEmpty(), rateDisplays[code].orEmpty())
}, },
availableCurrencies = availableFiatCurrencies, currentCurrency = currentCurrency(),
selectedOtherCurrencies = selectedOtherCurrencies(),
unselectedCurrencies = unselectedCurrencies(),
selectedCurrencyCodes = selectedFiatCurrencies, selectedCurrencyCodes = selectedFiatCurrencies,
localeCurrencyCode = localeCurrencyCode, localeCurrencyCode = localeCurrencyCode,
defaultCurrencyCode = defaultCurrencyCode, defaultCurrencyCode = defaultCurrencyCode,
@@ -2,6 +2,7 @@ package xyz.tyiu.satsprice.ui
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import xyz.tyiu.satsprice.CurrencyInfo
import xyz.tyiu.satsprice.domain.CurrencyConverter import xyz.tyiu.satsprice.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.toBigDecimalOrNull import xyz.tyiu.satsprice.domain.toBigDecimalOrNull
import kotlin.time.Instant import kotlin.time.Instant
@@ -23,6 +24,19 @@ fun ConverterUiState.exceedsMaxSupply(): Boolean {
fun ConverterUiState.defaultCurrencyRate(): String = fun ConverterUiState.defaultCurrencyRate(): String =
rateDisplays[defaultCurrencyCode]?.takeIf { it.isNotEmpty() } ?: "" 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 { private fun Instant.toDateTimeString(): String {
val local = toLocalDateTime(TimeZone.currentSystemDefault()) val local = toLocalDateTime(TimeZone.currentSystemDefault())
return local.toString().substringBefore('.').replace('T', ' ') return local.toString().substringBefore('.').replace('T', ' ')
@@ -1,6 +1,7 @@
package xyz.tyiu.satsprice.ui package xyz.tyiu.satsprice.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.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.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -58,6 +62,16 @@ fun PriceScreen(
viewModel: PriceViewModel = viewModel { PriceViewModel() }, viewModel: PriceViewModel = viewModel { PriceViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -207,21 +221,41 @@ fun PriceScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(stringResource(MR.strings.currencies_section_title), style = MaterialTheme.typography.titleMedium) Text(stringResource(MR.strings.currencies_section_title), style = MaterialTheme.typography.titleMedium)
MultiCurrencySelector( OutlinedButton(onClick = { showCurrencyPicker = true }) {
selectedCodes = state.selectedFiatCurrencies, Text(
options = state.availableFiatCurrencies, if (state.selectedFiatCurrencies.size <= 1) {
localeCurrencyCode = state.localeCurrencyCode, stringResource(MR.strings.add_currency)
onToggle = viewModel::onFiatCurrencyToggled, } else {
stringResource(MR.strings.currencies_selected_count, state.selectedFiatCurrencies.size)
},
) )
} }
}
state.selectedFiatCurrencies.forEach { code -> state.selectedFiatCurrencies.forEachIndexed { index, code ->
key(code) { key(code) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, 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( OutlinedTextField(
value = state.fiatAmounts[code].orEmpty(), value = state.fiatAmounts[code].orEmpty(),
onValueChange = { viewModel.onFiatAmountChanged(code, it) }, onValueChange = { viewModel.onFiatAmountChanged(code, it) },
@@ -230,15 +264,6 @@ fun PriceScreen(
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = { viewModel.onFiatCurrencyToggled(code) }) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(
MR.strings.remove_currency_content_description,
code,
),
)
}
} }
} }
} }
@@ -248,40 +273,174 @@ fun PriceScreen(
} }
} }
private fun List<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
@Composable @Composable
private fun MultiCurrencySelector( private fun CurrencyRowMenu(
selectedCodes: List<String>, code: String,
options: List<CurrencyInfo>, canMoveUp: Boolean,
localeCurrencyCode: String?, canMoveDown: Boolean,
onToggle: (String) -> Unit, canRemove: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box { Box {
OutlinedButton(onClick = { expanded = true }) { IconButton(onClick = { expanded = true }) {
Text( Icon(
if (selectedCodes.isEmpty()) { Icons.Default.MoreVert,
stringResource(MR.strings.add_currency) contentDescription = stringResource(MR.strings.currency_options_content_description, code),
} else {
stringResource(MR.strings.currencies_selected_count, selectedCodes.size)
},
) )
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { info -> DropdownMenuItem(
val checked = info.code in selectedCodes 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(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) { val label = if (info.code == localeCurrencyCode) {
stringResource(MR.strings.currency_option_label_local, info.code, info.displayName) stringResource(MR.strings.currency_option_label_local, info.code, info.displayName)
} else { } else {
stringResource(MR.strings.currency_option_label, info.code, info.displayName) stringResource(MR.strings.currency_option_label, info.code, info.displayName)
} }
DropdownMenuItem( Row(
text = { Text(label) }, modifier = Modifier
leadingIcon = { Checkbox(checked = checked, onCheckedChange = { onToggle(info.code) }) }, .fillMaxWidth()
onClick = { onToggle(info.code) }, .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 @Composable
@@ -113,8 +113,12 @@ class PriceViewModel(
matching + rest matching + rest
} }
val availableCodes = available.map { it.code }.toSet() val availableCodes = available.map { it.code }.toSet()
val selection = state.selectedFiatCurrencies.filter { it in availableCodes } // defaultCurrencyCode is the pinned, non-removable "Current Currency" and must
.ifEmpty { listOfNotNull(available.firstOrNull()?.code) } // 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( recomputeFromKnownField(
state.copy( state.copy(
isLoading = false, isLoading = false,
@@ -141,7 +145,9 @@ class PriceViewModel(
fun onFiatAmountChanged(code: String, value: String) = fun onFiatAmountChanged(code: String, value: String) =
updateAmount(EditedField.Fiat(code), sanitizeDecimalInput(value)) updateAmount(EditedField.Fiat(code), sanitizeDecimalInput(value))
/** [defaultCurrencyCode] is the pinned "Current Currency" and can't be removed. */
fun onFiatCurrencyToggled(code: String) { fun onFiatCurrencyToggled(code: String) {
if (code == defaultCurrencyCode) return
_uiState.update { state -> _uiState.update { state ->
val newSelection = if (code in state.selectedFiatCurrencies) { val newSelection = if (code in state.selectedFiatCurrencies) {
state.selectedFiatCurrencies - code 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) { fun onSourceSelected(displayName: String) {
val selected = sources.firstOrNull { it.displayName == displayName } ?: return val selected = sources.firstOrNull { it.displayName == displayName } ?: return
currentSource = selected currentSource = selected
@@ -9,10 +9,15 @@
<string name="btc_label">BTC</string> <string name="btc_label">BTC</string>
<string name="exceeds_max_supply">Exceeds the 21,000,000 BTC maximum supply</string> <string name="exceeds_max_supply">Exceeds the 21,000,000 BTC maximum supply</string>
<string name="currencies_section_title">Currencies</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="add_currency">Add currency</string>
<string name="currencies_selected_count">%1$d selected</string> <string name="currencies_selected_count">%1$d selected</string>
<string name="remove_currency_content_description">Remove %1$s</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">%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> <string name="done">Done</string>
</resources> </resources>