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
@@ -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
@@ -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) },
)
}
}
}
}
@@ -3,6 +3,7 @@
<string name="bitcoin_section_title">Bitcoin</string>
<string name="btc_label">BTC</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_selected_count">%1$d selected</string>
<string name="current_currency_section_title">Current Currency</string>
@@ -22,5 +23,6 @@
<string name="remove_currency_content_description">Remove</string>
<string name="retry">Retry</string>
<string name="sats_label">Sats</string>
<string name="search_currencies_placeholder">Search currencies</string>
<string name="selected_currencies_section_title">Selected Currencies</string>
</resources>
@@ -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"))
}
}