Commit Graph
32 Commits
Author SHA1 Message Date
tyiuandClaude Sonnet 5 4f190308a3 Warm the region display-name cache at startup
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
2026-09-10 12:39:24 +03:00
tyiuandClaude Sonnet 5 3bdf320f5c Cache region display-name lookups used by currency search
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
2026-09-10 12:35:39 +03:00
tyiuandClaude Sonnet 5 293c822c86 Reject decimal input in the manual "currency to Sats" field
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
2026-09-10 12:24:13 +03:00
tyiuandClaude Sonnet 5 69762133fe Show a fixed, generic message for fetch errors instead of e.message
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
2026-09-10 12:01:15 +03:00
tyiuandClaude Sonnet 5 3ce3bab7c6 Format the "updated" timestamp as a locale-aware date/time
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
2026-09-10 11:57:19 +03:00
tyiuandClaude Sonnet 5 43b66b6665 Keep the "currency to Sats" field visible, make it editable in Manual
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
2026-09-10 11:16:35 +03:00
tyiuandClaude Sonnet 5 bf91abdd12 Keep "currency to Sats" empty when the BTC rate is, and whole-Sats only
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
2026-09-10 10:21:26 +03:00
tyiuandClaude Sonnet 5 161c219d60 Rename rate labels, style them like other amount fields
"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
2026-09-10 10:14:05 +03:00
tyiuandClaude Sonnet 5 947f8bf81a Add a "1 <currency> to Sats" field
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
2026-09-10 10:04:38 +03:00
tyiuandClaude Sonnet 5 e5b31f3750 Hide flag emoji on the web target, where they render as empty boxes
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
2026-09-10 09:49:12 +03:00
tyiuandClaude Sonnet 5 eafaeaa8d3 Match currency search against issuing country names too
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
2026-09-10 08:36:12 +03:00
tyiuandClaude Sonnet 5 25a69d8f69 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
2026-09-10 08:22:07 +03:00
tyiuandClaude Sonnet 5 1c67182187 Hide currency row controls when there's only one currency
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
2026-09-09 23:50:05 +03:00
tyiuandClaude Sonnet 5 94508c291c Hide the "Not priced by Manual" label for Manual
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
2026-09-09 23:45:30 +03:00
tyiuandClaude Sonnet 5 0927a04fe8 Hide the Manual status line, clear fiat amounts when its rate is gone
"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
2026-09-09 23:42:23 +03:00
tyiuandClaude Sonnet 5 583b9bd14e Hide refresh for Manual, seed its rate from another source
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
2026-09-09 09:03:18 +03:00
tyiuandClaude Sonnet 5 f3f018741c Hide currency flags shared by more than 3 countries
XCD, XOF, and XAF each have 6-8 issuing countries, which is too many
flags to cram side by side next to a currency code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
2026-09-09 08:57:19 +03:00
tyiuandClaude Sonnet 5 dda836274c Simplify Manual source UI to just the default currency
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
2026-09-09 08:56:24 +03:00
tyiuandClaude Sonnet 5 f21bdd48f8 Show a flag emoji next to each currency code
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
2026-09-09 08:47:35 +03:00
tyiuandClaude Sonnet 5 1a16f96739 Simplify currency row menu labels, add move to top/bottom
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
2026-09-09 08:09:21 +03:00
tyiuandClaude Sonnet 5 5587428ca7 Add locale-aware digit grouping to displayed amounts
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
2026-09-09 07:37:46 +03:00
tyiuandClaude Sonnet 5 323ca43472 Exclude withdrawn currencies from the picker, keep precious metals
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
2026-09-08 17:20:09 +03:00
tyiuandClaude Sonnet 5 b0f3744851 Always show all currencies, flagging ones the active source doesn't price
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
2026-09-08 17:19:48 +03:00
tyiuandClaude Sonnet 5 84df992a82 Sort localization string keys alphabetically
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
2026-09-08 11:04:52 +03:00
tyiuandClaude Sonnet 5 f35fb05e7c Make the max-supply warning's BTC amount a link on every platform
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
2026-09-08 10:30:10 +03:00
tyiuandClaude Sonnet 5 b77455bfc8 Move currency row overflow menu to the right of the amount field
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
2026-09-08 09:46:38 +03:00
tyiuandClaude Sonnet 5 200319de86 Fix Intl interop crash on Kotlin/JS web target
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
2026-09-08 09:34:40 +03:00
tyiuandClaude Sonnet 5 14298d450e Persist exchange rates, selected currencies, and price source via SQLDelight
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
2026-09-08 09:34:21 +03:00
tyiu 14ec044c44 Fix currencies screen padding and section header styling 2026-09-07 22:39:35 +03:00
tyiu 2925c845bd Add manual currency sorting and improve currency categorization 2026-09-07 22:15:29 +03:00
tyiu deef01a58a Add support for macOS and backport to older OS versions 2026-09-03 20:06:59 +03:00
tyiu abf7cf2e83 Replace Skip implementation with Kotlin Multiplatform implementation 2026-09-02 22:11:32 +03:00