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:
2026-09-10 08:22:07 +03:00
co-authored by Claude Sonnet 5
parent e7af3e6fc8
commit 25a69d8f69
5 changed files with 109 additions and 38 deletions
+22 -7
View File
@@ -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"))
}
} }