From 25a69d8f6931fb3318bb1099324233270f4c534c Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Thu, 10 Sep 2026 08:22:07 +0300 Subject: [PATCH] Add search to the currency picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy --- iosApp/iosApp/CurrencyPickerSheet.swift | 29 ++++-- .../xyz/tyiu/satsprice/SystemCurrencies.kt | 10 ++ .../xyz/tyiu/satsprice/ui/PriceScreen.kt | 92 ++++++++++++------- .../moko-resources/base/strings.xml | 2 + .../tyiu/satsprice/SystemCurrenciesTest.kt | 14 +++ 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index ddc4d2e..bfc8780 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -11,28 +11,43 @@ struct CurrencyPickerSheet: View { let onToggle: (String) -> Void @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 { NavigationStack { List { - Section(IosLocalizationKt.localizedString(resource: MR.strings.shared.current_currency_section_title)) { - currencyRow(for: currentCurrency, isSelected: true, onTap: nil) + if matches(currentCurrency) { + 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)) { - ForEach(selectedOtherCurrencies, id: \.code) { info in + ForEach(matchingSelectedOthers, 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) }) + let matchingUnselected = unselectedCurrencies.filter(matches) + if !matchingUnselected.isEmpty { + 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)) #if os(iOS) .navigationBarTitleDisplayMode(.inline) diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt index 9fe195a..95c3459 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/SystemCurrencies.kt @@ -2,6 +2,16 @@ package xyz.tyiu.satsprice 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 * country, so a currently-used-currency filter derived from country/locale data (as the diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index bec13c8..dc411e1 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults 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.groupDigits import xyz.tyiu.satsprice.domain.localizedDecimalSeparator +import xyz.tyiu.satsprice.matchesCurrencySearch import xyz.tyiu.satsprice.shared.MR private val SectionColors @@ -497,6 +499,28 @@ private fun CurrencyPickerScreen( 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( modifier = Modifier .fillMaxSize() @@ -504,22 +528,26 @@ private fun CurrencyPickerScreen( .padding(horizontal = screenHorizontalPadding, vertical = 8.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 { - Text( - stringResource(MR.strings.current_currency_section_title).uppercase(), - style = SectionHeaderStyle, - modifier = Modifier.padding(bottom = 4.dp), - ) - CurrencyRow( - info = state.currentCurrency(), - isSelected = true, - isPriced = state.isPriced(state.currentCurrency().code), - sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, - onClick = null, - ) + if (matchesCurrencySearch(currentCurrency, searchQuery)) { + Column { + Text( + stringResource(MR.strings.current_currency_section_title).uppercase(), + style = SectionHeaderStyle, + modifier = Modifier.padding(bottom = 4.dp), + ) + CurrencyRow( + info = currentCurrency, + isSelected = true, + isPriced = state.isPriced(currentCurrency.code), + sourceName = state.sourceName, + localeCurrencyCode = state.localeCurrencyCode, + onClick = null, + ) + } } if (selectedOthers.isNotEmpty()) { @@ -544,22 +572,24 @@ private fun CurrencyPickerScreen( } } - Column { - Text( - stringResource(MR.strings.currencies_section_title).uppercase(), - style = SectionHeaderStyle, - modifier = Modifier.padding(bottom = 4.dp), - ) - state.unselectedCurrencies().forEach { info -> - key(info.code) { - CurrencyRow( - info = info, - isSelected = false, - isPriced = state.isPriced(info.code), - sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, - onClick = { onToggle(info.code) }, - ) + if (unselected.isNotEmpty()) { + Column { + Text( + stringResource(MR.strings.currencies_section_title).uppercase(), + style = SectionHeaderStyle, + modifier = Modifier.padding(bottom = 4.dp), + ) + unselected.forEach { info -> + key(info.code) { + CurrencyRow( + info = info, + isSelected = false, + isPriced = state.isPriced(info.code), + sourceName = state.sourceName, + localeCurrencyCode = state.localeCurrencyCode, + onClick = { onToggle(info.code) }, + ) + } } } } diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 85d6ea3..85998ae 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -3,6 +3,7 @@ Bitcoin BTC 1 BTC to %1$s + Clear search Currencies %1$d selected Current Currency @@ -22,5 +23,6 @@ Remove Retry Sats + Search currencies Selected Currencies diff --git a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt index de273fe..ecacf10 100644 --- a/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt +++ b/shared/src/commonTest/kotlin/xyz/tyiu/satsprice/SystemCurrenciesTest.kt @@ -2,6 +2,7 @@ package xyz.tyiu.satsprice import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class SystemCurrenciesTest { @@ -22,4 +23,17 @@ class SystemCurrenciesTest { assertEquals(0, currencyDecimalDigits("JPY")) 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")) + } }