Pulled the complete "Historical currency codes" table from
https://en.wikipedia.org/wiki/ISO_4217#Historical_codes and added
every code not already covered — ~85 more entries (Argentine austral,
Brazilian cruzeiro/cruzado through its several redenominations,
Yugoslav dinar variants, Bulgarian lev's earlier revisions, settlement/
funds-only codes like XEU/XFO/XFU/XRE, and more).
Cross-checked the full 127-entry list against the JVM's live-derived
"currently active" set to make sure nothing genuinely active got
added by mistake — clean, except for one deliberate exception:
Wikipedia lists "BGN" (a fourth, euro-replaced Bulgarian lev) as
historical, but the JVM still resolves BGN as Bulgaria's current
currency, so it's left out to stay consistent with what this app's
own JVM/Android path actually does — documented inline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
CLDR appends a "(1927–2002)" or "(2009–2024)" style validity-period
suffix to a historical/superseded currency's display name — a
broader, data-driven signal than the hand-maintained
WITHDRAWN_CURRENCY_CODES list, catching ones not added there yet
(confirmed AFA, ALK, AOK, AON, AOR, ROL, TRL, RUR, and others all get
this treatment). Not a full replacement for the list though: some
withdrawn currencies (pre-euro ones especially, e.g. "German Mark"
for DEM) carry no such hint in every ICU dataset.
Verified with Node against the real Intl.DisplayNames output: matches
every year-annotated name actually observed, and produces zero false
positives across all 162 currently-formattable codes (Node's
Intl.supportedValuesOf('currency') set) — the only two matches were
SLL and ZWL, which are already known-withdrawn.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Reverts the allowlist approach back to WITHDRAWN_CURRENCY_CODES per
request, and adds the codes actually reported: AFA, ALK, AOK, AON,
AOR, plus the full set of pre-euro legacy currencies (ATS, BEF, DEM,
FRF, ITL, ESP, GRD, PTE, NLG, IEP, and the rest), which are exactly
the kind of long-withdrawn code an ICU currency dataset would still
carry.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
The previous WITHDRAWN_CURRENCY_CODES exclusion list only covered the
5 retired codes I'd found by diffing against the JVM — but which
codes Intl.supportedValuesOf('currency') returns is ICU-version-
dependent, and apparently some browsers' ICU data includes currencies
retired decades ago (AFA, ALK, AOK, AON, AOR, and evidently more)
alongside current ones. An exclusion list can't keep up with an
unbounded, browser-dependent set of historical codes.
Replaced with ACTIVE_CURRENCY_CODES, an allowlist taken directly from
the JVM's own live-derived "currently used" set (verified against a
real JVM run, 155 codes) plus PRECIOUS_METAL_CURRENCY_CODES. This
naturally excludes every retired code regardless of what a given
browser's Intl data happens to expose, rather than needing to know
about each one in advance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Intl.supportedValuesOf('currency') includes several codes for
currencies that have actually been withdrawn/superseded — unlike
JVM/Android/Apple, which derive "currently used" from each ISO
country's live-assigned currency (java.util.Currency/NSLocale), the
web platform has no equivalent API, so this app was showing them
unfiltered.
Confirmed exactly which codes by diffing the web list against the
JVM's own live-derived set: ANG, CUC, HRK, SLL, and ZWL are genuinely
withdrawn/superseded national currencies, now excluded via a manually
maintained list (the web platform's only option here). XDR and XSU
were also missing from the JVM's set, but only because they aren't
tied to any ISO country — not because they're withdrawn — so those
stay, same reasoning this codebase already applies to precious
metals.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
jsCurrencyDisplayName() was hardcoding 'en' for Intl.DisplayNames,
so every currency name shown on web (USD, JPY, ...) stayed English
regardless of the user's locale — same class of bug as the "EU"
string, just for every currency rather than one region. Switched to
undefined, matching regionDisplayName()'s fix; verified with Node
that this actually produces locale-appropriate names (e.g. "US
Dollar" / "dollar des États-Unis" / "米ドル").
Left jsCurrencyFractionDigits()'s 'en' alone and documented why:
verified with Node that a currency's decimal-digit count doesn't
vary by locale, so locale there is just a required constructor
argument, not something the result depends on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Verified directly (java.util.Locale, NSLocale, and Intl.DisplayNames
all tested outside the app) that every platform's locale data resolves
"EU" correctly on its own — "European Union" in en, "Union européenne"
in fr, "Europäische Union" in de, etc. — via CLDR, which defines "EU"
as a grouping in its own right despite it not being a real ISO 3166-1
country code. The hardcoded English fallback was based on an
assumption that was never actually verified.
Removes the special case from the shared code entirely. That required
two platform-side fixes to stop blocking "EU" from ever reaching the
platform lookup:
- JVM/Android: dropped the Locale.getISOCountries() membership guard
(which excluded "EU") in favor of detecting an unresolvable code by
getDisplayCountry() echoing it back unchanged.
- Web: jsRegionDisplayName() was hardcoding locale 'en' for
Intl.DisplayNames — changed to undefined (the browser's own locale),
matching what the other platforms already did. Without this fix,
"EU" would resolve, but only ever in English on web.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Previously the cache only filled lazily on first search use, so the
very first character typed into currency search still paid for every
region code at once. Kicking off warmRegionDisplayNameCache() in its
own coroutine from PriceViewModel's init moves that cost to app
startup, well before the user can reach the search field, without
delaying the ViewModel's own startup sequence (separate launch{}, not
folded into it).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
matchesCurrencySearch() re-derives a country name for every currency
on every keystroke; region codes don't change during a session, so
this was redoing the same locale lookups repeatedly — worst on web,
where each lookup constructs a fresh Intl.DisplayNames instance.
Caches by region code instead, including null results (regions the
platform doesn't recognize), so each code's lookup happens once ever.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
It was using the decimal sanitizer/keyboard, same as the rate field
next to it — but Sats are indivisible, so this one should behave like
the existing Sats amount field: integers only, numeric keypad.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Exceptions from the HTTP client or JSON parsing can carry verbose,
technical text (full response bodies, stack traces) — not meaningful
to a user seeing it as a red error banner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Replaces the raw "2026-09-10 10:03:54" with each platform's own
locale-formatted, no-seconds rendering (e.g. "Sep 10, 2026, 10:03 AM"
in en-US) — mirroring how amounts are already locale-formatted
elsewhere (NumberFormat.kt). Android uses the pre-java.time
Calendar/DateFormat APIs since minSdk 24 predates java.time (API 26)
and the project has no core library desugaring configured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Always render the field now (label included) with just its value
empty when unknown, rather than hiding the whole field.
In Manual source mode it's now a genuine input: typing into it
recomputes the "BTC to currency" rate the same way typing into that
field recomputes this one. Both edit the same underlying
ManualExchangeRateSource.rate via the same self-inverse division
(CurrencyConverter.satsPerCurrencyUnit of a rate gives Sats, and of a
Sats amount gives a rate back), tracked as a new manualSatsPerCurrencyInput
field alongside the existing manualRateInput.
Wired through PriceViewModel, the iOS bridge/ConverterViewModel, and
both UIs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Clearing the Manual rate cleared pricedCurrencyCodes but left
rateDisplays stale, so "currency to Sats" kept showing a value
computed from the old rate even after "BTC to currency" went empty.
refresh()'s early-return path now clears rateDisplays too.
oneCurrencyToSats() also now derives from defaultCurrencyRate()
directly instead of re-reading rateDisplays independently, so the
two rates structurally can't disagree about whether a rate is known,
and formats to a whole number of Sats (0 decimals) instead of 2 —
fractional Sats aren't a meaningful unit to show.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
"1 BTC to USD" -> "BTC to USD", "1 <currency> to Sats" -> "<currency>
to Sats" (the "1 " was redundant with the field itself now carrying
the label).
Compose: the BTC-to-currency rate and currency-to-sats value are now
OutlinedTextFields (read-only, matching the enabled/disabled styling
of the Currencies section's fields) instead of plain Text, with the
label folded into the field itself rather than a separate heading.
The manual-source rate field's label changes from generic "Rate" to
the same "BTC to <currency>".
iOS/macOS: dropped the Section's "1 BTC to <currency>" header;
the label now sits directly on the rate row (label left, value
right), matching the existing Sats/BTC/currency field rows and the
already-matching "<currency> to Sats" row below it.
rate_label is now unused and removed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Shows the inverse of the existing "1 BTC to <currency>" rate — how
many Sats one unit of the default currency is worth — right below it.
Useful since many currencies are worth less than, or a low multiple
of, a single Sat.
CurrencyConverter.satsPerCurrencyUnit(rate) does the actual division;
ConverterUiState.oneCurrencyToSats() derives the display string from
the already-fetched rate the same way defaultCurrencyRate() does, so
no new ViewModel state was needed. Wired into both Compose and the
iOS bridge/SwiftUI.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Compose for Web (js/wasmJs) draws everything through Skia onto a
<canvas> rather than through the browser's own text stack, so it has
no color-emoji font to fall back to — a flag's regional-indicator
codepoints render as empty tofu boxes instead of a flag. Reproduced
via a headless-Chrome screenshot of the running dev server.
Adds a per-platform supportsFlagEmoji() (true everywhere except
js/wasmJs) that currencyFlagEmoji() now checks first, so currency
rows on web just show the plain code/name with no flag rather than a
broken glyph.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Searching "Japan" now finds JPY, "Canada" finds CAD, etc. — matching
just the currency's own display name wasn't enough, since e.g.
"Canadian Dollar" doesn't contain "Canada" as a substring.
Adds a per-platform regionDisplayName(regionCode) (java.util.Locale
on JVM/Android, NSLocale on Apple, Intl.DisplayNames on web) and
reuses CurrencyFlag.kt's currency-to-country-code derivation (now
extracted as issuingCountryCodes) to look up the country/countries
behind a currency code. "EU" is hardcoded to "European Union" since
it's not a real ISO 3166-1 code platform locale data can resolve.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
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
With a single currency shown there's nothing to reorder or remove, so
the per-row "..." menu (Compose) and the Edit button plus swipe-to-
delete/drag-to-reorder (iOS) no longer appear. This also covers
Manual, which always shows just the default currency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Manual's default-currency field being unpriced just means no rate has
been typed in yet, not an actual pricing error, so the label (and the
red error styling that came with it) no longer applies when it's the
active source.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
"via Manual" had nothing useful left to say once the "updated" part
was dropped, so statusLine() now returns empty for Manual and both
UIs skip rendering it entirely instead of showing a blank line/footer.
onManualRateChanged also now clears manualSource.rate whenever the
field doesn't parse (emptied, or mid-edit on an incomplete number)
rather than only ever updating it on a successful parse. refresh()'s
early-return for a null Manual rate now also clears the cached rates
and pricedCurrencyCodes, so a cleared/incomplete manual rate stops
fiat amounts from continuing to show a stale computed value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Refreshing has no meaning for a manually-typed rate, so the button
(and its loading spinner) is now hidden while Manual is selected.
Switching to Manual for the first time now seeds its rate field from
whichever source was active before, falling back to any other
source's last cached rate for the default currency. When neither is
available, the rate and amount fields are left empty instead of
showing stale values.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
The manual rate source only ever prices the default currency, so the
currency selector, additional currency rows, and the "updated" part
of the status line no longer make sense for it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Derives a country flag from the ISO 4217 code's country prefix, with
an explicit map of country codes for currencies shared by multiple
countries (XAF, XCD, XCG, XOF, XPF) showing every issuing country's
flag, and the EU flag as a special case for EUR.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Move up/down/Remove no longer repeat the currency code in the menu item
text — the row's own field already shows it right next to the button.
Also adds Move to top / Move to bottom, enabled under the same conditions
as the existing up/down actions.
Switches from material-icons-core to material-icons-extended: the core set
is a bare ~50-icon subset with nothing suited to "move to top/bottom" (no
double-chevron or vertical-align icons), so KeyboardDoubleArrowUp/Down
needed the fuller set.
Compose-only: the native SwiftUI screen has no three-dot menu, it uses
native list reordering instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Adds groupDigits(), a display-only formatter that inserts the current
locale's digit-grouping separators into an amount's integer part (e.g.
"1,000,000.5" in en-US vs "1.000.000,5" in de-DE, where the roles of ","
and "." are swapped, or hi-IN's irregular "12,34,567"). Delegates to each
platform's native locale APIs (java.text.NumberFormat/DecimalFormatSymbols,
NSNumberFormatter/NSLocale, Intl.NumberFormat with BigInt for arbitrary
precision) rather than reimplementing grouping rules, and never touches the
canonical '.'-decimal strings the rest of the app parses and stores.
Applied via a Compose VisualTransformation (BTC/Sats/fiat/manual-rate
fields, plus the read-only rate display) and, on the native SwiftUI screen,
by re-deriving the field's displayed text on every change. Also made input
parsing locale-aware: sanitizeDecimalInput now recognizes the locale's own
decimal separator (e.g. what a locale-aware decimal keypad sends), not just
"." — with a stricter rule on the SwiftUI side specifically, since its
single text buffer re-feeds the grouped display back through sanitize, so
a "." can be grouping noise there rather than a decimal point.
The exceeds-max-supply warning's "21,000,000 BTC" is no longer baked into
the localized string with hardcoded grouping; it's substituted in at
runtime via groupDigits so the link-detection text always matches exactly,
in every locale.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Currency.getAvailableCurrencies() (JVM/Android) and NSLocale.ISOCurrencyCodes
(iOS/macOS) return every ISO 4217 code the platform has ever known about —
withdrawn currencies like the Deutsche Mark and French Franc, and test/
placeholder codes (XTS, XXX), included. Measured on the JVM: 232 total
codes, only 156 still assigned to a country.
Filters the list down to currencies actually assigned to an ISO country
today, which drops the withdrawn ones without needing a maintained
exclusion list. Precious metals (XAU, XAG, XPD, XPT) are added back in
since they're still actively traded despite not being tied to any country.
Web is left unfiltered — there's no reliable country-to-currency API there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Previously a currency the active price source had no rate for was hidden
entirely, so switching sources (e.g. to CoinGecko, which quotes far fewer
fiat currencies than Coinbase) could silently drop it from the picker and
even from the user's own selection.
Every system currency is now always offered. ConverterUiState tracks which
codes the latest fetch actually priced, and both UIs (Compose and the
native SwiftUI screen, since iOS/macOS don't render Compose UI at all) show
a "Not priced by <source>" indicator for the rest, with the amount field
emptied and disabled rather than showing a stale or meaningless value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Tapping "21,000,000 BTC" in the exceeds-max-supply warning now sets the BTC
field to that value, on both Compose (Android/Desktop/Web) and the native
SwiftUI screen (iOS/macOS) — Compose UI isn't rendered on iOS/macOS at all,
so that side needed its own AttributedString-based link.
The link is styled to match the surrounding warning text: Compose's
LinkAnnotation otherwise falls back to the theme's accent color unless every
interaction state's style is set explicitly, and SwiftUI renders `.link`
runs in the accent color regardless of the Text's own foregroundColor
modifier unless it's set directly on the link's AttributedString range.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
js("new Foo(x).bar()") can misparse so `new` binds to the whole chain
instead of just the constructor call, throwing "X.resolvedOptions is not a
constructor" at runtime on the JS target (Kotlin/Wasm was unaffected, since
its js() compiles through a different path). Binding the constructor result
to a local variable first removes the ambiguity.
This was also silently breaking systemCurrencies() and the currency display
name lookup, and had been cascading into an unrelated CoinGecko test
failure in the same karma bundle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
Adds a SQLDelight-backed AppDatabase (Android, Desktop, iOS/macOS) so the
converter survives restarts: the last-known rates per price source, the
user's selected fiat currencies (with order preserved), and the last-used
price source all reload before the first network fetch completes. Web isn't
a shipped platform and SQLDelight's driver there needs a worker plus a wasm
sqlite binary, so it gets a lighter localStorage-backed implementation of
the same store interfaces instead.
Android needs an app Context for its driver, wired via a new
SatsPriceApplication. iOS/macOS need libsqlite3 linked explicitly (Swift's
autolinking only covers Shared.framework itself, not its C library
dependencies), plus two extension-loading symbols marked optional since
macOS's system sqlite build omits them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp