Add search to the currency picker
Filters by code or display name, case-insensitively, across all three sections (current, selected, and remaining currencies) — a section disappears entirely once nothing in it matches. The matching logic lives in shared code (matchesCurrencySearch) so Compose and SwiftUI stay in sync; SwiftUI wires it into the native .searchable() field, Compose gets a plain search text field with a clear button. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
This commit is contained in:
@@ -11,28 +11,43 @@ struct CurrencyPickerSheet: View {
|
|||||||
let onToggle: (String) -> Void
|
let onToggle: (String) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchQuery = ""
|
||||||
|
|
||||||
|
private func matches(_ info: CurrencyInfo) -> Bool {
|
||||||
|
SystemCurrenciesKt.matchesCurrencySearch(info: info, query: searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.current_currency_section_title)) {
|
if matches(currentCurrency) {
|
||||||
currencyRow(for: currentCurrency, isSelected: true, onTap: nil)
|
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.current_currency_section_title)) {
|
||||||
|
currencyRow(for: currentCurrency, isSelected: true, onTap: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !selectedOtherCurrencies.isEmpty {
|
let matchingSelectedOthers = selectedOtherCurrencies.filter(matches)
|
||||||
|
if !matchingSelectedOthers.isEmpty {
|
||||||
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.selected_currencies_section_title)) {
|
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.selected_currencies_section_title)) {
|
||||||
ForEach(selectedOtherCurrencies, id: \.code) { info in
|
ForEach(matchingSelectedOthers, id: \.code) { info in
|
||||||
currencyRow(for: info, isSelected: true, onTap: { onToggle(info.code) })
|
currencyRow(for: info, isSelected: true, onTap: { onToggle(info.code) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
|
let matchingUnselected = unselectedCurrencies.filter(matches)
|
||||||
ForEach(unselectedCurrencies, id: \.code) { info in
|
if !matchingUnselected.isEmpty {
|
||||||
currencyRow(for: info, isSelected: false, onTap: { onToggle(info.code) })
|
Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title)) {
|
||||||
|
ForEach(matchingUnselected, id: \.code) { info in
|
||||||
|
currencyRow(for: info, isSelected: false, onTap: { onToggle(info.code) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.searchable(
|
||||||
|
text: $searchQuery,
|
||||||
|
prompt: IosLocalizationKt.localizedString(resource: MR.strings.shared.search_currencies_placeholder)
|
||||||
|
)
|
||||||
.navigationTitle(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title))
|
.navigationTitle(IosLocalizationKt.localizedString(resource: MR.strings.shared.currencies_section_title))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ package xyz.tyiu.satsprice
|
|||||||
|
|
||||||
data class CurrencyInfo(val code: String, val displayName: String)
|
data class CurrencyInfo(val code: String, val displayName: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [info]'s code or display name contains [query], case-insensitively. A plain function
|
||||||
|
* (rather than a `CurrencyInfo` extension) so Kotlin/Native exports a predictable, positionally
|
||||||
|
* clear Swift signature, matching [currencyFlagEmoji]'s style.
|
||||||
|
*/
|
||||||
|
fun matchesCurrencySearch(info: CurrencyInfo, query: String): Boolean =
|
||||||
|
query.isBlank() ||
|
||||||
|
info.code.contains(query, ignoreCase = true) ||
|
||||||
|
info.displayName.contains(query, ignoreCase = true)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ISO 4217 codes for the precious metals actively traded today. These aren't tied to any
|
* ISO 4217 codes for the precious metals actively traded today. These aren't tied to any
|
||||||
* country, so a currently-used-currency filter derived from country/locale data (as the
|
* country, so a currently-used-currency filter derived from country/locale data (as the
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
|||||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
|
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -70,6 +71,7 @@ import xyz.tyiu.satsprice.domain.CurrencyConverter
|
|||||||
import xyz.tyiu.satsprice.domain.formatAmount
|
import xyz.tyiu.satsprice.domain.formatAmount
|
||||||
import xyz.tyiu.satsprice.domain.groupDigits
|
import xyz.tyiu.satsprice.domain.groupDigits
|
||||||
import xyz.tyiu.satsprice.domain.localizedDecimalSeparator
|
import xyz.tyiu.satsprice.domain.localizedDecimalSeparator
|
||||||
|
import xyz.tyiu.satsprice.matchesCurrencySearch
|
||||||
import xyz.tyiu.satsprice.shared.MR
|
import xyz.tyiu.satsprice.shared.MR
|
||||||
|
|
||||||
private val SectionColors
|
private val SectionColors
|
||||||
@@ -497,6 +499,28 @@ private fun CurrencyPickerScreen(
|
|||||||
TextButton(onClick = onDone) { Text(stringResource(MR.strings.done)) }
|
TextButton(onClick = onDone) { Text(stringResource(MR.strings.done)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
placeholder = { Text(stringResource(MR.strings.search_currencies_placeholder)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = if (searchQuery.isNotEmpty()) {
|
||||||
|
{
|
||||||
|
IconButton(onClick = { searchQuery = "" }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(MR.strings.clear_search_content_description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = screenHorizontalPadding),
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -504,22 +528,26 @@ private fun CurrencyPickerScreen(
|
|||||||
.padding(horizontal = screenHorizontalPadding, vertical = 8.dp),
|
.padding(horizontal = screenHorizontalPadding, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
val selectedOthers = state.selectedOtherCurrencies()
|
val currentCurrency = state.currentCurrency()
|
||||||
|
val selectedOthers = state.selectedOtherCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
|
||||||
|
val unselected = state.unselectedCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
|
||||||
|
|
||||||
Column {
|
if (matchesCurrencySearch(currentCurrency, searchQuery)) {
|
||||||
Text(
|
Column {
|
||||||
stringResource(MR.strings.current_currency_section_title).uppercase(),
|
Text(
|
||||||
style = SectionHeaderStyle,
|
stringResource(MR.strings.current_currency_section_title).uppercase(),
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
style = SectionHeaderStyle,
|
||||||
)
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
CurrencyRow(
|
)
|
||||||
info = state.currentCurrency(),
|
CurrencyRow(
|
||||||
isSelected = true,
|
info = currentCurrency,
|
||||||
isPriced = state.isPriced(state.currentCurrency().code),
|
isSelected = true,
|
||||||
sourceName = state.sourceName,
|
isPriced = state.isPriced(currentCurrency.code),
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
sourceName = state.sourceName,
|
||||||
onClick = null,
|
localeCurrencyCode = state.localeCurrencyCode,
|
||||||
)
|
onClick = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedOthers.isNotEmpty()) {
|
if (selectedOthers.isNotEmpty()) {
|
||||||
@@ -544,22 +572,24 @@ private fun CurrencyPickerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
if (unselected.isNotEmpty()) {
|
||||||
Text(
|
Column {
|
||||||
stringResource(MR.strings.currencies_section_title).uppercase(),
|
Text(
|
||||||
style = SectionHeaderStyle,
|
stringResource(MR.strings.currencies_section_title).uppercase(),
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
style = SectionHeaderStyle,
|
||||||
)
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
state.unselectedCurrencies().forEach { info ->
|
)
|
||||||
key(info.code) {
|
unselected.forEach { info ->
|
||||||
CurrencyRow(
|
key(info.code) {
|
||||||
info = info,
|
CurrencyRow(
|
||||||
isSelected = false,
|
info = info,
|
||||||
isPriced = state.isPriced(info.code),
|
isSelected = false,
|
||||||
sourceName = state.sourceName,
|
isPriced = state.isPriced(info.code),
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
sourceName = state.sourceName,
|
||||||
onClick = { onToggle(info.code) },
|
localeCurrencyCode = state.localeCurrencyCode,
|
||||||
)
|
onClick = { onToggle(info.code) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="bitcoin_section_title">Bitcoin</string>
|
<string name="bitcoin_section_title">Bitcoin</string>
|
||||||
<string name="btc_label">BTC</string>
|
<string name="btc_label">BTC</string>
|
||||||
<string name="btc_to_currency">1 BTC to %1$s</string>
|
<string name="btc_to_currency">1 BTC to %1$s</string>
|
||||||
|
<string name="clear_search_content_description">Clear search</string>
|
||||||
<string name="currencies_section_title">Currencies</string>
|
<string name="currencies_section_title">Currencies</string>
|
||||||
<string name="currencies_selected_count">%1$d selected</string>
|
<string name="currencies_selected_count">%1$d selected</string>
|
||||||
<string name="current_currency_section_title">Current Currency</string>
|
<string name="current_currency_section_title">Current Currency</string>
|
||||||
@@ -22,5 +23,6 @@
|
|||||||
<string name="remove_currency_content_description">Remove</string>
|
<string name="remove_currency_content_description">Remove</string>
|
||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
<string name="sats_label">Sats</string>
|
<string name="sats_label">Sats</string>
|
||||||
|
<string name="search_currencies_placeholder">Search currencies</string>
|
||||||
<string name="selected_currencies_section_title">Selected Currencies</string>
|
<string name="selected_currencies_section_title">Selected Currencies</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package xyz.tyiu.satsprice
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class SystemCurrenciesTest {
|
class SystemCurrenciesTest {
|
||||||
@@ -22,4 +23,17 @@ class SystemCurrenciesTest {
|
|||||||
assertEquals(0, currencyDecimalDigits("JPY"))
|
assertEquals(0, currencyDecimalDigits("JPY"))
|
||||||
assertEquals(3, currencyDecimalDigits("BHD"))
|
assertEquals(3, currencyDecimalDigits("BHD"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun matchesCurrencySearch_matchesByCodeOrDisplayNameCaseInsensitively() {
|
||||||
|
val usd = CurrencyInfo("USD", "US Dollar")
|
||||||
|
|
||||||
|
assertTrue(matchesCurrencySearch(usd, ""))
|
||||||
|
assertTrue(matchesCurrencySearch(usd, "usd"))
|
||||||
|
assertTrue(matchesCurrencySearch(usd, "USD"))
|
||||||
|
assertTrue(matchesCurrencySearch(usd, "dollar"))
|
||||||
|
assertTrue(matchesCurrencySearch(usd, "US Doll"))
|
||||||
|
assertFalse(matchesCurrencySearch(usd, "EUR"))
|
||||||
|
assertFalse(matchesCurrencySearch(usd, "Euro"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user