Add manual currency sorting and improve currency categorization
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user