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_BUNDLE_IDENTIFIER=xyz.tyiu.SatsPrice
+54 -6
View File
@@ -15,6 +15,15 @@ struct ContentView: View {
}
}
.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)) {
Button(
state.selectedCurrencyCodes.isEmpty
state.selectedCurrencyCodes.count <= 1
? IosLocalizationKt.localizedString(resource: MR.strings.shared.add_currency)
: IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currencies_selected_count,
@@ -112,14 +121,30 @@ struct ContentView: View {
showCurrencyPicker = true
}
ForEach(state.fiatRows, id: \.code) { row in
ForEach(Array(state.fiatRows.enumerated()), id: \.element.code) { index, row in
amountRow(
label: row.code,
value: row.amount,
keyboardType: .decimalPad,
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)
.onDelete { indexSet in
@@ -135,8 +160,9 @@ struct ContentView: View {
#endif
.sheet(isPresented: $showCurrencyPicker) {
CurrencyPickerSheet(
currencies: state.availableCurrencies,
selectedCodes: state.selectedCurrencyCodes,
currentCurrency: state.currentCurrency,
selectedOtherCurrencies: state.selectedOtherCurrencies,
unselectedCurrencies: state.unselectedCurrencies,
localeCurrencyCode: state.localeCurrencyCode,
onToggle: { viewModel.onFiatCurrencyToggled($0) }
)
@@ -149,9 +175,31 @@ struct ContentView: View {
value: String,
keyboardType: NumericFieldKeyboard,
sanitize: @escaping (String) -> String,
onChange: @escaping (String) -> Void
onChange: @escaping (String) -> Void,
onMoveUp: (() -> Void)? = nil,
onMoveDown: (() -> Void)? = nil
) -> some View {
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)
Spacer()
NumericField(
+4
View File
@@ -35,6 +35,10 @@ final class ConverterViewModel: ObservableObject {
bridge.onFiatCurrencyToggled(code: code)
}
func onFiatCurrenciesReordered(_ newOrder: [String]) {
bridge.onFiatCurrenciesReordered(newOrder: newOrder)
}
func onSourceSelected(_ name: String) {
bridge.onSourceSelected(name: name)
}
+36 -13
View File
@@ -2,8 +2,9 @@ import Shared
import SwiftUI
struct CurrencyPickerSheet: View {
let currencies: [CurrencyInfo]
let selectedCodes: [String]
let currentCurrency: CurrencyInfo
let selectedOtherCurrencies: [CurrencyInfo]
let unselectedCurrencies: [CurrencyInfo]
let localeCurrencyCode: String?
let onToggle: (String) -> Void
@@ -11,20 +12,24 @@ struct CurrencyPickerSheet: View {
var body: some View {
NavigationStack {
List(currencies, id: \.code) { info in
Button {
onToggle(info.code)
} label: {
HStack {
Text(currencyLabel(for: info))
.foregroundColor(.primary)
Spacer()
if selectedCodes.contains(info.code) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
List {
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.current_currency_section_title)) {
currencyRow(for: currentCurrency, isSelected: true, onTap: nil)
}
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) })
}
}
}
.navigationTitle(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title))
#if os(iOS)
@@ -44,6 +49,24 @@ struct CurrencyPickerSheet: View {
#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 {
if info.code == localeCurrencyCode {
return IosLocalizationKt.localizedFormattedString(
@@ -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>