Strings.kt is now generated at build time (:webHtmlApp:generateStrings, in webHtmlApp/build.gradle.kts) by parsing shared's strings.xml directly, rather than a hand-maintained duplicate that could silently drift from it. Names are mechanical camelCase of the XML key, so a few call sites got more verbose (e.g. moveUp -> moveCurrencyUpContentDescription) in exchange for zero manual sync. Only covers the base English locale. Also collapses currency_option_label_local into currency_option_label: the two had identical English text and existed only as a hook for a future translation to phrase the user's own currency differently, which no locale currently does. Removes the info.code == localeCurrencyCode branching (and the now-pointless localeCurrencyCode parameter/field) from all three UI implementations - PriceScreen.kt, CurrencyPickerSheet.swift, and HtmlCurrencyPicker.kt - plus IosConverterState.localeCurrencyCode, which existed solely to feed that branch on the Swift side. Fixes a real regression surfaced while verifying the above by actually building the Xcode project (not done since currencyFlagEmoji() gained its supportsFlagEmoji parameter): Kotlin/Native doesn't generate default-parameter overloads for Swift/ObjC-exported functions, so both Swift call sites needed the argument passed explicitly, via the correct exported facade name for an expect/actual pair - the actual file's (SystemCurrencies_appleKt), not the common one (SystemCurrenciesKt). Also drops "(DOM)" from webHtmlApp's page title. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
AGENTS.md
Guidance for AI coding agents working in this repository. See README.md for
the user-facing project description and build/run/test commands — this file
covers the things that aren't obvious from reading the code, or that took
real trial-and-error to get right.
What this is
SatsPrice is a Kotlin Multiplatform app, currently released on iOS, macOS,
and Android (see README's Supported Platforms/Download and Install).
Desktop/JVM is a real future release target — desktopApp already has full
native packaging configured (DMG/MSI/DEB, see its build.gradle.kts) — but
has no release/CI pipeline set up yet, so treat it as not-yet-shipped rather
than dev-only. Web is a step ahead of Desktop: .github/workflows/pages.yml
builds :webApp:wasmJsBrowserDistribution and publishes it alongside the
marketing site to satsprice.app/app/ on every push to main — see
"Website and web app deployment" below. The app converts between BTC, Sats,
and fiat currencies using live exchange rates (Coinbase, CoinGecko, or a
manually typed-in rate).
This is a from-scratch rewrite of an earlier Skip-based (Swift-transpiled-to-
Kotlin) implementation — main is now this Kotlin Multiplatform project.
Module layout
shared/— view models, data sources, domain logic. No Compose UI toolkit dependency (deliberately - seesharedUi/below). Almost all business logic happens here.sharedUi/— the Skia-based Compose Multiplatform UI (App(),ui/PriceScreen.kt) thatandroidApp,desktopApp, andwebApprender. Split out fromsharedso a js/wasmJs consumer that doesn't want Compose UI's several-MB Skia web runtime (seewebHtmlApp/) can depend onsharedalone without pulling it in transitively - the Compose Multiplatform Gradle plugin bundles that runtime into any js/wasmJs target whose resolved dependencies containorg.jetbrains.compose.ui:uianywhere.androidApp/,desktopApp/,webApp/— thin launcher shells aroundsharedUi's Compose UI. Rarely need changes.webHtmlApp/— an alternative web build rendering to real DOM via Compose HTML instead of Compose Multiplatform UI's canvas/Skia. Depends onshareddirectly, notsharedUi: Compose HTML and Compose Multiplatform UI are different, incompatible composable sets, so its screens (HtmlApp.kt,HtmlCurrencyPicker.kt) are a separate hand-written port ofsharedUi/ui/PriceScreen.ktrather than a shared one. Not what's deployed to production (webApp's wasmJs build is); exists for comparing the two approaches and viawebsite/serve-local.sh.iosApp/— a native SwiftUI app (shared across iOS and macOS via#if os(macOS)), not Compose. It talks tosharedthrough a hand-written bridge (see "Core architecture" below), not by rendering Compose directly.
Inside shared/src, source sets follow standard KMP conventions:
commonMain (all platforms), androidMain, jvmMain (Desktop),
appleMain (iOS + macOS, shared), webMain (JS + Wasm, shared),
jsMain/wasmJsMain (JS/Wasm-specific only), plus per-target *Main
folders for things that need exact target granularity.
expect/actual pairs are named Foo.kt (common) / Foo.jvm.kt /
Foo.android.kt / Foo.apple.kt / Foo.web.kt — follow that convention for
new ones. Ignore Platform.kt/getPlatform() in every source set: it's
unused KMP-wizard scaffolding, not load-bearing.
Core architecture
ui/PriceViewModel.kt— the single state holder (ConverterUiState), shared by every platform. All business logic (rate fetching, currency selection, amount conversion, manual-rate math) lives here.ui/ConverterUiStateDisplay.kt— pure derived-display helpers (ConverterUiState.xyz()extension functions) shared between Compose and the iOS bridge. Read the localization note below before adding anything here that produces user-facing text.sharedUi/src/commonMain/.../ui/PriceScreen.kt— the actual Compose UI. Used as-is by Android, Desktop, andwebApp(same composable, three renderers).webHtmlApphas its own separate Compose HTML port of this screen instead (see "Module layout" above).- iOS/macOS doesn't use Compose. The chain is:
PriceViewModel(Kotlin) →IosPriceViewModel/IosConverterState(shared/src/appleMain/.../IosPriceViewModel.kt, a flattened Map-free snapshot of the state, since Kotlin/Native's Swift export handlesList<DataClass>far better thanMap<K,V>) →ConverterViewModel.swift(a thinObservableObjectwrapper) →ContentView.swift/CurrencyPickerSheet.swift(the actual SwiftUI views). When you change something inPriceViewModel/ConverterUiState, check whetherIosPriceViewModel.ktneeds the same field/method added, and whetherConverterViewModel.swiftneeds a forwarding method.
Localization — read this before touching display strings
Strings live in shared/src/commonMain/moko-resources/base/strings.xml
(moko-resources; only a base/ — no other locales exist yet). There is
no synchronous, cross-platform way to resolve a moko-resources string
from plain shared Kotlin code:
- Compose can resolve one via
stringResource(), but only inside a@Composablefunction. - Apple (
ui/IosLocalization.kt'slocalizedString/localizedFormattedString) can resolve one synchronously outside Compose — but that's Apple-only. - Android has no platform
Contextavailable in the sharedViewModel. - Web resource loading is
fetch()-based, i.e. asynchronous — fundamentally incompatible with a plain synchronous function call.
So shared, non-Composable code (ConverterUiStateDisplay.kt,
PriceViewModel.kt) must never build a user-facing sentence out of English
literals. Return data instead (a formatted number, a formatted
date/time, a boolean, an enum) and let each UI layer (PriceScreen.kt via
stringResource, ContentView.swift via IosLocalizationKt) assemble the
localized sentence around it. ConverterUiState.lastUpdatedDateTime() is
the pattern to copy: it returns a locale-formatted String? (using
domain/DateFormat.kt, which is fine to call from shared code — it's
locale-aware formatting via platform APIs, not translated text), and each
UI layer wraps it with its own localized "Updated %1$s" string.
webHtmlApp is the one UI layer that deliberately breaks this pattern: it
can't call stringResource() because moko-resources' Compose integration
(moko-resourcesCompose) pulls in compose.foundation/compose.ui
transitively - exactly the dependency webHtmlApp exists to avoid (see
"Module layout" above). Its Strings.kt is instead generated at build
time (:webHtmlApp:generateStrings, in webHtmlApp/build.gradle.kts) by
parsing shared/.../moko-resources/base/strings.xml directly, so that file
stays the single source of truth instead of a manually maintained,
silently driftable duplicate - edit the XML, not the generated file. Only
covers the base (English) locale, since it skips moko's per-locale
resolution entirely. Not a template to follow elsewhere; a one-off
tradeoff specific to that module.
Locale-aware formatting
domain/NumberFormat.kt (digit grouping, decimal separator) and
domain/DateFormat.kt (date/time) are expect/actual wrappers around
each platform's native locale APIs (java.text/java.time on JVM/Android,
NSLocale/NSDateFormatter on Apple, Intl.* on web). These are safe to
call from anywhere in shared code — they format using the platform's
locale, they don't translate UI text.
Kotlin/Native ↔ Swift interop conventions
Functions called from Swift are written as plain top-level functions,
not CurrencyInfo/ConverterUiState extension functions — e.g.
matchesCurrencySearch(info, query) rather than info.matchesCurrencySearch(query),
currencyFlagEmoji(code). Kotlin/Native's Objective-C/Swift export handles
extension-function receivers unpredictably (the exported Swift signature's
argument label for the receiver isn't obvious ahead of time); plain
functions export as FileNameKt.functionName(label: ...) with predictable,
positional argument labels. Follow this for anything new that Swift needs
to call.
Currency data is inherently messy — expect platform divergence
SystemCurrencies.kt/CurrencyFlag.kt:issuingCountryCodes()derives a currency's issuing country/countries from its ISO 4217 code (plus a hardcoded map for the handful of currencies shared by multiple countries with no single issuer).currencyFlagEmoji()returnsnullon web (supportsFlagEmoji()isfalsethere) because Compose for Web renders through Skia onto a<canvas>, not the browser's text stack — no system/browser color-emoji font to fall back to, so a flag's regional-indicator codepoints would draw as empty boxes.SystemCurrencies.web.ktneeds its own withdrawn-currency filtering (WITHDRAWN_CURRENCY_CODES+ a year-annotation regex, e.g. CLDR naming a retired currency "Afghan Afghani (1927–2002)"):Intl.supportedValuesOf ('currency')'s result is ICU-version-dependent per browser and includes currencies retired decades ago. JVM/Android/Apple instead derive "currently used" live from each platform's own per-country currency lookup (java.util.Currency/NSLocale), so they don't need a maintained list. If you need ground truth for "is this currency still active," a quick JVM snippet iteratingLocale.getISOCountries()+Currency.getInstance(Locale)is the most reliable source available in this environment — more reliable than trusting any single browser'sIntldata or assuming a Wikipedia snapshot is current.- Region/country display names (used by currency search) are cached
(
regionDisplayNameCacheinSystemCurrencies.kt) and warmed atPriceViewModelstartup (warmRegionDisplayNameCache), since some platforms' lookups (especially constructingIntl.DisplayNameson web) are too expensive to redo on every keystroke.
Persistence
SQLDelight (data/db/SqlDelightStores.kt + platform-specific driver
actuals) backs ExchangeRateStore/SelectedCurrenciesStore/
SelectedSourceStore, each exposed via an expect fun createXStore()
factory. Web has no real SQLDelight driver — SQLDelight's web driver needs a
worker plus a wasm sqlite binary — so it gets a localStorage-backed
fallback instead (data/db/LocalStorageStores.kt), which does survive page
reloads, just scoped to the browser profile/origin (cleared by clearing
site data, not shared across browsers/devices).
Website and web app deployment
website/ is a static marketing site (plain HTML/CSS/JS, no build step of
its own) deployed to satsprice.app via GitHub Pages
(.github/workflows/pages.yml). That workflow does more than upload
website/ as-is: it also runs ./gradlew :webApp:wasmJsBrowserDistribution
and copies the result into website/app/ before publishing, so the live
Compose web app is reachable at satsprice.app/app/ and linked from the
site (nav, hero, download section) via /app/.
website/app/is git-ignored — it's build output, generated fresh by CI (and locally by the script below), never committed.- To try the site + web app together locally (
/app/links working):./website/serve-local.sh [port]. It buildswebHtmlApp's Compose HTML (DOM) distribution, copies it intowebsite/app/, and serveswebsite/withpython3 -m http.server— note this no longer matches what the Pages workflow actually deploys (still the SkiawebAppwasmJs build), so it's for trying the DOM build, not reproducing production. - If you change the Pages workflow, remember
paths:in the trigger includeswebApp/**/shared/**/sharedUi/**/Gradle files, not justwebsite/**— a shared-code change that affects the web app should also redeploy the site.
Testing and verification
shared/src/commonTestholds the real unit test suite (pure Kotlin, runs identically across targets)../gradlew :shared:jvmTestis the fast one to run during iteration; it's usually sufficient sincecommonTestcode doesn't touch platform actuals directly.- After any shared-code change, also compile the other targets to catch
platform-specific breakage even without running their tests:
:shared:compileAndroidMain,:shared:compileKotlinWasmJs,:shared:compileKotlinJs,:shared:compileKotlinMacosArm64. - For iOS/macOS-touching changes, build both
xcodebuild -scheme iosApp -destination 'platform=macOS'and-destination 'generic/platform=iOS Simulator'— they catch different Swift/bridge mismatches. - Compose for Web renders to a
<canvas>, not real DOM — there's no text to inspect or click via DOM selectors. To actually see a Compose UI change, run./gradlew :webApp:wasmJsBrowserDevelopmentRun --continuousand screenshot it with headless Chrome. Plain--headless=newalone fails to get a WebGL context (Skia needs one); use:--headless=new --disable-gpu-sandbox --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader --ignore-gpu-blocklist. This is also the most practical way to verify a Compose UI change at all, since there's no headless Android/Desktop runner set up here. webHtmlAppis the exception to the above: it renders real DOM, so plain--headless=new --dump-dom(no WebGL/GPU flags needed) shows actual inspectable HTML. Build/serve it with./gradlew :webHtmlApp:jsBrowserDistributionand a static file server.- Interactive verification of the native macOS build via AppleScript/System Events GUI scripting works but is genuinely flaky — stale processes can linger across launches, the accessibility tree doesn't always reflect sheets/dialogs, and clicks sometimes don't land. It's worth one clean attempt for a behavior a screenshot alone can't confirm (e.g. confirming a click actually triggers an action), but don't rabbit-hole on it — a successful build plus a static screenshot plus careful code review is often the more reliable combination.