README: drop the stale Skip attribution line (the project moved to Kotlin Multiplatform entirely; the "Kotlin Multiplatform" line below it already covers the current tooling) and fix the stated minimum Android version — androidApp's actual minSdk is 24 (Android 7.0), not 10.0. AGENTS.md: a navigation/orientation doc for AI agents, focused on the things that aren't obvious from reading the code or that took real trial-and-error to establish this session — the localization architecture constraint (no synchronous, cross-platform way to resolve a moko-resources string outside Compose/Apple, so shared code must return data, not sentences), the Swift-interop plain-function convention, per-platform currency-data quirks (web's ICU-dependent currency list, missing color-emoji font), and the verification workflow for each platform (especially that Compose for Web renders to a <canvas>, so headless Chrome + specific GL flags is the way to actually see a change, not DOM inspection). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
9.9 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 (shipped on iOS, macOS, and Android;
Web and Desktop/JVM exist as fast dev/preview targets, not shipped products —
see data/db/SqlDelightStores.kt's doc comment). It converts between BTC,
Sats, and fiat currencies using live exchange rates (Coinbase, CoinGecko, or
a manually typed-in rate).
The project is currently developed on the kmp branch, not main — it's a
from-scratch Kotlin Multiplatform rewrite of an earlier Skip-based
(Swift-transpiled-to-Kotlin) implementation. main still has the old Skip
app. Don't use the update-readme branch as a reference for anything — it's
a stale branch from before the Skip → KMP migration and describes a build
process (skip build, Swift Package Manager) this project no longer uses.
Module layout
shared/— the actual app: UI, view models, data sources, domain logic. Almost everything happens here.androidApp/,desktopApp/,webApp/— thin launcher shells aroundshared's Compose UI. Rarely need changes.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.ui/PriceScreen.kt— the actual Compose UI. Used as-is by Android, Desktop, and Web (same composable, three renderers).- 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.
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 — it gets an in-memory-only
fallback (nothing persists across a page reload), consistent with web not
being a shipped platform.
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. - 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.