From c76745e5f1aa127876baffac6d820f23cf8461bc Mon Sep 17 00:00:00 2001 From: pjay Date: Mon, 27 Apr 2026 00:33:57 +0700 Subject: [PATCH] WIP: folio refactor - KotlinConf-style production-app shape (#47) * feat(hierarchy): testTag alias resolves to resource-id and accessibilityIdentifier Compose's testTag surfaces as resource-id on Android and as accessibilityIdentifier on iOS. Selectors written as { testTag: "Foo" } now match either, so Sanderling specs can use the same tag on both platforms. Also rounds out the iOS identifier aliases so resource-id / identifier / accessibilityIdentifier all resolve to one another. * chore(folio): add gradle/libs.versions.toml Centralises versions for all folio modules ahead of the module split. Adds new entries for kotlinx-serialization, navigation3, Metro, KSP, and the JetBrains lifecycle-viewmodel-compose multiplatform artifact. * refactor(folio): introduce nested KotlinConf-style modules Split the monolithic :composeApp into :core, :app:shared, :app:ui-components, and :app:androidApp. The old module is still present and remains the source of truth until the next commits remove it; both compile in parallel to keep iOS/Android builds green during the cut-over. Highlights: - :core - SQLDelight schema + LedgerStore + Repository (now an injectable class, not a singleton object). Methods are suspend to match generateAsync = true. - :app:ui-components - design system primitives. IconButton/AppButton APIs revised: label = real contentDescription, testTag = stable selector. Drops the data-carrier description argument. - :app:shared - per-screen ViewModels colocated with screens; pure composables on (state, onEvent); LocalAppComponent CompositionLocal for hand-rolled DI; @Serializable Route. Hosts the iOS framework (baseName Shared). - :app:androidApp - thin Android entry that constructs the DriverFactory and hands it to App(). - gradle/libs.versions.toml centralises versions; settings.gradle.kts enables type-safe project accessors. Deferred to follow-up PRs (per the design discussion): - Metro DI: hand-rolled AppComponent for now; Metro graphs are mostly ceremony for an app this size and add KSP/version risk. - Navigation3: kept the existing Navigator-as-backstack class, injected rather than singleton; nav3 isn't shipping a stable multiplatform artifact for commonMain consumption yet. - :app:webApp + OPFS sqlite worker: web persistence is real new wiring (custom worker on @sqlite.org/sqlite-wasm). Master's WebLedgerStore + Snapshot is being removed by this PR; web stays buildable as a klib but no app-level wasm binary lands here. * refactor(folio): delete :composeApp and retarget tooling Removes the old monolithic module now that :core / :app:shared / :app:ui-components / :app:androidApp own the source. Updates: - justfile install/uninstall recipes -> :app:androidApp - iosApp/project.yml framework path -> ../app/shared/..., baseName Shared (was ComposeApp); pre-build script invokes :app:shared:linkDebugFrameworkIosSimulatorArm64 - iosApp/iosApp/iOSApp.swift -> import Shared - README -> mentions SQLDelight unification, drops the data-carrier contentDescription notes (now stale), no Layout section per repo convention * refactor(folio-spec): query testTag and identify items by visible text Replaces every accessibilityText / descPrefix data-carrier read with testTag selectors that resolve to resource-id (Android) or accessibilityIdentifier (iOS) via the SDK's alias table. - Routes detected via testTag (LoginScreen, HomeScreen, etc.) - Account identity = visible account name (no synthetic id encoded in semantics). - Ledger row identity = joined text content of the row. - Active account derived from route alone (not parsed from contentDescription). - Focused input read from native focused="true" attribute, not from a custom focused_input data carrier. * fix(folio): build green on Android assemble + iOS framework link - Drop ksp/metro/navigation3 plugin aliases - not actually applied by any module in this PR (deferred follow-up). - import awaitAsOne from app.cash.sqldelight.async.coroutines for the suspend single-row reads enabled by generateAsync = true. - Drop kotlin.js.ExperimentalWasmJsInterop opt-in from common compilerOptions (it isn't valid for android/jvm targets). - :app:shared androidMain pulls in androidx.activity:activity-compose for the BackHandler actual. * fix(folio): testTagsAsResourceId at App root + JS-bridge regression test App.kt sets testTagsAsResourceId=true on the root Box semantics so Compose's testTag surfaces as Android resource-id (and equivalent on iOS via accessibilityIdentifier). Without this, testTag stays in the Compose semantics tree but never reaches the runtime hierarchy that UIAutomator and Sanderling read. Also adds TestStateAxObjectSelectorTestTagAlias as a regression test for the {testTag: ...} object selector resolving through the SDK alias to resource-id at the JS bridge layer. * test(verifier): expose PredicateError latching across steps The runner logs PredicateError once per step. The current implementation latches the first error per thunk, so the log freezes on step 1 forever even when later steps would observe different errors. This test fails today and locks in the contract: PredicateError must reflect the most recent step. * fix(verifier): refresh predicate errors per step EvaluateProperties short-circuits once an Always-property latches to violated, so the underlying goja predicate stops being called and formula.err keeps whatever it threw at step 1. The runner logs PredicateError every step a property is violated, which made every subsequent log line repeat the step-1 throw. That looks like the spec runtime is seeing stale state, but it is just stale error reporting. EvaluateProperties now invokes every registered predicate once per step purely to refresh formula.err. Verdicts are unaffected. The thunk itself stops latching so the new value wins on whichever path runs first. * chore(folio): add Metro DI plugin (1.0.0-RC4) to versions catalog Adds dev.zacsweers.metro plugin alias and applies it to :core as a smoke test. Compiler-plugin only, no KSP required. * chore(folio): apply Metro plugin to :app:shared and :app:androidApp * feat(folio-core): annotate Repository and SqlLedgerStore with @Inject * feat(folio-core): scope Repository and SqlLedgerStore as @SingleIn(AppScope) Both are app-wide singletons so the SqlDelight-backed flows remain shared across the graph. * feat(folio): annotate ViewModels with Metro @Inject / @AssistedInject LedgerViewModel and AddTransactionViewModel use @AssistedInject for their accountId param plus a nested @AssistedFactory; the rest are plain @Inject constructor classes. * feat(folio): introduce Metro AppGraph in commonMain Single shared @DependencyGraph(AppScope::class) that exposes Repository, Navigator, and ViewModels. LedgerDatabase enters the graph via @DependencyGraph.Factory.create(database) so the suspend DriverFactory.create() can stay outside the DI surface. @Binds wires SqlLedgerStore to LedgerStore; Navigator is provided explicitly so its Route.Home start state stays in DI rather than relying on a default-parameter being honored by the graph. * fix(folio): expect/actual testTagsAsResourceId so iOS link succeeds Compose's androidx.compose.ui.semantics.testTagsAsResourceId is Android-only. Calling it directly from commonMain broke linkDebugFrameworkIosSimulatorArm64. Replace with an expect Modifier extension that wires the semantics on Android and is a no-op on iOS / wasmJs. * refactor(folio): replace AppComponent with Metro AppGraph in App.kt App now takes a suspend graph builder; the platform constructs LedgerDatabase off the suspend DriverFactory.create() before invoking the Metro graph factory. Routes resolve VMs through LocalAppGraph instead of the hand-rolled LocalAppComponent. Drops the loading-state placeholder comment (the empty Box is enough). * refactor(folio): resolve ViewModels through LocalAppGraph in routes Each *Route composable now reads the AppGraph from CompositionLocal and pulls its VM via the appropriate accessor or AssistedFactory. * refactor(folio): build AppGraph from platform entry points MainActivity (Android) and MainViewController (iOS) now own the suspend DriverFactory.create() and feed the resulting LedgerDatabase into Metro's createGraphFactory(). * chore(folio): add navigation-compose 2.9.2 dependency Adds the JetBrains KMP navigation-compose library to the shared module. Used in subsequent commits to replace the hand-rolled Navigator with a typed-route NavHost. * refactor(folio): replace custom Navigator with NavHost backstack Wraps androidx.navigation.NavHostController behind the existing push/replace/back surface so call sites in ViewModels stay unchanged. App.kt now wires a typed NavHost with @Serializable Route entries and observes the controller's currentBackStackEntry to drive the session-based Login/Home redirect. * fix(folio-core): wire kotlinx-browser so wasmJs DriverFactory compiles org.w3c.dom.Worker on wasmJs lives in kotlinx-browser, not the stdlib. Pin 0.5.0 alongside the @sqlite.org/sqlite-wasm 3.53.0-build1 version that the upcoming web app will depend on, and switch the worker constructor to the module-worker form that webpack expects. * feat(folio): scaffold :app:webApp wasmJs module Compose Multiplatform target that depends on :app:shared and pulls @sqlite.org/sqlite-wasm 3.53.0-build1 as the npm runtime for the SQLDelight web worker. * feat(folio-webApp): add main entrypoint and index.html main.kt mirrors the iOS entry point: builds DriverFactory + AppGraph factory, hooks browser back-gesture into WebBackGesture, then mounts the shared App composable into ComposeViewport. * feat(folio-webApp): OPFS-backed sqlite worker + webpack config sqlite.worker.js implements the SQLDelight web-worker protocol (exec/begin_transaction/end_transaction/rollback_transaction) on top of @sqlite.org/sqlite-wasm. Prefers the OPFS SAH pool VFS for persistent storage and falls back to in-memory when OPFS is unavailable. webpack.config.d/coopcoep.js sends COOP/COEP headers on the dev server so cross-origin isolation is available, even though the SAH pool itself does not require it. webpack.config.d/sqlite-wasm.js enables asyncWebAssembly so webpack can bundle sqlite3.wasm via the 'new URL("sqlite3.wasm", import.meta.url)' reference inside the sqlite-wasm package. * chore(folio): add web/web-build just recipes and refresh yarn lock Yarn lock picks up @sqlite.org/sqlite-wasm 3.53.0-build1. * fix(folio-core): probe schema before create on wasmJs Wasm SqlDriver doesn't auto-track user_version like the Android driver, so awaitCreate() ran on every page load and tripped over already-created tables. Read PRAGMA user_version, run awaitCreate/awaitMigrate based on it, and self-heal pre-existing tables with version 0 by stamping the current schema version. * chore(folio-webApp): pin dev-server port and trim worker logging webpack-dev-server now binds 8088 (or WEBAPP_PORT) so it doesn't collide with the docs server on 8080. Drop the per-message reply log; keep only the OPFS init line and error logging. * chore(folio): nest iosApp under app/ for KotlinConf parity Match KotlinConf-app's filesystem layout where every entry point (android, ios, web, shared, ui-components) lives under app/. iosApp is still an Xcode project, not a Gradle module, so settings.gradle.kts is unchanged. * feat(folio): testTag identity for AccountName and ledger row cells Replaces string-heuristic identity in the spec extractors with stable testTags. AccountCard exposes AccountName; LedgerRow exposes TxnNote and TxnDate. Spec extractors read those directly instead of filtering visible text by "starts with $" / "matches digit". * fix(folio-app): branch start destination on initial session Read repository.session.value at first composition and pick Route.Home or Route.Login as the NavHost startDestination. Avoids the one-frame Home flash on cold start with no persisted session. * refactor(folio-webApp): hard-fail when OPFS unavailable Drops the silent in-memory fallback. The README claims OPFS persistence; falling back without surfacing the degrade made data loss invisible across reloads. Now the worker errors out and the Kotlin DriverFactory rejects the create() call instead. * docs(verifier): document extractor advancement and refresh invariants Extractor previous/current advance only on PushSnapshot, never per thunk-call. refreshPredicateErrors depends on this for safe re-entry. Also flags that re-invoked predicates run outside their LTL gate, so they must be side-effect-free reads. * fix(hierarchy): populate ResourceID from accessibilityIdentifier iOS Compose surfaces testTag as accessibilityIdentifier. Previously only resource-id and identifier seeded element.ResourceID, leaving element.id empty for iOS Compose nodes and forcing specs to walk attrs to recover stable identifiers. * refactor(folio-spec): use element.id for focused field tag Now that ResourceID populates uniformly across Android/iOS Compose, the spec can read element.id directly instead of probing attrs for each platform's underlying field name. * fix(folio-spec): pick account card via seeded from(), not Math.random Math.random() breaks --seed reproducibility. The verifier's seeded RNG flows through from(), so re-running a seed now produces the same card pick sequence. * docs(spec): fix README example to use scoped extractors The previous snippet referenced `state.ax.find` inside an actions() body where state is not in scope, and shadowed the imported actions helper with an export of the same name. * feat(hierarchy): add FindBySelectorPath for chained object selectors Each selector in the chain is matched within the descendants of the previous match. Returns the deepest match (or nil) for FindBySelectorPath and every deepest match for FindAllBySelectorPath. * feat(verifier): dispatch JS array selectors to FindBySelectorPath `state.ax.find([{...}, {...}])` now walks each segment scoped under the previous match. Strings and single objects keep their existing single-shot lookup. * feat(spec): expose SelectorPath in find/findAll signatures * fix(spec): satisfy AccessibilityElement interface in Tap test fixture * refactor(folio-spec): collapse chained finds into selector paths * feat(spec): add keyedBy(element, tags) identity helper Joins element.find({testTag: tag})?.text per tag with U+001F as the delimiter so user-visible text can never collide with the separator. Returns empty string for an undefined element. * refactor(folio-spec): use keyedBy for ledger row identity * feat(spec): add whenRoute action gating helper whenRoute(route, allowedRoutes, body) wraps an actions() generator that returns [] unless route.current matches one of the allowed values. Accepts a single route or an array. * test(spec): cover whenRoute matching, gating, and array routes * refactor(folio-spec): gate addAccount and addTxn with whenRoute * refactor(hierarchy): use maps.Copy for attribute merge Linter flagged the manual loop after recent edits surfaced the hint. * feat(verifier): dispatch setup generator before actions root Setup is consulted every step; when it returns ErrNoAction the call falls through to the existing actionGenerator retry loop. This lets specs split deterministic preconditions (login, onboarding) out of the weighted action pool while auto-reengaging if state regresses (e.g. logout under fuzz). * docs(spec): document setup precondition action generator * refactor(folio-spec): export login as setup, remove from action pool Login is deterministic and yields no actions once the app is past the login screen; sitting at weight 50 in the action pool wasted half of step picks on a no-op. Promote it to setup so the runner only consults it while it has work to do, and rebalance remaining weights to round numbers (addAccount 50, addTxn 40, back 10). --- examples/folio/.gitignore | 6 +- examples/folio/README.md | 25 +- .../folio/app/androidApp/build.gradle.kts | 45 ++++ .../androidApp/src/main}/AndroidManifest.xml | 1 - .../src/main/kotlin/app/folio/MainActivity.kt | 20 ++ .../folio/{ => app}/iosApp/iosApp/Info.plist | 0 .../{ => app}/iosApp/iosApp/iOSApp.swift | 2 +- examples/folio/{ => app}/iosApp/project.yml | 10 +- examples/folio/app/shared/build.gradle.kts | 66 +++++ .../app/folio/ui/BackHandler.android.kt | 0 .../app/folio/ui/TestTagBridge.android.kt | 10 + .../kotlin/app/folio/util}/Format.android.kt | 2 +- .../src/commonMain/kotlin/app/folio/App.kt | 103 ++++++++ .../kotlin/app/folio/di/AppGraph.kt | 41 +++ .../kotlin/app/folio/di/LocalAppGraph.kt | 7 + .../folio/feature/account/AddAccountScreen.kt | 67 ++--- .../feature/account/AddAccountViewModel.kt | 61 +++++ .../app/folio/feature/auth/LoginScreen.kt | 67 ++--- .../app/folio/feature/auth/LoginViewModel.kt | 65 +++++ .../app/folio/feature/home/HomeScreen.kt | 67 ++--- .../app/folio/feature/home/HomeViewModel.kt | 56 ++++ .../feature/ledger/AddTransactionScreen.kt | 120 +++++++++ .../feature/ledger/AddTransactionViewModel.kt | 97 +++++++ .../app/folio/feature/ledger/LedgerScreen.kt | 74 +++--- .../folio/feature/ledger/LedgerViewModel.kt | 61 +++++ .../kotlin/app/folio/navigation/Navigator.kt | 32 +++ .../kotlin/app/folio/navigation/Route.kt | 12 + .../kotlin/app/folio/ui/BackHandler.kt | 0 .../kotlin/app/folio/ui/TestTagBridge.kt | 5 + .../kotlin/app/folio/util}/Format.kt | 6 +- .../kotlin/app/folio/MainViewController.kt | 18 +- .../kotlin/app/folio/ui/BackHandler.ios.kt | 2 +- .../kotlin/app/folio/ui/IosBackGesture.kt | 0 .../kotlin/app/folio/ui/TestTagBridge.ios.kt | 5 + .../kotlin/app/folio/util}/Format.ios.kt | 2 +- .../kotlin/app/folio/ui/BackHandler.wasmJs.kt | 0 .../app/folio/ui/TestTagBridge.wasmJs.kt | 5 + .../kotlin/app/folio/util}/Format.wasmJs.kt | 2 +- .../folio/app/ui-components/build.gradle.kts | 49 ++++ .../app/folio/ui/component/AppButton.kt | 10 +- .../kotlin/app/folio/ui/component/Card.kt | 0 .../app/folio/ui/component/EmptyState.kt | 0 .../app/folio/ui/component/ErrorText.kt | 0 .../app/folio/ui/component/FieldLabel.kt | 0 .../kotlin/app/folio/ui/component/Header.kt | 0 .../app/folio/ui/component/IconButton.kt | 9 +- .../kotlin/app/folio/ui/component/Screen.kt | 0 .../app/folio/ui/component/Segmented.kt | 12 +- .../app/folio/ui/component/TextInput.kt | 18 +- .../kotlin/app/folio/ui/icon/Icons.kt | 0 .../kotlin/app/folio/ui/theme/Theme.kt | 0 .../kotlin/app/folio/ui/theme/Type.kt | 0 examples/folio/app/webApp/build.gradle.kts | 38 +++ .../src/wasmJsMain/kotlin/app/folio/main.kt | 19 +- .../src/wasmJsMain/resources/index.html | 16 ++ .../src/wasmJsMain/resources/sqlite.worker.js | 62 +++++ .../app/webApp/webpack.config.d/coopcoep.js | 10 + .../webApp/webpack.config.d/sqlite-wasm.js | 9 + examples/folio/build.gradle.kts | 14 +- examples/folio/composeApp/build.gradle.kts | 129 --------- .../kotlin/app/folio/FolioApplication.kt | 13 - .../kotlin/app/folio/MainActivity.kt | 12 - .../app/folio/data/LedgerStore.android.kt | 10 - .../src/commonMain/kotlin/app/folio/App.kt | 77 ------ .../kotlin/app/folio/FocusTracker.kt | 21 -- .../kotlin/app/folio/data/LedgerStore.kt | 17 -- .../feature/account/AddAccountUiState.kt | 7 - .../app/folio/feature/auth/LoginUiState.kt | 7 - .../feature/ledger/AddTransactionScreen.kt | 162 ------------ .../feature/ledger/AddTransactionUiState.kt | 8 - .../kotlin/app/folio/navigation/Navigator.kt | 31 --- .../kotlin/app/folio/navigation/Route.kt | 9 - .../app/folio/data/DriverFactory.ios.kt | 10 - .../kotlin/app/folio/data/LedgerStore.ios.kt | 3 - .../kotlin/app/folio/data/Snapshot.kt | 65 ----- .../kotlin/app/folio/data/WebLedgerStore.kt | 60 ----- .../src/wasmJsMain/resources/index.html | 17 -- examples/folio/core/build.gradle.kts | 68 +++++ .../folio/core}/data/DriverFactory.android.kt | 7 +- .../folio/core}/platform/Platform.android.kt | 2 +- .../kotlin/app/folio/core}/data/Account.kt | 2 +- .../app/folio/core}/data/DriverFactory.kt | 4 +- .../kotlin/app/folio/core/data/LedgerStore.kt | 15 ++ .../kotlin/app/folio/core}/data/Repository.kt | 34 ++- .../kotlin/app/folio/core}/data/Session.kt | 2 +- .../app/folio/core}/data/SqlLedgerStore.kt | 41 +-- .../app/folio/core}/data/Transaction.kt | 2 +- .../app/folio/core}/platform/Platform.kt | 2 +- .../sqldelight/app/folio/db/Ledger.sq | 0 .../app/folio/core/data/DriverFactory.ios.kt | 11 + .../app/folio/core}/platform/Platform.ios.kt | 2 +- .../folio/core/data/DriverFactory.wasmJs.kt | 70 +++++ .../folio/core}/platform/Platform.wasmJs.kt | 2 +- examples/folio/gradle/libs.versions.toml | 42 +++ examples/folio/justfile | 28 +- examples/folio/kotlin-js-store/wasm/yarn.lock | 5 + examples/folio/sanderling/spec.ts | 170 ++++++------ examples/folio/settings.gradle.kts | 8 +- internal/hierarchy/hierarchy.go | 75 +++++- internal/hierarchy/hierarchy_test.go | 151 +++++++++++ internal/verifier/ax_integration_test.go | 52 ++++ internal/verifier/bindings.go | 7 +- internal/verifier/marshal.go | 46 +++- internal/verifier/verifier_test.go | 244 ++++++++++++++++++ internal/verifier/worker.go | 64 ++++- pkg/spec/README.md | 34 ++- pkg/spec/src/actions.ts | 13 + pkg/spec/src/extract.ts | 14 +- pkg/spec/src/index.ts | 4 +- pkg/spec/src/types.ts | 10 +- pkg/spec/test/api.test.ts | 84 +++++- 111 files changed, 2330 insertions(+), 1071 deletions(-) create mode 100644 examples/folio/app/androidApp/build.gradle.kts rename examples/folio/{composeApp/src/androidMain => app/androidApp/src/main}/AndroidManifest.xml (94%) create mode 100644 examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt rename examples/folio/{ => app}/iosApp/iosApp/Info.plist (100%) rename examples/folio/{ => app}/iosApp/iosApp/iOSApp.swift (96%) rename examples/folio/{ => app}/iosApp/project.yml (69%) create mode 100644 examples/folio/app/shared/build.gradle.kts rename examples/folio/{composeApp => app/shared}/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt (100%) create mode 100644 examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt rename examples/folio/{composeApp/src/androidMain/kotlin/app/folio/platform => app/shared/src/androidMain/kotlin/app/folio/util}/Format.android.kt (89%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt rename examples/folio/{composeApp => app/shared}/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt (50%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt rename examples/folio/{composeApp => app/shared}/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt (55%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt rename examples/folio/{composeApp => app/shared}/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt (72%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt rename examples/folio/{composeApp => app/shared}/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt (72%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt rename examples/folio/{composeApp => app/shared}/src/commonMain/kotlin/app/folio/ui/BackHandler.kt (100%) create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio/platform => app/shared/src/commonMain/kotlin/app/folio/util}/Format.kt (95%) rename examples/folio/{composeApp => app/shared}/src/iosMain/kotlin/app/folio/MainViewController.kt (75%) rename examples/folio/{composeApp => app/shared}/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt (100%) rename examples/folio/{composeApp => app/shared}/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt (100%) create mode 100644 examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt rename examples/folio/{composeApp/src/iosMain/kotlin/app/folio/platform => app/shared/src/iosMain/kotlin/app/folio/util}/Format.ios.kt (94%) rename examples/folio/{composeApp => app/shared}/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt (100%) create mode 100644 examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt rename examples/folio/{composeApp/src/wasmJsMain/kotlin/app/folio/platform => app/shared/src/wasmJsMain/kotlin/app/folio/util}/Format.wasmJs.kt (97%) create mode 100644 examples/folio/app/ui-components/build.gradle.kts rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt (86%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/Card.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/Header.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt (82%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/Screen.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt (86%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt (86%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt (100%) rename examples/folio/{composeApp => app/ui-components}/src/commonMain/kotlin/app/folio/ui/theme/Type.kt (100%) create mode 100644 examples/folio/app/webApp/build.gradle.kts rename examples/folio/{composeApp => app/webApp}/src/wasmJsMain/kotlin/app/folio/main.kt (53%) create mode 100644 examples/folio/app/webApp/src/wasmJsMain/resources/index.html create mode 100644 examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js create mode 100644 examples/folio/app/webApp/webpack.config.d/coopcoep.js create mode 100644 examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js delete mode 100644 examples/folio/composeApp/build.gradle.kts delete mode 100644 examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt delete mode 100644 examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt delete mode 100644 examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt delete mode 100644 examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt delete mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt delete mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt delete mode 100644 examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt delete mode 100644 examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt delete mode 100644 examples/folio/composeApp/src/wasmJsMain/resources/index.html create mode 100644 examples/folio/core/build.gradle.kts rename examples/folio/{composeApp/src/androidMain/kotlin/app/folio => core/src/androidMain/kotlin/app/folio/core}/data/DriverFactory.android.kt (51%) rename examples/folio/{composeApp/src/androidMain/kotlin/app/folio => core/src/androidMain/kotlin/app/folio/core}/platform/Platform.android.kt (84%) rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/Account.kt (76%) rename examples/folio/{composeApp/src/sqlMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/DriverFactory.kt (53%) create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/Repository.kt (58%) rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/Session.kt (71%) rename examples/folio/{composeApp/src/sqlMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/SqlLedgerStore.kt (56%) rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/data/Transaction.kt (87%) rename examples/folio/{composeApp/src/commonMain/kotlin/app/folio => core/src/commonMain/kotlin/app/folio/core}/platform/Platform.kt (69%) rename examples/folio/{composeApp => core}/src/commonMain/sqldelight/app/folio/db/Ledger.sq (100%) create mode 100644 examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt rename examples/folio/{composeApp/src/iosMain/kotlin/app/folio => core/src/iosMain/kotlin/app/folio/core}/platform/Platform.ios.kt (89%) create mode 100644 examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt rename examples/folio/{composeApp/src/wasmJsMain/kotlin/app/folio => core/src/wasmJsMain/kotlin/app/folio/core}/platform/Platform.wasmJs.kt (96%) create mode 100644 examples/folio/gradle/libs.versions.toml diff --git a/examples/folio/.gitignore b/examples/folio/.gitignore index 827413a..9ac256b 100644 --- a/examples/folio/.gitignore +++ b/examples/folio/.gitignore @@ -2,9 +2,9 @@ build/ .kotlin/ local.properties -iosApp/build/ -iosApp/iosApp.xcodeproj/ -iosApp/iosApp.xcodeproj/** +app/iosApp/build/ +app/iosApp/iosApp.xcodeproj/ +app/iosApp/iosApp.xcodeproj/** .DS_Store sanderling/node_modules/ sanderling/runs/ diff --git a/examples/folio/README.md b/examples/folio/README.md index 968899c..c82204b 100644 --- a/examples/folio/README.md +++ b/examples/folio/README.md @@ -2,14 +2,15 @@ A minimal Kotlin Multiplatform personal-ledger app: login with demo credentials, create accounts, add credits and debits. Shared UI across -Android, iOS, and Web via Compose Multiplatform. Doubles as the example +Android and iOS via Compose Multiplatform. Doubles as the example sanderling runs its property-based specs against. ## Stack - Kotlin Multiplatform + Compose Multiplatform (shared UI) -- kotlinx.serialization for file-backed persistence +- SQLDelight for the data layer (unified across platforms) - kotlinx.coroutines for state flows +- kotlinx.serialization for `@Serializable` route types ## Prerequisites @@ -34,9 +35,9 @@ just ios # default device: iPhone 17 Pro IOS_DEVICE="iPhone 15" just ios # pick a different simulator ``` -`just ios` regenerates `iosApp/iosApp.xcodeproj` from `iosApp/project.yml`, -builds the KMP framework, links it into the SwiftUI host, installs, and -launches. +`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`, +builds the KMP framework (`Shared.framework` from `:app:shared`), links it +into the SwiftUI host, installs, and launches. ## Demo credentials @@ -69,9 +70,13 @@ Traces land in `./sanderling/runs//`. ## How it connects to sanderling -- UI elements expose state via content descriptions (`account:$id:$balance`, - `ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`, - `focused_input:$field`) +- Each screen sets a stable Compose `testTag` (`HomeScreen`, `AccountCard`, + `LedgerRow`, `TxnAmount`, ...). The Sanderling SDK resolves `testTag` to + `resource-id` on Android and `accessibilityIdentifier` on iOS. +- Identity for list items is the visible text content (account name; txn + note + amount). No synthetic IDs encoded in semantics. +- `contentDescription` is reserved for real accessibility labels, never as a + data carrier. - `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`, - asserts properties, and weights the actions the fuzzer picks from -- `just test` invokes `sanderling test` against the installed APK + asserts properties, and weights the actions the fuzzer picks from. +- `just test` invokes `sanderling test` against the installed APK. diff --git a/examples/folio/app/androidApp/build.gradle.kts b/examples/folio/app/androidApp/build.gradle.kts new file mode 100644 index 0000000..b854bb4 --- /dev/null +++ b/examples/folio/app/androidApp/build.gradle.kts @@ -0,0 +1,45 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.metro) +} + +val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev" + +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + +android { + namespace = "app.folio" + compileSdk = 36 + + defaultConfig { + applicationId = "app.folio" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = sanderlingVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { debug { isDebuggable = true } } + + sourceSets["main"].apply { + manifest.srcFile("src/main/AndroidManifest.xml") + res.srcDirs("src/main/res") + } +} + +dependencies { + implementation(projects.app.shared) + implementation(libs.androidx.activity.compose) +} diff --git a/examples/folio/composeApp/src/androidMain/AndroidManifest.xml b/examples/folio/app/androidApp/src/main/AndroidManifest.xml similarity index 94% rename from examples/folio/composeApp/src/androidMain/AndroidManifest.xml rename to examples/folio/app/androidApp/src/main/AndroidManifest.xml index 748409f..c94068c 100644 --- a/examples/folio/composeApp/src/androidMain/AndroidManifest.xml +++ b/examples/folio/app/androidApp/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ diff --git a/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt b/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt new file mode 100644 index 0000000..c5409ea --- /dev/null +++ b/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt @@ -0,0 +1,20 @@ +package app.folio + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph +import dev.zacsweers.metro.createGraphFactory + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val driverFactory = DriverFactory(applicationContext) + val graphFactory = createGraphFactory() + setContent { + App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) }) + } + } +} diff --git a/examples/folio/iosApp/iosApp/Info.plist b/examples/folio/app/iosApp/iosApp/Info.plist similarity index 100% rename from examples/folio/iosApp/iosApp/Info.plist rename to examples/folio/app/iosApp/iosApp/Info.plist diff --git a/examples/folio/iosApp/iosApp/iOSApp.swift b/examples/folio/app/iosApp/iosApp/iOSApp.swift similarity index 96% rename from examples/folio/iosApp/iosApp/iOSApp.swift rename to examples/folio/app/iosApp/iosApp/iOSApp.swift index 09b4572..c4821c1 100644 --- a/examples/folio/iosApp/iosApp/iOSApp.swift +++ b/examples/folio/app/iosApp/iosApp/iOSApp.swift @@ -1,5 +1,5 @@ import SwiftUI -import ComposeApp +import Shared @main struct iOSApp: App { diff --git a/examples/folio/iosApp/project.yml b/examples/folio/app/iosApp/project.yml similarity index 69% rename from examples/folio/iosApp/project.yml rename to examples/folio/app/iosApp/project.yml index 5fcf8cd..10a632d 100644 --- a/examples/folio/iosApp/project.yml +++ b/examples/folio/app/iosApp/project.yml @@ -19,7 +19,7 @@ targets: sources: - iosApp dependencies: - - framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework + - framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework embed: false - sdk: libsqlite3.tbd settings: @@ -28,16 +28,16 @@ targets: PRODUCT_NAME: iosApp INFOPLIST_FILE: iosApp/Info.plist FRAMEWORK_SEARCH_PATHS: - - $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework + - $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework OTHER_LDFLAGS: - -ObjC KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64 preBuildScripts: - name: Build Kotlin framework script: | - cd "$SRCROOT/.." + cd "$SRCROOT/../.." ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \ - ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 + ./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64 outputFiles: - - $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp + - $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared basedOnDependencyAnalysis: false diff --git a/examples/folio/app/shared/build.gradle.kts b/examples/folio/app/shared/build.gradle.kts new file mode 100644 index 0000000..9dd07b2 --- /dev/null +++ b/examples/folio/app/shared/build.gradle.kts @@ -0,0 +1,66 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "Shared" + isStatic = true + binaryOption("bundleId", "app.folio.shared") + export(projects.core) + } + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(projects.core) + api(projects.app.uiComponents) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.navigation.compose) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + } + } +} + +android { + namespace = "app.folio.shared" + compileSdk = 36 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt similarity index 100% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt rename to examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt diff --git a/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt new file mode 100644 index 0000000..5896d35 --- /dev/null +++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt @@ -0,0 +1,10 @@ +package app.folio.ui + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +@OptIn(ExperimentalComposeUiApi::class) +actual fun Modifier.testTagsAsResourceId(): Modifier = + semantics { testTagsAsResourceId = true } diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt similarity index 89% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt rename to examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt index ede6eed..340f8bf 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt +++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util import java.text.SimpleDateFormat import java.util.Date diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt new file mode 100644 index 0000000..6ab54a9 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt @@ -0,0 +1,103 @@ +package app.folio + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.folio.di.AppGraph +import app.folio.di.LocalAppGraph +import app.folio.feature.account.AddAccountRoute +import app.folio.feature.auth.LoginRoute +import app.folio.feature.home.HomeRoute +import app.folio.feature.ledger.AddTransactionRoute +import app.folio.feature.ledger.LedgerRoute +import app.folio.navigation.Route +import app.folio.ui.testTagsAsResourceId +import app.folio.ui.theme.LedgerTheme +import app.folio.ui.theme.LocalTokens +import app.folio.ui.theme.Tokens + +@Composable +fun App(graphBuilder: suspend () -> AppGraph) { + var graph by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { graph = graphBuilder() } + + LedgerTheme { + val t = Tokens() + CompositionLocalProvider(LocalTokens provides t) { + Box( + Modifier + .fillMaxSize() + .background(t.bg) + .windowInsetsPadding(WindowInsets.safeDrawing) + .testTagsAsResourceId(), + contentAlignment = Alignment.Center, + ) { + val g = graph + if (g != null) { + CompositionLocalProvider(LocalAppGraph provides g) { + AppContent() + } + } + } + } + } +} + +@Composable +private fun AppContent() { + val graph = LocalAppGraph.current + val navController = rememberNavController() + + LaunchedEffect(navController) { graph.navigator.attach(navController) } + + val initialSession = remember { graph.repository.session.value } + val startDestination: Route = if (initialSession != null) Route.Home else Route.Login + + val session by graph.repository.session.collectAsState() + val currentEntry by navController.currentBackStackEntryAsState() + val onLogin = currentEntry?.destination?.hasRoute(Route.Login::class) == true + + LaunchedEffect(session, onLogin) { + if (session == null && !onLogin) { + graph.navigator.replace(Route.Login) + } else if (session != null && onLogin) { + graph.navigator.replace(Route.Home) + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + ) { + composable { LoginRoute() } + composable { HomeRoute() } + composable { AddAccountRoute() } + composable { entry -> + LedgerRoute(accountId = entry.toRoute().accountId) + } + composable { entry -> + AddTransactionRoute(accountId = entry.toRoute().accountId) + } + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt new file mode 100644 index 0000000..8de68f2 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt @@ -0,0 +1,41 @@ +package app.folio.di + +import app.folio.core.data.LedgerStore +import app.folio.core.data.Repository +import app.folio.core.data.SqlLedgerStore +import app.folio.db.LedgerDatabase +import app.folio.feature.account.AddAccountViewModel +import app.folio.feature.auth.LoginViewModel +import app.folio.feature.home.HomeViewModel +import app.folio.feature.ledger.AddTransactionViewModel +import app.folio.feature.ledger.LedgerViewModel +import app.folio.navigation.Navigator +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn + +@SingleIn(AppScope::class) +@DependencyGraph(AppScope::class) +interface AppGraph { + val repository: Repository + val navigator: Navigator + + val loginViewModel: LoginViewModel + val homeViewModel: HomeViewModel + val addAccountViewModel: AddAccountViewModel + val ledgerViewModelFactory: LedgerViewModel.Factory + val addTransactionViewModelFactory: AddTransactionViewModel.Factory + + @Binds val SqlLedgerStore.bindLedgerStore: LedgerStore + + @SingleIn(AppScope::class) + @Provides + fun provideNavigator(): Navigator = Navigator() + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides database: LedgerDatabase): AppGraph + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt new file mode 100644 index 0000000..a40236e --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt @@ -0,0 +1,7 @@ +package app.folio.di + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalAppGraph = staticCompositionLocalOf { + error("AppGraph not provided. Wrap content in CompositionLocalProvider(LocalAppGraph provides ...).") +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt similarity index 50% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt index f4d017b..5fe7ddb 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt @@ -6,20 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph import app.folio.ui.BackHandler import app.folio.ui.component.AppButton import app.folio.ui.component.BackButton @@ -33,46 +26,30 @@ import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.Type @Composable -fun AddAccountScreen() { +fun AddAccountRoute() { + val graph = LocalAppGraph.current + val vm: AddAccountViewModel = viewModel { graph.addAccountViewModel } + val state by vm.state.collectAsState() + AddAccountScreen(state = state, onEvent = vm::onEvent) +} + +@Composable +fun AddAccountScreen(state: AddAccountUiState, onEvent: (AddAccountEvent) -> Unit) { val t = LocalTokens.current - var name by remember { mutableStateOf("") } - val err by AddAccountUiState.addAccountError.collectAsState() - - BackHandler { Navigator.back(Route.Home) } - - DisposableEffect(Unit) { - onDispose { AddAccountUiState.addAccountError.value = "" } - } - - fun submit() { - val trimmed = name.trim() - if (trimmed.isEmpty()) { - AddAccountUiState.addAccountError.value = "Account name is required"; return - } - if (trimmed.length > 40) { - AddAccountUiState.addAccountError.value = "Name is too long (max 40 characters)"; return - } - try { - Repository.createAccount(trimmed) - AddAccountUiState.addAccountError.value = "" - Navigator.replace(Route.Home) - } catch (e: IllegalArgumentException) { - AddAccountUiState.addAccountError.value = e.message ?: "Could not create account" - } - } + BackHandler { onEvent(AddAccountEvent.Back) } Screen( - modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" }, + modifier = Modifier.testTag("AddAccountScreen"), header = { - Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) }) + Header(title = "New account", left = { BackButton(onClick = { onEvent(AddAccountEvent.Back) }) }) }, footer = { AppButton( text = "Create account", - onClick = ::submit, + onClick = { onEvent(AddAccountEvent.Submit) }, style = ButtonStyle.Primary, - enabled = name.trim().isNotEmpty(), - description = "add_account_submit", + enabled = state.name.trim().isNotEmpty(), + testTag = "AddAccountSubmit", ) }, ) { @@ -83,15 +60,15 @@ fun AddAccountScreen() { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Account name") TextInput( - value = name, - onChange = { name = it; AddAccountUiState.addAccountError.value = "" }, + value = state.name, + onChange = { onEvent(AddAccountEvent.NameChange(it)) }, placeholder = "e.g. Checking", - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), label = "Account name", - description = "account_name_field", + testTag = "AccountNameField", ) } - ErrorText(err) + ErrorText(state.error) Text( "Use a short, recognizable name. You can create as many accounts as you need.", style = Type.caption, diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt new file mode 100644 index 0000000..0262050 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt @@ -0,0 +1,61 @@ +package app.folio.feature.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.folio.core.data.Repository +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AddAccountUiState( + val name: String = "", + val error: String = "", +) + +sealed interface AddAccountEvent { + data class NameChange(val value: String) : AddAccountEvent + data object Submit : AddAccountEvent + data object Back : AddAccountEvent +} + +@Inject +class AddAccountViewModel( + private val repository: Repository, + private val navigator: Navigator, +) : ViewModel() { + private val _state = MutableStateFlow(AddAccountUiState()) + val state: StateFlow = _state.asStateFlow() + + fun onEvent(event: AddAccountEvent) { + when (event) { + is AddAccountEvent.NameChange -> _state.update { it.copy(name = event.value, error = "") } + AddAccountEvent.Back -> navigator.back(Route.Home) + AddAccountEvent.Submit -> submit() + } + } + + private fun submit() { + val trimmed = _state.value.name.trim() + if (trimmed.isEmpty()) { + _state.update { it.copy(error = "Account name is required") } + return + } + if (trimmed.length > 40) { + _state.update { it.copy(error = "Name is too long (max 40 characters)") } + return + } + viewModelScope.launch { + try { + repository.createAccount(trimmed) + navigator.replace(Route.Home) + } catch (e: IllegalArgumentException) { + _state.update { it.copy(error = e.message ?: "Could not create account") } + } + } + } +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt similarity index 55% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt index be1149c..e22fc98 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt @@ -8,19 +8,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import app.folio.data.Repository +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph import app.folio.ui.component.AppButton import app.folio.ui.component.ButtonStyle import app.folio.ui.component.Card @@ -31,38 +26,18 @@ import app.folio.ui.component.TextInput import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.Type -const val DEMO_EMAIL = "demo@folio.app" -const val DEMO_PASSWORD = "ledger123" - -private fun checkCredentials(email: String, password: String): Boolean { - return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD +@Composable +fun LoginRoute() { + val graph = LocalAppGraph.current + val vm: LoginViewModel = viewModel { graph.loginViewModel } + val state by vm.state.collectAsState() + LoginScreen(state = state, onEvent = vm::onEvent) } @Composable -fun LoginScreen(onLoggedIn: (String) -> Unit) { +fun LoginScreen(state: LoginUiState, onEvent: (LoginEvent) -> Unit) { val t = LocalTokens.current - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val err by LoginUiState.loginError.collectAsState() - - DisposableEffect(Unit) { - onDispose { LoginUiState.loginError.value = "" } - } - - fun submit() { - if (email.isBlank() || password.isEmpty()) { - LoginUiState.loginError.value = "Enter email and password"; return - } - if (!checkCredentials(email, password)) { - LoginUiState.loginError.value = "Invalid email or password"; return - } - LoginUiState.loginError.value = "" - val user = email.trim().lowercase() - Repository.setSession(user) - onLoggedIn(user) - } - - Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) { + Screen(modifier = Modifier.testTag("LoginScreen")) { Spacer(Modifier.height(16.dp)) Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), @@ -71,34 +46,34 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Email") TextInput( - value = email, - onChange = { email = it; LoginUiState.loginError.value = "" }, + value = state.email, + onChange = { onEvent(LoginEvent.EmailChange(it)) }, placeholder = DEMO_EMAIL, - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), keyboardType = KeyboardType.Email, label = "Email", - description = "login_email", + testTag = "LoginEmail", ) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Password") TextInput( - value = password, - onChange = { password = it; LoginUiState.loginError.value = "" }, + value = state.password, + onChange = { onEvent(LoginEvent.PasswordChange(it)) }, placeholder = "••••••••", password = true, - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), keyboardType = KeyboardType.Password, label = "Password", - description = "login_password", + testTag = "LoginPassword", ) } - ErrorText(err) + ErrorText(state.error) AppButton( text = "Sign in", - onClick = ::submit, + onClick = { onEvent(LoginEvent.Submit) }, style = ButtonStyle.Primary, - description = "login_submit", + testTag = "LoginSubmit", ) Spacer(Modifier.height(4.dp)) Card(dashed = true) { diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt new file mode 100644 index 0000000..bcfd389 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt @@ -0,0 +1,65 @@ +package app.folio.feature.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.folio.core.data.Repository +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +const val DEMO_EMAIL = "demo@folio.app" +const val DEMO_PASSWORD = "ledger123" + +data class LoginUiState( + val email: String = "", + val password: String = "", + val error: String = "", +) + +sealed interface LoginEvent { + data class EmailChange(val value: String) : LoginEvent + data class PasswordChange(val value: String) : LoginEvent + data object Submit : LoginEvent +} + +@Inject +class LoginViewModel( + private val repository: Repository, + private val navigator: Navigator, +) : ViewModel() { + private val _state = MutableStateFlow(LoginUiState()) + val state: StateFlow = _state.asStateFlow() + + fun onEvent(event: LoginEvent) { + when (event) { + is LoginEvent.EmailChange -> _state.update { it.copy(email = event.value, error = "") } + is LoginEvent.PasswordChange -> _state.update { it.copy(password = event.value, error = "") } + LoginEvent.Submit -> submit() + } + } + + private fun submit() { + val s = _state.value + if (s.email.isBlank() || s.password.isEmpty()) { + _state.update { it.copy(error = "Enter email and password") } + return + } + if (!checkCredentials(s.email, s.password)) { + _state.update { it.copy(error = "Invalid email or password") } + return + } + val user = s.email.trim().lowercase() + viewModelScope.launch { + repository.setSession(user) + navigator.replace(Route.Home) + } + } + + private fun checkCredentials(email: String, password: String): Boolean = + email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt similarity index 72% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt index ce18006..29bae99 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt @@ -13,24 +13,21 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.runtime.collectAsState -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.initialsOf -import app.folio.platform.signedAmount +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph +import app.folio.util.balanceOf +import app.folio.util.formatCents +import app.folio.util.initialsOf +import app.folio.util.signedAmount import app.folio.ui.component.AppButton import app.folio.ui.component.ButtonStyle import app.folio.ui.component.EmptyState @@ -43,20 +40,31 @@ import app.folio.ui.theme.RadiusLg import app.folio.ui.theme.Type @Composable -fun HomeScreen(user: String, onLogout: () -> Unit) { +fun HomeRoute() { + val graph = LocalAppGraph.current + val vm: HomeViewModel = viewModel { graph.homeViewModel } + val state by vm.state.collectAsState() + HomeScreen(state = state, onEvent = vm::onEvent) +} + +@Composable +fun HomeScreen(state: HomeUiState, onEvent: (HomeEvent) -> Unit) { val t = LocalTokens.current - val accounts by Repository.accounts.collectAsState() - val txns by Repository.transactions.collectAsState() - val total = balanceOf(txns) + val total = balanceOf(state.transactions) Screen( - modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" }, + modifier = Modifier.testTag("HomeScreen"), header = { Header( title = "Accounts", - subtitle = user, + subtitle = state.user, right = { - IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout) + IconButton( + onClick = { onEvent(HomeEvent.Logout) }, + label = "Log out", + icon = Icons.Logout, + testTag = "LogoutButton", + ) }, ) }, @@ -68,20 +76,20 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { } Box(Modifier.weight(1f)) Text( - "${accounts.size} account${if (accounts.size == 1) "" else "s"}", + "${state.accounts.size} account${if (state.accounts.size == 1) "" else "s"}", style = Type.caption, color = t.textFaint, ) } AppButton( text = "+ Add account", - onClick = { Navigator.push(Route.AddAccount) }, + onClick = { onEvent(HomeEvent.AddAccount) }, style = ButtonStyle.Primary, - description = "add_account_button", + testTag = "AddAccountButton", ) }, ) { - if (accounts.isEmpty()) { + if (state.accounts.isEmpty()) { EmptyState( title = "No accounts yet", subtitle = "Create your first account to start tracking transactions.", @@ -89,16 +97,15 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { ) } else { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - accounts.forEach { a -> - val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) } - val count = txns.count { it.accountId == a.id } + state.accounts.forEach { a -> + val bal = state.transactions.filter { it.accountId == a.id }.sumOf { signedAmount(it) } + val count = state.transactions.count { it.accountId == a.id } AccountCard( - id = a.id, name = a.name, initials = initialsOf(a.name), count = count, balance = bal, - onClick = { Navigator.push(Route.Ledger(a.id)) }, + onClick = { onEvent(HomeEvent.OpenAccount(a.id)) }, ) } } @@ -108,7 +115,6 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { @Composable private fun AccountCard( - id: String, name: String, initials: String, count: Int, @@ -123,7 +129,7 @@ private fun AccountCard( .clip(RoundedCornerShape(RadiusLg)) .background(t.surface) .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) - .semantics { contentDescription = "account_card:$id" } + .testTag("AccountCard") .clickable(role = Role.Button, onClick = onClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -145,6 +151,7 @@ private fun AccountCard( color = t.text, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("AccountName"), ) Text(txnLabel, style = Type.caption, color = t.textMuted) } @@ -152,7 +159,7 @@ private fun AccountCard( formatCents(balance), style = Type.bodyStrong, color = t.text, - modifier = Modifier.semantics { contentDescription = "account_balance" }, + modifier = Modifier.testTag("AccountBalance"), ) } } diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..c41d0a8 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt @@ -0,0 +1,56 @@ +package app.folio.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.folio.core.data.Account +import app.folio.core.data.Repository +import app.folio.core.data.Transaction +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class HomeUiState( + val user: String = "", + val accounts: List = emptyList(), + val transactions: List = emptyList(), +) + +sealed interface HomeEvent { + data object Logout : HomeEvent + data object AddAccount : HomeEvent + data class OpenAccount(val accountId: String) : HomeEvent +} + +@Inject +class HomeViewModel( + private val repository: Repository, + private val navigator: Navigator, +) : ViewModel() { + val state: StateFlow = combine( + repository.session, + repository.accounts, + repository.transactions, + ) { session, accounts, transactions -> + HomeUiState( + user = session?.user ?: "", + accounts = accounts, + transactions = transactions, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUiState()) + + fun onEvent(event: HomeEvent) { + when (event) { + HomeEvent.Logout -> viewModelScope.launch { + repository.clearSession() + navigator.replace(Route.Login) + } + HomeEvent.AddAccount -> navigator.push(Route.AddAccount) + is HomeEvent.OpenAccount -> navigator.push(Route.Ledger(event.accountId)) + } + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt new file mode 100644 index 0000000..e319cec --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt @@ -0,0 +1,120 @@ +package app.folio.feature.ledger + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.core.data.TxnType +import app.folio.di.LocalAppGraph +import app.folio.ui.BackHandler +import app.folio.ui.component.AppButton +import app.folio.ui.component.BackButton +import app.folio.ui.component.ButtonStyle +import app.folio.ui.component.EmptyState +import app.folio.ui.component.ErrorText +import app.folio.ui.component.FieldLabel +import app.folio.ui.component.Header +import app.folio.ui.component.Screen +import app.folio.ui.component.Segmented +import app.folio.ui.component.TextInput +import app.folio.ui.theme.Type + +@Composable +fun AddTransactionRoute(accountId: String) { + val graph = LocalAppGraph.current + val vm: AddTransactionViewModel = viewModel(key = "addTxn:$accountId") { + graph.addTransactionViewModelFactory.create(accountId) + } + val state by vm.state.collectAsState() + AddTransactionScreen(state = state, onEvent = vm::onEvent) +} + +@Composable +fun AddTransactionScreen(state: AddTransactionUiState, onEvent: (AddTransactionEvent) -> Unit) { + BackHandler { onEvent(AddTransactionEvent.Back) } + + val account = state.account + if (account == null) { + Screen( + header = { + Header(title = "Add transaction", left = { BackButton { onEvent(AddTransactionEvent.BackToHome) } }) + }, + ) { + EmptyState(title = "Account not found", subtitle = "") + AppButton( + text = "Back to accounts", + onClick = { onEvent(AddTransactionEvent.BackToHome) }, + style = ButtonStyle.Secondary, + ) + } + return + } + + Screen( + modifier = Modifier.testTag("AddTransactionScreen"), + header = { + Header( + title = "Add transaction", + subtitle = account.name, + left = { BackButton { onEvent(AddTransactionEvent.Back) } }, + ) + }, + footer = { + AppButton( + text = if (state.type == TxnType.credit) "Add credit" else "Add debit", + onClick = { onEvent(AddTransactionEvent.Submit) }, + style = ButtonStyle.Primary, + enabled = state.amount.isNotBlank(), + testTag = "TxnSubmit", + ) + }, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Segmented( + selected = if (state.type == TxnType.credit) 0 else 1, + labels = listOf("Credit", "Debit"), + onSelect = { + onEvent(AddTransactionEvent.TypeChange(if (it == 0) TxnType.credit else TxnType.debit)) + }, + testTags = listOf("TxnTypeCredit", "TxnTypeDebit"), + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + FieldLabel("Amount") + TextInput( + value = state.amount, + onChange = { onEvent(AddTransactionEvent.AmountChange(it)) }, + placeholder = "0.00", + invalid = state.error.isNotEmpty(), + keyboardType = KeyboardType.Decimal, + textAlign = TextAlign.Center, + textStyle = Type.amountInput, + label = "Amount", + testTag = "TxnAmountField", + ) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + FieldLabel("Note (optional)") + TextInput( + value = state.note, + onChange = { onEvent(AddTransactionEvent.NoteChange(it)) }, + placeholder = "What's this for?", + label = "Note", + testTag = "TxnNoteField", + ) + } + ErrorText(state.error) + } + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt new file mode 100644 index 0000000..60bc0aa --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt @@ -0,0 +1,97 @@ +package app.folio.feature.ledger + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.folio.core.data.Account +import app.folio.core.data.Repository +import app.folio.core.data.TxnType +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import app.folio.util.parseCents +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") + +data class AddTransactionUiState( + val account: Account? = null, + val type: TxnType = TxnType.credit, + val amount: String = "", + val note: String = "", + val error: String = "", +) + +sealed interface AddTransactionEvent { + data class TypeChange(val type: TxnType) : AddTransactionEvent + data class AmountChange(val value: String) : AddTransactionEvent + data class NoteChange(val value: String) : AddTransactionEvent + data object Submit : AddTransactionEvent + data object Back : AddTransactionEvent + data object BackToHome : AddTransactionEvent +} + +@AssistedInject +class AddTransactionViewModel( + private val repository: Repository, + private val navigator: Navigator, + @Assisted private val accountId: String, +) : ViewModel() { + private val form = MutableStateFlow(AddTransactionUiState()) + + val state: StateFlow = combine( + form, + repository.accounts.map { accounts -> accounts.firstOrNull { it.id == accountId } }, + ) { f, account -> f.copy(account = account) } + .stateIn(viewModelScope, SharingStarted.Eagerly, AddTransactionUiState()) + + fun onEvent(event: AddTransactionEvent) { + when (event) { + is AddTransactionEvent.TypeChange -> form.update { it.copy(type = event.type, error = "") } + is AddTransactionEvent.AmountChange -> { + val v = event.value + if (v.isEmpty() || AMOUNT_REGEX.matches(v)) { + form.update { it.copy(amount = v, error = "") } + } + } + is AddTransactionEvent.NoteChange -> form.update { it.copy(note = event.value.take(80), error = "") } + AddTransactionEvent.Back -> navigator.back(Route.Ledger(accountId)) + AddTransactionEvent.BackToHome -> navigator.replace(Route.Home) + AddTransactionEvent.Submit -> submit() + } + } + + private fun submit() { + val s = form.value + val cents = parseCents(s.amount) + if (cents == null) { + form.update { it.copy(error = "Enter a valid amount (e.g. 12.34)") } + return + } + if (cents <= 0) { + form.update { it.copy(error = "Amount must be greater than zero") } + return + } + viewModelScope.launch { + try { + repository.createTransaction(accountId, s.type, cents, s.note) + navigator.back(Route.Home) + } catch (e: IllegalArgumentException) { + form.update { it.copy(error = e.message ?: "Could not save transaction") } + } + } + } + + @AssistedFactory + fun interface Factory { + fun create(accountId: String): AddTransactionViewModel + } +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt similarity index 72% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt index f7f60df..1897d6e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt @@ -13,24 +13,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.data.TxnType -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.formatDate -import app.folio.platform.signedAmount +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.core.data.TxnType +import app.folio.di.LocalAppGraph +import app.folio.util.balanceOf +import app.folio.util.formatCents +import app.folio.util.formatDate import app.folio.ui.BackHandler import app.folio.ui.component.AppButton import app.folio.ui.component.BackButton @@ -45,50 +41,55 @@ import app.folio.ui.theme.RadiusLg import app.folio.ui.theme.Type @Composable -fun LedgerScreen(accountId: String) { +fun LedgerRoute(accountId: String) { + val graph = LocalAppGraph.current + val vm: LedgerViewModel = viewModel(key = "ledger:$accountId") { + graph.ledgerViewModelFactory.create(accountId) + } + val state by vm.state.collectAsState() + LedgerScreen(state = state, onEvent = vm::onEvent) +} + +@Composable +fun LedgerScreen(state: LedgerUiState, onEvent: (LedgerEvent) -> Unit) { val t = LocalTokens.current - val accounts by Repository.accounts.collectAsState() - val allTxns by Repository.transactions.collectAsState() - BackHandler { Navigator.back(Route.Home) } + BackHandler { onEvent(LedgerEvent.Back) } - val account = accounts.firstOrNull { it.id == accountId } + val account = state.account if (account == null) { Screen( header = { - Header(title = "Account", left = { BackButton { Navigator.back(Route.Home) } }) + Header(title = "Account", left = { BackButton { onEvent(LedgerEvent.Back) } }) }, ) { EmptyState(title = "Account not found", subtitle = "It may have been deleted.") AppButton( text = "Back to accounts", - onClick = { Navigator.replace(Route.Home) }, + onClick = { onEvent(LedgerEvent.BackToHome) }, style = ButtonStyle.Secondary, ) } return } - val txns = allTxns - .filter { it.accountId == accountId } - .sortedByDescending { it.createdAt } - val balance = balanceOf(txns) + val balance = balanceOf(state.transactions) Screen( - modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" }, + modifier = Modifier.testTag("LedgerScreen"), header = { Header( title = account.name, subtitle = "Ledger", - left = { BackButton { Navigator.back(Route.Home) } }, + left = { BackButton { onEvent(LedgerEvent.Back) } }, ) }, footer = { AppButton( text = "+ Add transaction", - onClick = { Navigator.push(Route.AddTransaction(accountId)) }, + onClick = { onEvent(LedgerEvent.AddTransaction) }, style = ButtonStyle.Primary, - description = "add_txn_button", + testTag = "AddTransactionButton", ) }, ) { @@ -98,7 +99,7 @@ fun LedgerScreen(accountId: String) { formatCents(balance), style = Type.balance, color = t.text, - modifier = Modifier.semantics { contentDescription = "ledger_balance_display" }, + modifier = Modifier.testTag("LedgerBalance"), ) } Text( @@ -107,7 +108,7 @@ fun LedgerScreen(accountId: String) { color = t.textFaint, modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp), ) - if (txns.isEmpty()) { + if (state.transactions.isEmpty()) { EmptyState( title = "No transactions yet", subtitle = "Add your first credit or debit to see it here.", @@ -122,9 +123,9 @@ fun LedgerScreen(accountId: String) { .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) .padding(horizontal = 16.dp), ) { - txns.forEachIndexed { i, txn -> - TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) - if (i != txns.lastIndex) { + state.transactions.forEachIndexed { i, txn -> + TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) + if (i != state.transactions.lastIndex) { Box(Modifier.fillMaxWidth().height(1.dp).background(t.border)) } } @@ -134,14 +135,14 @@ fun LedgerScreen(accountId: String) { } @Composable -private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: String) { +private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) { val t = LocalTokens.current val signed = if (type == TxnType.credit) amount else -amount Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 14.dp) - .semantics { contentDescription = "ledger_row:$id" }, + .testTag("LedgerRow"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -162,14 +163,15 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: color = t.text, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("TxnNote"), ) - Text(date, style = Type.caption, color = t.textFaint) + Text(date, style = Type.caption, color = t.textFaint, modifier = Modifier.testTag("TxnDate")) } Text( formatCents(signed, signed = true), style = Type.bodyStrong, color = t.text, - modifier = Modifier.semantics { contentDescription = "txn_amount" }, + modifier = Modifier.testTag("TxnAmount"), ) } } diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt new file mode 100644 index 0000000..e5c9bae --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt @@ -0,0 +1,61 @@ +package app.folio.feature.ledger + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.folio.core.data.Account +import app.folio.core.data.Repository +import app.folio.core.data.Transaction +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +data class LedgerUiState( + val account: Account? = null, + val transactions: List = emptyList(), + val accountId: String = "", +) + +sealed interface LedgerEvent { + data object Back : LedgerEvent + data object AddTransaction : LedgerEvent + data object BackToHome : LedgerEvent +} + +@AssistedInject +class LedgerViewModel( + private val repository: Repository, + private val navigator: Navigator, + @Assisted private val accountId: String, +) : ViewModel() { + val state: StateFlow = combine( + repository.accounts, + repository.transactions, + ) { accounts, txns -> + LedgerUiState( + account = accounts.firstOrNull { it.id == accountId }, + transactions = txns + .filter { it.accountId == accountId } + .sortedByDescending { it.createdAt }, + accountId = accountId, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, LedgerUiState(accountId = accountId)) + + fun onEvent(event: LedgerEvent) { + when (event) { + LedgerEvent.Back -> navigator.back(Route.Home) + LedgerEvent.AddTransaction -> navigator.push(Route.AddTransaction(accountId)) + LedgerEvent.BackToHome -> navigator.replace(Route.Home) + } + } + + @AssistedFactory + fun interface Factory { + fun create(accountId: String): LedgerViewModel + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt new file mode 100644 index 0000000..9d218da --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt @@ -0,0 +1,32 @@ +package app.folio.navigation + +import androidx.navigation.NavHostController + +class Navigator { + private var controller: NavHostController? = null + + fun attach(controller: NavHostController) { + this.controller = controller + } + + fun push(route: Route) { + controller?.navigate(route) { + launchSingleTop = true + } + } + + fun replace(route: Route) { + val nav = controller ?: return + nav.navigate(route) { + popUpTo(nav.graph.id) { inclusive = true } + launchSingleTop = true + } + } + + fun back(fallback: Route) { + val nav = controller ?: return + if (!nav.popBackStack()) { + replace(fallback) + } + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt new file mode 100644 index 0000000..73ce55e --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt @@ -0,0 +1,12 @@ +package app.folio.navigation + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Route { + @Serializable data object Login : Route + @Serializable data object Home : Route + @Serializable data object AddAccount : Route + @Serializable data class Ledger(val accountId: String) : Route + @Serializable data class AddTransaction(val accountId: String) : Route +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt new file mode 100644 index 0000000..1da4fa5 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +expect fun Modifier.testTagsAsResourceId(): Modifier diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt similarity index 95% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt index efbb4d1..d40c9d4 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt @@ -1,7 +1,7 @@ -package app.folio.platform +package app.folio.util -import app.folio.data.Transaction -import app.folio.data.TxnType +import app.folio.core.data.Transaction +import app.folio.core.data.TxnType import kotlin.math.absoluteValue private const val CURRENCY = "$" diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt similarity index 75% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt index 04a86e2..0f3cbeb 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt @@ -1,9 +1,11 @@ package app.folio import androidx.compose.ui.window.ComposeUIViewController -import app.folio.data.Repository +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph import app.folio.ui.IosBackGesture -import kotlin.native.ObjCName +import dev.zacsweers.metro.createGraphFactory import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ObjCAction @@ -28,13 +30,11 @@ private val backGestureTarget = BackGestureTarget() @OptIn(ExperimentalForeignApi::class) fun MainViewController(): UIViewController { - Repository.init() - app.folio.sanderling.AuthSnapshots - app.folio.sanderling.AccountSnapshots - app.folio.sanderling.LedgerSnapshots - app.folio.sanderling.NavigationSnapshots - app.folio.sanderling.SanderlingIos.start() - val vc = ComposeUIViewController { App() } + val driverFactory = DriverFactory() + val graphFactory = createGraphFactory() + val vc = ComposeUIViewController { + App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) }) + } val gesture = UIScreenEdgePanGestureRecognizer( target = backGestureTarget, action = NSSelectorFromString("handleGesture:"), diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt similarity index 100% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt index bb3dcab..aae594d 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt @@ -2,8 +2,8 @@ package app.folio.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState @Composable actual fun BackHandler(onBack: () -> Unit) { diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt similarity index 100% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt diff --git a/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt new file mode 100644 index 0000000..b0f567e --- /dev/null +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.testTagsAsResourceId(): Modifier = this diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt similarity index 94% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt index c78fc7b..fe6edcd 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt similarity index 100% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt rename to examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt diff --git a/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt new file mode 100644 index 0000000..b0f567e --- /dev/null +++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.testTagsAsResourceId(): Modifier = this diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt similarity index 97% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt rename to examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt index bc99afc..100fa81 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt +++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util private val MONTHS = arrayOf( "Jan", "Feb", "Mar", "Apr", "May", "Jun", diff --git a/examples/folio/app/ui-components/build.gradle.kts b/examples/folio/app/ui-components/build.gradle.kts new file mode 100644 index 0000000..558224e --- /dev/null +++ b/examples/folio/app/ui-components/build.gradle.kts @@ -0,0 +1,49 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.plugin.compose) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + api(compose.runtime) + api(compose.foundation) + api(compose.material3) + api(compose.ui) + } + } +} + +android { + namespace = "app.folio.ui" + compileSdk = 36 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt index d6012e4..0469542 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt @@ -13,9 +13,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.RadiusMd @@ -30,7 +29,7 @@ fun AppButton( modifier: Modifier = Modifier, style: ButtonStyle = ButtonStyle.Secondary, enabled: Boolean = true, - description: String? = null, + testTag: String? = null, ) { val t = LocalTokens.current val (bg, fg, border) = when (style) { @@ -44,10 +43,7 @@ fun AppButton( .clip(RoundedCornerShape(RadiusMd)) .background(if (enabled) bg else t.surface3) .border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd)) - .then( - if (description != null) Modifier.semantics { contentDescription = description } - else Modifier - ) + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(vertical = 14.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt similarity index 82% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt index 81624e7..dfc9c79 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -22,8 +23,9 @@ import app.folio.ui.theme.LocalTokens @Composable fun IconButton( onClick: () -> Unit, - description: String, + label: String, icon: ImageVector, + testTag: String? = null, ) { val t = LocalTokens.current Box( @@ -33,7 +35,8 @@ fun IconButton( .background(t.surface2) .border(1.dp, t.border, RoundedCornerShape(10.dp)) .clickable(onClick = onClick) - .semantics { contentDescription = description }, + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) + .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { Icon( @@ -47,5 +50,5 @@ fun IconButton( @Composable fun BackButton(onClick: () -> Unit) { - IconButton(onClick = onClick, description = "Back", icon = Icons.Back) + IconButton(onClick = onClick, label = "Back", icon = Icons.Back, testTag = "BackButton") } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt index 89a1ea3..7625279 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt @@ -14,8 +14,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -29,7 +29,7 @@ fun Segmented( selected: Int, labels: List, onSelect: (Int) -> Unit, - descriptions: List? = null, + testTags: List? = null, ) { val t = LocalTokens.current Row( @@ -43,16 +43,14 @@ fun Segmented( ) { labels.forEachIndexed { i, label -> val active = i == selected - val desc = descriptions?.getOrNull(i) + val tag = testTags?.getOrNull(i) Box( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(RadiusSm)) .background(if (active) t.surface3 else t.surface) - .semantics { - this.selected = active - if (desc != null) contentDescription = desc - } + .then(if (tag != null) Modifier.testTag(tag) else Modifier) + .semantics { this.selected = active } .clickable(role = Role.Tab) { onSelect(i) } .padding(vertical = 10.dp), contentAlignment = Alignment.Center, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt index a2ecdd3..eb595eb 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription @@ -29,7 +30,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import app.folio.FocusTracker import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.RadiusMd import app.folio.ui.theme.Type @@ -44,8 +44,8 @@ fun TextInput( keyboardType: KeyboardType = KeyboardType.Text, textAlign: TextAlign = TextAlign.Start, textStyle: TextStyle = Type.body, - label: String? = null, - description: String? = null, + label: String, + testTag: String? = null, modifier: Modifier = Modifier, ) { val t = LocalTokens.current @@ -78,16 +78,10 @@ fun TextInput( cursorBrush = SolidColor(t.text), modifier = Modifier .fillMaxWidth() - .onFocusChanged { - focused = it.isFocused - if (description != null) { - if (it.isFocused) FocusTracker.enter(description) - else FocusTracker.leave(description) - } - } + .onFocusChanged { focused = it.isFocused } + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) .semantics { - val desc = description ?: label - if (desc != null) contentDescription = desc + contentDescription = label if (invalid) stateDescription = "Invalid" }, ) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt diff --git a/examples/folio/app/webApp/build.gradle.kts b/examples/folio/app/webApp/build.gradle.kts new file mode 100644 index 0000000..edef4a6 --- /dev/null +++ b/examples/folio/app/webApp/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + outputModuleName.set("composeApp") + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + } + } + binaries.executable() + } + + sourceSets { + wasmJsMain.dependencies { + implementation(projects.app.shared) + implementation(projects.core) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.kotlinx.browser) + implementation(npm("@sqlite.org/sqlite-wasm", libs.versions.sqlite.wasm.get())) + } + } +} diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt b/examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt similarity index 53% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt rename to examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt index baaeb83..54cdadc 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt +++ b/examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt @@ -1,22 +1,33 @@ +@file:OptIn(ExperimentalComposeUiApi::class, kotlin.js.ExperimentalWasmJsInterop::class) + package app.folio import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -import app.folio.data.Repository +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph import app.folio.ui.WebBackGesture +import dev.zacsweers.metro.createGraphFactory import kotlinx.browser.document import kotlinx.browser.window import org.w3c.dom.events.Event -@OptIn(ExperimentalComposeUiApi::class) fun main() { - Repository.init() + val driverFactory = DriverFactory() + val graphFactory = createGraphFactory() + window.history.pushState(null, "", window.location.href) window.addEventListener("popstate", { _: Event -> if (WebBackGesture.dispatch()) { window.history.pushState(null, "", window.location.href) } }) + val target = document.getElementById("app") ?: document.body!! - ComposeViewport(target) { App() } + ComposeViewport(target) { + App(graphBuilder = { + graphFactory.create(LedgerDatabase(driverFactory.create())) + }) + } } diff --git a/examples/folio/app/webApp/src/wasmJsMain/resources/index.html b/examples/folio/app/webApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..6927ef9 --- /dev/null +++ b/examples/folio/app/webApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,16 @@ + + + + + + Folio + + + +
+ + + diff --git a/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js b/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js new file mode 100644 index 0000000..0fc0c69 --- /dev/null +++ b/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js @@ -0,0 +1,62 @@ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let db = null; + +async function init() { + const sqlite3 = await sqlite3InitModule({ + print: (msg) => console.log('[sqlite]', msg), + printErr: (msg) => console.error('[sqlite]', msg), + }); + + const pool = await sqlite3.installOpfsSAHPoolVfs({ + directory: '/folio-sqlite-pool', + clearOnInit: false, + initialCapacity: 6, + }); + db = new pool.OpfsSAHPoolDb('/folio.sqlite3'); + console.log('[sqlite] using OPFS SAH pool'); +} + +function execQuery(sql, params) { + const rows = db.exec({ + sql, + bind: params, + returnValue: 'resultRows', + }); + return { values: rows }; +} + +function dispatch(data) { + switch (data && data.action) { + case 'exec': + if (!data.sql) throw new Error('exec: missing sql'); + return execQuery(data.sql, data.params); + case 'begin_transaction': + db.exec('BEGIN TRANSACTION;'); + return { values: [] }; + case 'end_transaction': + db.exec('COMMIT;'); + return { values: [] }; + case 'rollback_transaction': + db.exec('ROLLBACK;'); + return { values: [] }; + default: + throw new Error('Unsupported action: ' + (data && data.action)); + } +} + +const ready = init(); + +self.onmessage = (event) => { + const data = event.data; + ready + .then(() => { + const results = dispatch(data); + self.postMessage({ id: data.id, results }); + }) + .catch((err) => { + const message = err && err.message ? String(err.message) : String(err); + console.error('[sqlite worker] error', message, err); + self.postMessage({ id: data && data.id, error: { message, name: err && err.name } }); + }); +}; diff --git a/examples/folio/app/webApp/webpack.config.d/coopcoep.js b/examples/folio/app/webApp/webpack.config.d/coopcoep.js new file mode 100644 index 0000000..759cf0b --- /dev/null +++ b/examples/folio/app/webApp/webpack.config.d/coopcoep.js @@ -0,0 +1,10 @@ +config.devServer = { + ...(config.devServer || {}), + port: process.env.WEBAPP_PORT ? Number(process.env.WEBAPP_PORT) : 8088, + headers: { + ...((config.devServer && config.devServer.headers) || {}), + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'cross-origin', + }, +}; diff --git a/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js b/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js new file mode 100644 index 0000000..412eaac --- /dev/null +++ b/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js @@ -0,0 +1,9 @@ +// Webpack picks up `new Worker(new URL("./sqlite.worker.js", import.meta.url), +// { type: "module" })` from the compiled Kotlin/wasm output and bundles the +// worker. The bundled worker imports `@sqlite.org/sqlite-wasm`, which in turn +// loads sqlite3.wasm via `new URL("sqlite3.wasm", import.meta.url)`. Enabling +// asyncWebAssembly lets webpack process that .wasm reference correctly. +config.experiments = { + ...(config.experiments || {}), + asyncWebAssembly: true, +}; diff --git a/examples/folio/build.gradle.kts b/examples/folio/build.gradle.kts index d3137ca..6f1b1cf 100644 --- a/examples/folio/build.gradle.kts +++ b/examples/folio/build.gradle.kts @@ -1,7 +1,11 @@ plugins { - id("com.android.application") version "8.13.2" apply false - id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false - id("org.jetbrains.compose") version "1.10.3" apply false - id("app.cash.sqldelight") version "2.3.2" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.plugin.compose) apply false + alias(libs.plugins.kotlin.plugin.serialization) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.metro) apply false } diff --git a/examples/folio/composeApp/build.gradle.kts b/examples/folio/composeApp/build.gradle.kts deleted file mode 100644 index 286730d..0000000 --- a/examples/folio/composeApp/build.gradle.kts +++ /dev/null @@ -1,129 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - id("com.android.application") - kotlin("multiplatform") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.compose") - id("app.cash.sqldelight") -} - -val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev" -val sqldelightVersion = "2.3.2" - -kotlin { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - optIn.add("kotlin.js.ExperimentalWasmJsInterop") - } - - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { target -> - target.binaries.framework { - baseName = "ComposeApp" - isStatic = true - binaryOption("bundleId", "app.folio") - } - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - outputModuleName.set("composeApp") - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - } - } - binaries.executable() - } - - applyHierarchyTemplate { - common { - group("sql") { - withAndroidTarget() - group("apple") { - group("ios") { - withIosX64() - withIosArm64() - withIosSimulatorArm64() - } - } - } - group("wasmJs") { - withWasmJs() - } - } - } - - sourceSets { - commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("app.cash.sqldelight:runtime:$sqldelightVersion") - implementation("app.cash.sqldelight:coroutines-extensions:$sqldelightVersion") - } - - androidMain.dependencies { - implementation("androidx.activity:activity-compose:1.13.0") - implementation("app.cash.sqldelight:android-driver:$sqldelightVersion") - } - - iosMain.dependencies { - implementation("app.cash.sqldelight:native-driver:$sqldelightVersion") - } - } -} - -android { - namespace = "app.folio" - compileSdk = 36 - - defaultConfig { - applicationId = "app.folio" - minSdk = 24 - targetSdk = 36 - versionCode = 1 - versionName = sanderlingVersion - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - buildTypes { - debug { - isDebuggable = true - } - } - - sourceSets["main"].apply { - manifest.srcFile("src/androidMain/AndroidManifest.xml") - res.srcDirs("src/androidMain/res") - } -} - -sqldelight { - databases { - create("LedgerDatabase") { - packageName.set("app.folio.db") - } - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt deleted file mode 100644 index f5bf413..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.folio - -import android.app.Application -import app.folio.data.AndroidLedgerContext -import app.folio.data.Repository - -class FolioApplication : Application() { - override fun onCreate() { - super.onCreate() - AndroidLedgerContext.context = applicationContext - Repository.init() - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt deleted file mode 100644 index e7b0fb6..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.folio - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { App() } - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt deleted file mode 100644 index 84c706f..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import android.content.Context - -object AndroidLedgerContext { - lateinit var context: Context -} - -actual fun createLedgerStore(): LedgerStore = - SqlLedgerStore(DriverFactory(AndroidLedgerContext.context)) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt deleted file mode 100644 index 19b8270..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.folio - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.feature.account.AddAccountScreen -import app.folio.feature.auth.LoginScreen -import app.folio.feature.home.HomeScreen -import app.folio.feature.ledger.AddTransactionScreen -import app.folio.feature.ledger.LedgerScreen -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.FocusTracker -import app.folio.ui.theme.LedgerTheme -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Tokens - -@Composable -fun App() { - val session by Repository.session.collectAsState() - val route by Navigator.current.collectAsState() - val focusedInput by FocusTracker.current.collectAsState() - - LaunchedEffect(session, route) { - if (session == null && route !is Route.Login) { - Navigator.replace(Route.Login) - } else if (session != null && route is Route.Login) { - Navigator.replace(Route.Home) - } - } - - LedgerTheme { - val t = Tokens() - CompositionLocalProvider(LocalTokens provides t) { - Box( - Modifier - .fillMaxSize() - .background(t.bg) - .windowInsetsPadding(WindowInsets.safeDrawing), - ) { - if (focusedInput != null) { - Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" }) - } - Column(Modifier.fillMaxSize()) { - when (val r = route) { - Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) }) - Route.Home -> HomeScreen( - user = session?.user ?: "", - onLogout = { - Repository.clearSession() - Navigator.replace(Route.Login) - }, - ) - Route.AddAccount -> AddAccountScreen() - is Route.Ledger -> LedgerScreen(accountId = r.accountId) - is Route.AddTransaction -> AddTransactionScreen(accountId = r.accountId) - } - } - } - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt deleted file mode 100644 index b0e464a..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.folio - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -// Assumes single-focus: only one input can be focused at a time, so -// enter() overwriting is safe. leave() is id-gated so a stale dispose -// from a previously-focused field cannot clobber the active focus. -object FocusTracker { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current.asStateFlow() - - fun enter(id: String) { - _current.value = id - } - - fun leave(id: String) { - if (_current.value == id) _current.value = null - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt deleted file mode 100644 index 29f4a0e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.folio.data - -import kotlinx.coroutines.flow.StateFlow - -interface LedgerStore { - val accounts: StateFlow> - val transactions: StateFlow> - val session: StateFlow - - fun accountExistsByName(name: String): Boolean - fun insertAccount(id: String, name: String, createdAt: Long) - fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) - fun upsertSession(user: String, loggedInAt: Long) - fun clearSession() -} - -expect fun createLedgerStore(): LedgerStore diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt deleted file mode 100644 index 3c78d4e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.account - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddAccountUiState { - val addAccountError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt deleted file mode 100644 index e2c40e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.auth - -import kotlinx.coroutines.flow.MutableStateFlow - -object LoginUiState { - val loginError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt deleted file mode 100644 index 714abfb..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt +++ /dev/null @@ -1,162 +0,0 @@ -package app.folio.feature.ledger - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.data.TxnType -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.parseCents -import app.folio.ui.BackHandler -import app.folio.ui.component.AppButton -import app.folio.ui.component.BackButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.EmptyState -import app.folio.ui.component.ErrorText -import app.folio.ui.component.FieldLabel -import app.folio.ui.component.Header -import app.folio.ui.component.Screen -import app.folio.ui.component.Segmented -import app.folio.ui.component.TextInput -import app.folio.ui.theme.Type - -private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") - -@Composable -fun AddTransactionScreen(accountId: String) { - val accounts by Repository.accounts.collectAsState() - val account = accounts.firstOrNull { it.id == accountId } - - BackHandler { Navigator.back(Route.Ledger(accountId)) } - - if (account == null) { - Screen( - header = { - Header(title = "Add transaction", left = { BackButton { Navigator.back(Route.Home) } }) - }, - ) { - EmptyState(title = "Account not found", subtitle = "") - AppButton( - text = "Back to accounts", - onClick = { Navigator.replace(Route.Home) }, - style = ButtonStyle.Secondary, - ) - } - return - } - - var type by remember { mutableStateOf(TxnType.credit) } - var amount by remember { mutableStateOf("") } - var note by remember { mutableStateOf("") } - val err by AddTransactionUiState.txnError.collectAsState() - - DisposableEffect(type) { - AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit" - onDispose { AddTransactionUiState.txnFormType.value = null } - } - - DisposableEffect(Unit) { - onDispose { AddTransactionUiState.txnError.value = "" } - } - - fun submit() { - val cents = parseCents(amount) - if (cents == null) { - AddTransactionUiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return - } - if (cents <= 0) { - AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return - } - try { - Repository.createTransaction(accountId, type, cents, note) - AddTransactionUiState.txnError.value = "" - Navigator.back(Route.Home) - } catch (e: IllegalArgumentException) { - AddTransactionUiState.txnError.value = e.message ?: "Could not save transaction" - } - } - - Screen( - modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" }, - header = { - Header( - title = "Add transaction", - subtitle = account.name, - left = { BackButton { Navigator.back(Route.Ledger(accountId)) } }, - ) - }, - footer = { - AppButton( - text = if (type == TxnType.credit) "Add credit" else "Add debit", - onClick = ::submit, - style = ButtonStyle.Primary, - enabled = amount.isNotBlank(), - description = "txn_submit", - ) - }, - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Segmented( - selected = if (type == TxnType.credit) 0 else 1, - labels = listOf("Credit", "Debit"), - onSelect = { - AddTransactionUiState.txnError.value = "" - type = if (it == 0) TxnType.credit else TxnType.debit - }, - descriptions = listOf("txn_credit", "txn_debit"), - ) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Amount") - TextInput( - value = amount, - onChange = { - AddTransactionUiState.txnError.value = "" - if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { - amount = it - } - }, - placeholder = "0.00", - invalid = err.isNotEmpty(), - keyboardType = KeyboardType.Decimal, - textAlign = TextAlign.Center, - textStyle = Type.amountInput, - label = "Amount", - description = "txn_amount", - ) - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Note (optional)") - TextInput( - value = note, - onChange = { - AddTransactionUiState.txnError.value = "" - note = it.take(80) - }, - placeholder = "What's this for?", - label = "Note", - description = "txn_note", - ) - } - ErrorText(err) - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt deleted file mode 100644 index 1c6289f..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.folio.feature.ledger - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddTransactionUiState { - val txnError = MutableStateFlow("") - val txnFormType = MutableStateFlow(null) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt deleted file mode 100644 index 841dc00..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.folio.navigation - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -object Navigator { - private val stack = ArrayDeque().apply { addLast(Route.Home) } - private val _current = MutableStateFlow(Route.Home) - val current: StateFlow = _current.asStateFlow() - - fun push(route: Route) { - stack.addLast(route) - _current.value = route - } - - fun replace(route: Route) { - stack.clear() - stack.addLast(route) - _current.value = route - } - - fun back(fallback: Route) { - if (stack.size > 1) { - stack.removeLast() - _current.value = stack.last() - } else { - replace(fallback) - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt deleted file mode 100644 index cb8a6e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.folio.navigation - -sealed interface Route { - data object Login : Route - data object Home : Route - data object AddAccount : Route - data class Ledger(val accountId: String) : Route - data class AddTransaction(val accountId: String) : Route -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt deleted file mode 100644 index 598ccfb..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver -import app.folio.db.LedgerDatabase - -actual class DriverFactory { - actual fun create(): SqlDriver = - NativeSqliteDriver(LedgerDatabase.Schema, "ledger.db") -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt deleted file mode 100644 index 594de08..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.folio.data - -actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory()) diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt deleted file mode 100644 index 3bbd0da..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt +++ /dev/null @@ -1,65 +0,0 @@ -package app.folio.data - -internal data class Snapshot( - val accounts: List, - val transactions: List, - val session: Session?, -) { - fun encode(): String = buildString { - for (a in accounts) { - append("A\t").append(esc(a.id)).append('\t').append(esc(a.name)).append('\t').append(a.createdAt).append('\n') - } - for (t in transactions) { - append("T\t").append(esc(t.id)).append('\t').append(esc(t.accountId)).append('\t') - .append(t.type.name).append('\t').append(t.amount).append('\t') - .append(esc(t.note)).append('\t').append(t.createdAt).append('\n') - } - session?.let { append("S\t").append(esc(it.user)).append('\t').append(it.loggedInAt).append('\n') } - } - - companion object { - fun decode(raw: String): Snapshot? { - val accounts = mutableListOf() - val transactions = mutableListOf() - var session: Session? = null - for (line in raw.split('\n')) { - if (line.isEmpty()) continue - val parts = line.split('\t') - when (parts[0]) { - "A" -> if (parts.size == 4) accounts += Account(unesc(parts[1]), unesc(parts[2]), parts[3].toLong()) - "T" -> if (parts.size == 7) transactions += Transaction( - id = unesc(parts[1]), - accountId = unesc(parts[2]), - type = TxnType.valueOf(parts[3]), - amount = parts[4].toLong(), - note = unesc(parts[5]), - createdAt = parts[6].toLong(), - ) - "S" -> if (parts.size == 3) session = Session(unesc(parts[1]), parts[2].toLong()) - } - } - return Snapshot(accounts, transactions, session) - } - - private fun esc(s: String) = s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n") - private fun unesc(s: String): String { - val out = StringBuilder(s.length) - var i = 0 - while (i < s.length) { - val c = s[i] - if (c == '\\' && i + 1 < s.length) { - when (s[i + 1]) { - 't' -> out.append('\t') - 'n' -> out.append('\n') - '\\' -> out.append('\\') - else -> out.append(s[i + 1]) - } - i += 2 - } else { - out.append(c); i++ - } - } - return out.toString() - } - } -} diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt deleted file mode 100644 index 0523721..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt +++ /dev/null @@ -1,60 +0,0 @@ -package app.folio.data - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.browser.localStorage - -private const val STORAGE_KEY = "sanderling.ledger.v1" - -class WebLedgerStore : LedgerStore { - private val _accounts = MutableStateFlow>(emptyList()) - private val _transactions = MutableStateFlow>(emptyList()) - private val _session = MutableStateFlow(null) - - override val accounts: StateFlow> = _accounts.asStateFlow() - override val transactions: StateFlow> = _transactions.asStateFlow() - override val session: StateFlow = _session.asStateFlow() - - init { - load() - } - - override fun accountExistsByName(name: String): Boolean = - _accounts.value.any { it.name.equals(name, ignoreCase = true) } - - override fun insertAccount(id: String, name: String, createdAt: Long) { - _accounts.value = _accounts.value + Account(id, name, createdAt) - save() - } - - override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) { - _transactions.value = _transactions.value + Transaction(id, accountId, type, amount, note, createdAt) - save() - } - - override fun upsertSession(user: String, loggedInAt: Long) { - _session.value = Session(user, loggedInAt) - save() - } - - override fun clearSession() { - _session.value = null - save() - } - - private fun load() { - val raw = localStorage.getItem(STORAGE_KEY) ?: return - val parsed = Snapshot.decode(raw) ?: return - _accounts.value = parsed.accounts - _transactions.value = parsed.transactions - _session.value = parsed.session - } - - private fun save() { - val snap = Snapshot(_accounts.value, _transactions.value, _session.value) - localStorage.setItem(STORAGE_KEY, snap.encode()) - } -} - -actual fun createLedgerStore(): LedgerStore = WebLedgerStore() diff --git a/examples/folio/composeApp/src/wasmJsMain/resources/index.html b/examples/folio/composeApp/src/wasmJsMain/resources/index.html deleted file mode 100644 index dfc1982..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/resources/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Folio - - - -
- - - diff --git a/examples/folio/core/build.gradle.kts b/examples/folio/core/build.gradle.kts new file mode 100644 index 0000000..375fba2 --- /dev/null +++ b/examples/folio/core/build.gradle.kts @@ -0,0 +1,68 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.sqldelight) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + applyDefaultHierarchyTemplate() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + api(libs.sqldelight.runtime) + api(libs.sqldelight.coroutines.extensions) + api(libs.sqldelight.async.extensions) + } + androidMain.dependencies { + implementation(libs.sqldelight.android.driver) + } + iosMain.dependencies { + implementation(libs.sqldelight.native.driver) + } + wasmJsMain.dependencies { + implementation(libs.sqldelight.web.worker.driver) + implementation(libs.kotlinx.browser) + } + } +} + +android { + namespace = "app.folio.core" + compileSdk = 36 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +sqldelight { + databases { + create("LedgerDatabase") { + packageName.set("app.folio.db") + generateAsync.set(true) + } + } +} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt similarity index 51% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt rename to examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt index ff19f55..cd3f674 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt +++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt @@ -1,11 +1,12 @@ -package app.folio.data +package app.folio.core.data import android.content.Context +import app.cash.sqldelight.async.coroutines.synchronous import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import app.folio.db.LedgerDatabase actual class DriverFactory(private val context: Context) { - actual fun create(): SqlDriver = - AndroidSqliteDriver(LedgerDatabase.Schema, context, "ledger.db") + actual suspend fun create(): SqlDriver = + AndroidSqliteDriver(LedgerDatabase.Schema.synchronous(), context, "ledger.db") } diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt similarity index 84% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt rename to examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt index 9adf3f6..adefad1 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt +++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import java.util.UUID diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt similarity index 76% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt index 5601094..ff29a77 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data data class Account( val id: String, diff --git a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt similarity index 53% rename from examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt index 50b6df4..07ee724 100644 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt @@ -1,7 +1,7 @@ -package app.folio.data +package app.folio.core.data import app.cash.sqldelight.db.SqlDriver expect class DriverFactory { - fun create(): SqlDriver + suspend fun create(): SqlDriver } diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt new file mode 100644 index 0000000..4fa54fa --- /dev/null +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt @@ -0,0 +1,15 @@ +package app.folio.core.data + +import kotlinx.coroutines.flow.StateFlow + +interface LedgerStore { + val accounts: StateFlow> + val transactions: StateFlow> + val session: StateFlow + + suspend fun accountExistsByName(name: String): Boolean + suspend fun insertAccount(id: String, name: String, createdAt: Long) + suspend fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) + suspend fun upsertSession(user: String, loggedInAt: Long) + suspend fun clearSession() +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt similarity index 58% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt index 38293c3..020407e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt @@ -1,22 +1,19 @@ -package app.folio.data +package app.folio.core.data -import app.folio.platform.Platform +import app.folio.core.platform.Platform +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.StateFlow -object Repository { - private var _store: LedgerStore? = null - private val store: LedgerStore - get() = _store ?: error("Repository not initialized") +@SingleIn(AppScope::class) +@Inject +class Repository(private val store: LedgerStore) { + val accounts: StateFlow> = store.accounts + val transactions: StateFlow> = store.transactions + val session: StateFlow = store.session - val accounts: StateFlow> get() = store.accounts - val transactions: StateFlow> get() = store.transactions - val session: StateFlow get() = store.session - - fun init() { - if (_store == null) _store = createLedgerStore() - } - - fun createAccount(name: String): Account { + suspend fun createAccount(name: String): Account { val trimmed = name.trim() require(trimmed.isNotEmpty()) { "Name is required" } require(trimmed.length <= 40) { "Name is too long (max 40)" } @@ -28,7 +25,7 @@ object Repository { fun getAccount(id: String): Account? = accounts.value.firstOrNull { it.id == id } - fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction { + suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction { require(amount > 0) { "Amount must be greater than zero" } requireNotNull(getAccount(accountId)) { "Account not found" } val txn = Transaction( @@ -43,7 +40,6 @@ object Repository { return txn } - fun setSession(user: String) = store.upsertSession(user, Platform.now()) - - fun clearSession() = store.clearSession() + suspend fun setSession(user: String) = store.upsertSession(user, Platform.now()) + suspend fun clearSession() = store.clearSession() } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt similarity index 71% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt index 0e0929f..d3bae1c 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data data class Session( val user: String, diff --git a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt similarity index 56% rename from examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt index 7357c76..5720b71 100644 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt @@ -1,9 +1,13 @@ -package app.folio.data +package app.folio.core.data +import app.cash.sqldelight.async.coroutines.awaitAsOne import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOneOrNull import app.folio.db.LedgerDatabase +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,49 +16,50 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -class SqlLedgerStore(factory: DriverFactory) : LedgerStore { - private val db = LedgerDatabase(factory.create()) - private val q = db.ledgerQueries +@SingleIn(AppScope::class) +@Inject +class SqlLedgerStore(db: LedgerDatabase) : LedgerStore { + private val queries = db.ledgerQueries private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override val accounts: StateFlow> = - q.selectAllAccounts() + queries.selectAllAccounts() .asFlow() .mapToList(Dispatchers.Default) .map { rows -> rows.map { Account(it.id, it.name, it.createdAt) } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) override val transactions: StateFlow> = - q.selectAllTxns() + queries.selectAllTxns() .asFlow() .mapToList(Dispatchers.Default) .map { rows -> rows.map { it.toDomain() } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) override val session: StateFlow = - q.selectSession() + queries.selectSession() .asFlow() .mapToOneOrNull(Dispatchers.Default) - .map { it?.let { row -> Session(row.user, row.loggedInAt) } } + .map { row -> row?.let { Session(it.user, it.loggedInAt) } } .stateIn(scope, SharingStarted.Eagerly, null) - override fun accountExistsByName(name: String): Boolean = - q.accountExistsByName(name).executeAsOne() + override suspend fun accountExistsByName(name: String): Boolean = + queries.accountExistsByName(name).awaitAsOne() - override fun insertAccount(id: String, name: String, createdAt: Long) { - q.insertAccount(id, name, createdAt) + override suspend fun insertAccount(id: String, name: String, createdAt: Long) { + queries.insertAccount(id, name, createdAt) } - override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) { - q.insertTxn(id, accountId, type.name, amount, note, createdAt) + override suspend fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) { + queries.insertTxn(id, accountId, type.name, amount, note, createdAt) } - override fun upsertSession(user: String, loggedInAt: Long) { - q.upsertSession(user, loggedInAt) + override suspend fun upsertSession(user: String, loggedInAt: Long) { + queries.upsertSession(user, loggedInAt) } - override fun clearSession() { - q.clearSession() + override suspend fun clearSession() { + queries.clearSession() } } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt similarity index 87% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt index c9f1a54..33162ad 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data enum class TxnType { credit, debit } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt similarity index 69% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt index dc4e3ce..5d0650e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform expect object Platform { fun now(): Long diff --git a/examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq b/examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq similarity index 100% rename from examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq rename to examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq diff --git a/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt b/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt new file mode 100644 index 0000000..5633774 --- /dev/null +++ b/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt @@ -0,0 +1,11 @@ +package app.folio.core.data + +import app.cash.sqldelight.async.coroutines.synchronous +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver +import app.folio.db.LedgerDatabase + +actual class DriverFactory { + actual suspend fun create(): SqlDriver = + NativeSqliteDriver(LedgerDatabase.Schema.synchronous(), "ledger.db") +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt similarity index 89% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt rename to examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt index 6276e14..f4a0f37 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt +++ b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import platform.Foundation.NSDate import platform.Foundation.NSUUID diff --git a/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt new file mode 100644 index 0000000..3679cbc --- /dev/null +++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt @@ -0,0 +1,70 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +package app.folio.core.data + +import app.cash.sqldelight.async.coroutines.awaitCreate +import app.cash.sqldelight.async.coroutines.awaitMigrate +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.worker.WebWorkerDriver +import app.folio.db.LedgerDatabase +import org.w3c.dom.Worker + +actual class DriverFactory { + actual suspend fun create(): SqlDriver { + val driver = WebWorkerDriver(createSqliteWorker()) + val schema = LedgerDatabase.Schema + val current = readUserVersion(driver) + val target = schema.version + when { + current == 0L && !schemaExists(driver) -> { + schema.awaitCreate(driver) + writeUserVersion(driver, target) + } + current == 0L -> writeUserVersion(driver, target) + current < target -> { + schema.awaitMigrate(driver, current, target) + writeUserVersion(driver, target) + } + } + return driver + } +} + +private suspend fun readUserVersion(driver: SqlDriver): Long { + var version = 0L + driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + app.cash.sqldelight.db.QueryResult.AsyncValue { + if (cursor.next().await()) version = cursor.getLong(0) ?: 0L + Unit + } + }, + parameters = 0, + ).await() + return version +} + +private suspend fun writeUserVersion(driver: SqlDriver, version: Long) { + driver.execute(null, "PRAGMA user_version = $version", 0).await() +} + +private suspend fun schemaExists(driver: SqlDriver): Boolean { + var exists = false + driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1", + mapper = { cursor -> + app.cash.sqldelight.db.QueryResult.AsyncValue { + exists = cursor.next().await() + Unit + } + }, + parameters = 0, + ).await() + return exists +} + +private fun createSqliteWorker(): Worker = + js("""new Worker(new URL("./sqlite.worker.js", import.meta.url), { type: "module" })""") diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt similarity index 96% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt rename to examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt index 467b5d8..76d3685 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt +++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import kotlin.random.Random diff --git a/examples/folio/gradle/libs.versions.toml b/examples/folio/gradle/libs.versions.toml new file mode 100644 index 0000000..5e4f40b --- /dev/null +++ b/examples/folio/gradle/libs.versions.toml @@ -0,0 +1,42 @@ +[versions] +kotlin = "2.3.20" +agp = "8.13.2" +compose-multiplatform = "1.10.3" +sqldelight = "2.3.2" +kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.9.0" +lifecycle-viewmodel-compose = "2.10.0" +activity-compose = "1.13.0" +metro = "1.0.0-RC4" +navigation-compose = "2.9.2" +kotlinx-browser = "0.5.0" +sqlite-wasm = "3.53.0-build1" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-async-extensions = { module = "app.cash.sqldelight:async-extensions", version.ref = "sqldelight" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } +sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" } + +lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" } +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } + +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } + +kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +metro = { id = "dev.zacsweers.metro", version.ref = "metro" } diff --git a/examples/folio/justfile b/examples/folio/justfile index 263e8e0..98f8321 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -40,14 +40,14 @@ install: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:installDebug + ./gradlew :app:androidApp:installDebug # Remove the folio APK from the connected device. uninstall: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:uninstallDebug + ./gradlew :app:androidApp:uninstallDebug # Remove gradle + iOS build directories. clean: @@ -55,13 +55,13 @@ clean: set -euo pipefail export ANDROID_HOME="$(just _android-home)" ./gradlew clean - rm -rf iosApp/build + rm -rf app/iosApp/build # Regenerate iosApp.xcodeproj from project.yml. ios-gen: #!/usr/bin/env bash set -euo pipefail - cd iosApp && xcodegen generate + cd app/iosApp && xcodegen generate # Build + install + launch on a booted iOS simulator (boots IOS_DEVICE if none). ios: @@ -74,11 +74,11 @@ ios: sleep 3 fi just ios-gen - xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \ -destination 'platform=iOS Simulator,name={{ios_device}}' \ - -derivedDataPath iosApp/build \ + -derivedDataPath app/iosApp/build \ build | tail -5 - xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app + xcrun simctl install booted app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app xcrun simctl launch booted app.folio # Run 'sanderling test' against the folio app. Uses a connected device if one is @@ -98,6 +98,20 @@ test: --seed "{{seed}}" \ --output "{{output}}" +# Serve the wasmJs web app from a webpack dev server with COOP/COEP headers. +web: + #!/usr/bin/env bash + set -euo pipefail + export ANDROID_HOME="$(just _android-home)" + ./gradlew :app:webApp:wasmJsBrowserDevelopmentRun --continuous + +# Produce a webpack distributable bundle for the web app. +web-build: + #!/usr/bin/env bash + set -euo pipefail + export ANDROID_HOME="$(just _android-home)" + ./gradlew :app:webApp:wasmJsBrowserDevelopmentExecutableDistribution + # Build + install + run sanderling spec on iOS simulator. test-ios: #!/usr/bin/env bash diff --git a/examples/folio/kotlin-js-store/wasm/yarn.lock b/examples/folio/kotlin-js-store/wasm/yarn.lock index 5f4567d..bb54683 100644 --- a/examples/folio/kotlin-js-store/wasm/yarn.lock +++ b/examples/folio/kotlin-js-store/wasm/yarn.lock @@ -6,3 +6,8 @@ version "3.2.0" resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +"@sqlite.org/sqlite-wasm@3.53.0-build1": + version "3.53.0-build1" + resolved "https://registry.yarnpkg.com/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.53.0-build1.tgz#066cab9189973c39edbb7078c55bb4daa1cd2d30" + integrity sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g== diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index f7d2af3..d08f056 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -5,23 +5,24 @@ import { always, extract, from, + keyedBy, next, now, weighted, + whenRoute, } from "@sanderling/spec"; interface Account { - id: string; + name: string; balance: number; } interface LedgerRow { - id: string; + key: string; signed: number; } // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. -// formatCents always uses $ prefix, . as decimal, , as thousands separator. function parseDollarCents(text: string | undefined): number { if (!text) return 0; const sign = text.startsWith("-") ? -1 : 1; @@ -29,89 +30,80 @@ function parseDollarCents(text: string | undefined): number { return digits ? sign * parseInt(digits, 10) : 0; } -// Route and auth state derived from screen root nodes -const loggedIn = extract(s => s.ax.find({ accessibilityText: "LoginScreen" }) == null); +// Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS) +const loggedIn = extract(s => s.ax.find({ testTag: "LoginScreen" }) == null); const route = extract(s => { - if (s.ax.find({ accessibilityText: "LoginScreen" })) return "login"; - if (s.ax.find({ accessibilityText: "HomeScreen" })) return "home"; - if (s.ax.find({ accessibilityText: "AddAccountScreen" })) return "add-account"; - if (s.ax.find({ accessibilityText: "LedgerScreen" })) return "ledger"; - if (s.ax.find({ accessibilityText: "AddTransactionScreen" })) return "add-transaction"; + if (s.ax.find({ testTag: "LoginScreen" })) return "login"; + if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account"; + if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction"; + if (s.ax.find({ testTag: "LedgerScreen" })) return "ledger"; + if (s.ax.find({ testTag: "HomeScreen" })) return "home"; return null; }); -// All element lookups scoped through their screen root -const accounts = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") - .map(el => ({ - id: el.desc?.split(":")[1] ?? "", - balance: parseDollarCents(el.find({ accessibilityText: "account_balance" })?.text), - })) ?? []); -const ledgerRows = extract(s => - s.ax.find({ accessibilityText: "LedgerScreen" })?.findAll("descPrefix:ledger_row:") - .map(el => ({ - id: el.desc?.split(":")[1] ?? "", - signed: parseDollarCents(el.find({ accessibilityText: "txn_amount" })?.text), - })) ?? []); -const ledgerBalance = extract(s => - parseDollarCents( - s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "ledger_balance_display" })?.text - )); -const activeAccountId = extract(s => { - const desc = s.ax.find("descPrefix:LedgerScreen:")?.desc; - return desc ? (desc.split(":")[1] ?? null) : null; -}); +// Account cards on Home: identity is the AccountName text; balance comes from AccountBalance. +const accounts = extract(s => + s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({ + name: card.find({ testTag: "AccountName" })?.text ?? "", + balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text), + }))); -// focusedInput lives in the app root (not inside any screen), so unscoped -const focusedInput = extract(s => - s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null); +// Ledger rows: identity composed from the row's stable testTag'd cells. +const ledgerRows = extract(s => + s.ax.findAll([{ testTag: "LedgerScreen" }, { testTag: "LedgerRow" }]).map(row => ({ + key: keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), + signed: parseDollarCents(row.find({ testTag: "TxnAmount" })?.text), + }))); + +const ledgerBalance = extract(s => + parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text)); + +const focusedFieldTag = extract(s => s.ax.find({ focused: "true" })?.id ?? null); const loginEmailField = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_email" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])); const loginPasswordField = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_password" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }])); const loginSubmit = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_submit" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }])); const addAccountButton = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.find({ accessibilityText: "add_account_button" })); + s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }])); const accountNameField = extract(s => - s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "account_name_field" })); + s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }])); const addAccountSubmit = extract(s => - s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "add_account_submit" })); + s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }])); const addTxnButton = extract(s => - s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "add_txn_button" })); + s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }])); const txnAmountField = extract(s => - s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_amount" })); + s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }])); const txnSubmit = extract(s => - s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_submit" })); + s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }])); const accountCards = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") ?? []); -const backButton = extract(s => s.ax.find("desc:Back")); + s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])); +const backButton = extract(s => s.ax.find({ testTag: "BackButton" })); -// Property 1: every new account starts with balance === 0 -// Guard: only check when accounts were visible in the previous step too. -// Without this, navigating away from HomeScreen (accounts=[]) then back -// makes every account look "new", causing false positives on pre-existing balances. +// Property 1: every newly-appearing account starts with balance === 0. +// Identity is by visible name. Guard against navigation transitions where +// accounts vanish from the visible tree. const newAccountBalanceIsZero = always( next(() => { const prev = accounts.previous ?? []; const curr = accounts.current; if (prev.length === 0 || curr.length === 0) return true; - const prevIds = new Set(prev.map(a => a.id)); - const newAccounts = curr.filter(a => !prevIds.has(a.id)); - return newAccounts.every(a => a.balance === 0); + const prevNames = new Set(prev.map(a => a.name)); + return curr.filter(a => !prevNames.has(a.name)).every(a => a.balance === 0); }) ); -// Property 2: every new transaction changes the account ledger balance by exactly its signed amount +// Property 2: a newly-added ledger row changes the ledger balance by exactly its signed amount. const newTxnChangesBalance = always( - now(() => activeAccountId.current !== null).implies( + now(() => route.current === "ledger").implies( next(() => { - const prevRows = ledgerRows.previous ?? []; - const curRows = ledgerRows.current; - if (curRows.length !== prevRows.length + 1) return true; - const prevIds = new Set(prevRows.map(r => r.id)); - const added = curRows.find(r => !prevIds.has(r.id)); + const prev = ledgerRows.previous ?? []; + const curr = ledgerRows.current; + if (curr.length !== prev.length + 1) return true; + const prevKeys = new Set(prev.map(r => r.key)); + const added = curr.find(r => !prevKeys.has(r.key)); if (!added) return true; const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); return delta === added.signed && delta !== 0; @@ -122,15 +114,15 @@ const newTxnChangesBalance = always( const DEMO_EMAIL = "demo@folio.app"; const DEMO_PASSWORD = "ledger123"; -// Login if not already in - step by step based on which field has focus +// Login: drive the form via focus state read from the native focused="true" attr. const login = actions(() => { if (loggedIn.current) return []; - const focus = focusedInput.current; - if (focus === "login_password") { + const focus = focusedFieldTag.current; + if (focus === "LoginPassword") { const submit = loginSubmit.current; return submit ? [Tap({ on: submit })] : []; } - if (focus === "login_email") { + if (focus === "LoginEmail") { const pwd = loginPasswordField.current; return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : []; } @@ -140,47 +132,37 @@ const login = actions(() => { const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]); -// Add an account: home -> tap add -> type name -> submit -const addAccount = actions(() => { - if (!loggedIn.current) return []; +const addAccount = whenRoute(route, ["home", "add-account"], () => { if (route.current === "home") { const btn = addAccountButton.current; return btn ? [Tap({ on: btn })] : []; } - if (route.current === "add-account") { - const field = accountNameField.current; - const submit = addAccountSubmit.current; - const opts = []; - if (field) opts.push(InputText({ into: field, text: accountNames.generate() })); - if (submit) opts.push(Tap({ on: submit })); - return opts; - } - return []; + const field = accountNameField.current; + const submit = addAccountSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: accountNames.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; }); const amounts = from(["10", "50", "25", "100", "5"]); -// Add a transaction: home -> tap account card -> tap add txn -> type amount -> submit -const addTxn = actions(() => { - if (!loggedIn.current) return []; +const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => { if (route.current === "home") { const cards = accountCards.current; if (cards.length === 0) return []; - return [Tap({ on: cards[Math.floor(Math.random() * cards.length)] })]; + return [Tap({ on: from(cards).generate() })]; } if (route.current === "ledger") { const btn = addTxnButton.current; return btn ? [Tap({ on: btn })] : []; } - if (route.current === "add-transaction") { - const field = txnAmountField.current; - const submit = txnSubmit.current; - const opts = []; - if (field) opts.push(InputText({ into: field, text: amounts.generate() })); - if (submit) opts.push(Tap({ on: submit })); - return opts; - } - return []; + const field = txnAmountField.current; + const submit = txnSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: amounts.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; }); const back = actions(() => { @@ -193,12 +175,14 @@ export const properties = { newTxnChangesBalance, }; +export const setup = login; + export const actionsRoot = weighted( - [50, login], - [30, addAccount], - [30, addTxn], - [5, back], + [50, addAccount], + [40, addTxn], + [10, back], ); -(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; +(globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot; (globalThis as { properties?: unknown }).properties = properties; +(globalThis as { setup?: unknown }).setup = setup; diff --git a/examples/folio/settings.gradle.kts b/examples/folio/settings.gradle.kts index 2f2b992..253dd56 100644 --- a/examples/folio/settings.gradle.kts +++ b/examples/folio/settings.gradle.kts @@ -15,6 +15,12 @@ dependencyResolutionManagement { } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "folio" -include(":composeApp") +include(":core") +include(":app:shared") +include(":app:ui-components") +include(":app:androidApp") +include(":app:webApp") diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 78ee995..56c1511 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -18,12 +18,15 @@ // // Cross-platform aliases are expanded automatically: "label" / "accessibilityLabel" // resolve to accessibilityText; "content-desc" also checks accessibilityText and -// vice-versa; "identifier" / "accessibilityIdentifier" resolve to resource-id. +// vice-versa; "identifier" / "accessibilityIdentifier" / "testTag" resolve to +// resource-id (and to each other) so a Compose testTag matches whether the +// underlying platform exposes it as resource-id (Android) or accessibilityIdentifier (iOS). package hierarchy import ( "encoding/json" "fmt" + "maps" "regexp" "strconv" "strings" @@ -113,10 +116,12 @@ var attributeAliases = map[string][]string{ // accessibilityText is the canonical key; also check content-desc for Android/web "accessibilityText": {"content-desc"}, // resource-id canonical key; also check identifier (iOS AXElement raw field) - "resource-id": {"identifier"}, + "resource-id": {"identifier", "accessibilityIdentifier"}, // iOS identifier names - "identifier": {"resource-id"}, - "accessibilityIdentifier": {"resource-id"}, + "identifier": {"resource-id", "accessibilityIdentifier"}, + "accessibilityIdentifier": {"resource-id", "identifier"}, + // Compose testTag surfaces as resource-id on Android, accessibilityIdentifier on iOS + "testTag": {"resource-id", "identifier", "accessibilityIdentifier"}, // iOS AXElement raw name for hintText "placeholderValue": {"hintText"}, // iOS AXElement raw name for class @@ -189,6 +194,9 @@ func elementFromNode(node *treeNodeJSON) *Element { if element.ResourceID == "" { element.ResourceID = attrs["identifier"] } + if element.ResourceID == "" { + element.ResourceID = attrs["accessibilityIdentifier"] + } element.Text = attrs["text"] element.Description = attrs["content-desc"] if element.Description == "" { @@ -222,9 +230,7 @@ func elementFromNode(node *treeNodeJSON) *Element { } element.Attributes = make(map[string]string, len(attrs)+5) - for k, v := range attrs { - element.Attributes[k] = v - } + maps.Copy(element.Attributes, attrs) if node.Clickable != nil { element.Attributes["clickable"] = strconv.FormatBool(*node.Clickable) } @@ -291,6 +297,22 @@ func (t *Tree) FindAllNodes(selector string) []*Node { return searchSubtree(t.Root, kind, value) } +// FindBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindBySelectorPath(path []Selector) *Node { + if t == nil || t.Root == nil { + return nil + } + return t.Root.FindBySelectorPath(path) +} + +// FindAllBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node { + if t == nil || t.Root == nil { + return nil + } + return t.Root.FindAllBySelectorPath(path) +} + // Find returns the first Node in this node's subtree (descendants only) matching // the string selector. Path queries within the selector are not supported here. func (n *Node) Find(selector string) *Node { @@ -339,6 +361,45 @@ func (n *Node) FindAllBySelector(sel Selector) []*Node { return result } +// FindBySelectorPath walks a chain of selectors. The first selector is matched +// against descendants of the receiver; each subsequent selector is matched +// against descendants of the previous match. Returns the deepest match or nil. +func (n *Node) FindBySelectorPath(path []Selector) *Node { + if len(path) == 0 { + return nil + } + for _, child := range n.Children { + for _, candidate := range searchSubtreeBySelector(child, path[0]) { + if len(path) == 1 { + return candidate + } + if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil { + return deeper + } + } + } + return nil +} + +// FindAllBySelectorPath returns every deepest match for the selector chain +// scoped under the receiver. +func (n *Node) FindAllBySelectorPath(path []Selector) []*Node { + if len(path) == 0 { + return nil + } + var result []*Node + for _, child := range n.Children { + for _, candidate := range searchSubtreeBySelector(child, path[0]) { + if len(path) == 1 { + result = append(result, candidate) + continue + } + result = append(result, candidate.FindAllBySelectorPath(path[1:])...) + } + } + return result +} + func findPathNode(root *Node, segments []string) *Node { if root == nil || len(segments) == 0 { return nil diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index ae896d1..e016a30 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -464,6 +464,157 @@ func TestNodeFindScopedSearch(t *testing.T) { } } +func TestTestTagAliasMatchesResourceIDAndroid(t *testing.T) { + input := `{ + "attributes": {"resource-id": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + if len(searchSubtreeBySelector(tree.Root, sel)) == 0 { + t.Fatal("expected testTag selector to match resource-id on Android") + } +} + +func TestTestTagAliasMatchesAccessibilityIdentifierIOS(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + matches := searchSubtreeBySelector(tree.Root, sel) + if len(matches) == 0 { + t.Fatal("expected testTag selector to match accessibilityIdentifier on iOS") + } +} + +func TestTestTagAliasMatchesIdentifierIOSRaw(t *testing.T) { + input := `{ + "attributes": {"identifier": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + matches := searchSubtreeBySelector(tree.Root, sel) + if len(matches) == 0 { + t.Fatal("expected testTag selector to match identifier on iOS raw AXElement") + } +} + +func TestResourceIDFallsBackToAccessibilityIdentifier(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "MyButton", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "resource-id", Value: "MyButton"}}} + if len(searchSubtreeBySelector(tree.Root, sel)) == 0 { + t.Fatal("expected resource-id selector to fall back to accessibilityIdentifier") + } +} + +func TestElementResourceIDPopulatesFromAccessibilityIdentifier(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "LoginEmail", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + if len(tree.Elements) == 0 { + t.Fatal("no elements parsed") + } + if got := tree.Elements[0].ResourceID; got != "LoginEmail" { + t.Fatalf("ResourceID = %q, want LoginEmail (iOS Compose accessibilityIdentifier path)", got) + } +} + +const selectorPathDump = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + }, + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,200,540,400]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Savings", "bounds": "[10,210,200,240]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Travel", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] +}` + +func TestFindBySelectorPathSingleSegment(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}} + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen") + } + if got := node.Element.Attributes["testTag"]; got != "HomeScreen" { + t.Fatalf("testTag = %q, want HomeScreen", got) + } +} + +func TestFindBySelectorPathScopedDescent(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen > AccountCard > AccountName") + } + if node.Element.Text != "Checking" { + t.Fatalf("text = %q, want Checking", node.Element.Text) + } +} + +func TestFindBySelectorPathRespectsScope(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "LedgerScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + } + if node := tree.FindBySelectorPath(path); node != nil { + t.Fatalf("AccountCard is under HomeScreen only, expected nil, got %+v", node.Element) + } +} + +func TestFindAllBySelectorPathReturnsAllDeepestMatches(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + matches := tree.FindAllBySelectorPath(path) + if len(matches) != 2 { + t.Fatalf("want 2 matches (Checking, Savings), got %d", len(matches)) + } +} + +func TestFindBySelectorPathEmptyPathReturnsNil(t *testing.T) { + tree, _ := Parse(selectorPathDump) + if tree.FindBySelectorPath(nil) != nil { + t.Fatal("empty path should return nil") + } +} + func TestNodeFindDoesNotReturnSiblings(t *testing.T) { tree, _ := Parse(pathDump) a2Node := tree.FindNode("id:A2") diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index 1a37e68..fa1b7e9 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -7,6 +7,58 @@ import ( "github.com/priyanshujain/sanderling/internal/hierarchy" ) +// TestStateAxObjectSelectorTestTagAlias verifies that an object selector +// `{ testTag: "X" }` resolves through the testTag alias to match an element +// whose source attributes carry resource-id="X" (the Compose +// testTagsAsResourceId=true case on Android). +func TestStateAxObjectSelectorTestTagAlias(t *testing.T) { + src := `{ + "attributes": {"class": "android.widget.LinearLayout"}, + "children": [ + { + "attributes": {"resource-id": "LoginScreen", "class": "android.view.View"}, + "children": [ + { + "attributes": {"resource-id": "LoginEmail", "class": "android.widget.EditText"}, + "children": [] + } + ] + } + ] + }` + tree, err := hierarchy.Parse(src) + if err != nil { + t.Fatal(err) + } + + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.loginRoot = __sanderling__.extract(state => { + const r = state.ax.find({ testTag: "LoginScreen" }); + return r ? "matched" : "miss"; + }); + globalThis.loginEmailViaChain = __sanderling__.extract(state => { + const r = state.ax.find({ testTag: "LoginScreen" }); + if (!r) return "outer-miss"; + const inner = r.find({ testTag: "LoginEmail" }); + return inner ? "inner-matched" : "inner-miss"; + }); + `) + + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + + root := verifier.runtime.GlobalObject().Get("loginRoot").ToObject(verifier.runtime).Get("current").String() + if root != "matched" { + t.Fatalf("loginRoot = %q, want matched", root) + } + chain := verifier.runtime.GlobalObject().Get("loginEmailViaChain").ToObject(verifier.runtime).Get("current").String() + if chain != "inner-matched" { + t.Fatalf("loginEmailViaChain = %q, want inner-matched", chain) + } +} + // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip // actually lets the spec resolve selectors through state.ax.find. // Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent. diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index f8facbe..6d67b0e 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -14,9 +14,10 @@ type extractorState struct { type formulaState struct { predicate goja.Callable - // err latches the first goja error returned by predicate. The thunk - // returns false on error so the LTL evaluator marks the property - // violated; PredicateError surfaces the underlying cause. + // err holds the goja error from this thunk's most recent invocation, or + // nil if the latest call succeeded. The thunk returns false on error so + // the LTL evaluator marks the property violated; PredicateError surfaces + // the underlying cause for the current step. err error } diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 6d15f45..7055531 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -136,7 +136,8 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go return object } -// findNodeFromJS dispatches a JS value (string or object) to Tree-level node lookup. +// findNodeFromJS dispatches a JS value (string, object, or array of objects) +// to Tree-level node lookup. func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) *hierarchy.Node { if goja.IsUndefined(arg) || goja.IsNull(arg) { return nil @@ -147,6 +148,9 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) if s, ok := arg.Export().(string); ok { return tree.FindNode(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -162,6 +166,9 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va if s, ok := arg.Export().(string); ok { return tree.FindAllNodes(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -177,6 +184,9 @@ func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg go if s, ok := arg.Export().(string); ok { return node.Find(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -192,6 +202,9 @@ func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, ar if s, ok := arg.Export().(string); ok { return node.FindAll(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -219,6 +232,37 @@ func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selec return sel } +// selectorPathFromJS recognizes a JS array of selector objects and converts it +// into a Selector chain. Returns ok=false for non-arrays so callers fall +// through to single-object dispatch. +func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Selector, bool) { + exported := arg.Export() + slice, ok := exported.([]any) + if !ok { + return nil, false + } + obj := arg.ToObject(runtime) + if obj == nil { + return nil, false + } + path := make([]hierarchy.Selector, 0, len(slice)) + for index := range slice { + entry := obj.Get(fmt.Sprintf("%d", index)) + if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) { + return nil, false + } + sel := selectorFromJSObject(runtime, entry) + if len(sel.Filters) == 0 { + return nil, false + } + path = append(path, sel) + } + if len(path) == 0 { + return nil, false + } + return path, true +} + // selectorStringFromJS returns a string representation of the selector argument // for tagging returned element objects (used by selectorOf to reconstruct the // selector when the element is passed back as an action target). diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index f86986c..523349a 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -7,6 +7,9 @@ import ( "strings" "testing" + "github.com/dop251/goja" + + "github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/ltl" ) @@ -176,6 +179,108 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) { } } +func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:setup" })]); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:setup" { + t.Errorf("setup precedence: got %q, want id:setup", action.On) + } +} + +func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => []); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("fallthrough: got %q, want id:main", action.On) + } +} + +func TestNextAction_SetupReengagesAfterRegression(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.loggedIn = __sanderling__.extract(state => state.snapshots["loggedIn"] === true); + globalThis.setup = __sanderling__.actions(() => { + if (loggedIn.current) return []; + return [__sanderling__.tap({ on: "id:login" })]; + }); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + + push := func(loggedIn bool) { + raw := json.RawMessage(`false`) + if loggedIn { + raw = json.RawMessage(`true`) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}}); err != nil { + t.Fatal(err) + } + } + + push(false) + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 1 (logged out): got %q, want id:login", action.On) + } + + push(true) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Fatalf("step 2 (logged in): got %q, want id:main", action.On) + } + + push(false) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 3 (regressed): got %q, want id:login", action.On) + } +} + +func TestNextAction_NoSetupRegistered(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + if verifier.setupGenerator != nil { + t.Errorf("setupGenerator should be nil when spec does not export setup") + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("got %q, want id:main", action.On) + } +} + func TestInputText_RoundTrip(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, ` @@ -258,3 +363,142 @@ func TestLoad_AcceptsSpecWithoutPropertiesOrActions(t *testing.T) { t.Errorf("expected ErrNoAction, got %v", err) } } + +// TestSelectorPath_ScopedDescent ensures the JS-side `find([{...}, {...}])` +// shape walks each segment scoped under the previous match. +func TestSelectorPath_ScopedDescent(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Other", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.found = __sanderling__.extract(state => + state.ax.find([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }, { testTag: "AccountName" }]) + ); + globalThis.foundUnreachable = __sanderling__.extract(state => + state.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AccountCard" }]) + ); + globalThis.allInHome = __sanderling__.extract(state => + state.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountName" }]) + ); + `) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + found := verifier.runtime.GlobalObject().Get("found").ToObject(verifier.runtime).Get("current") + if found == nil || goja.IsUndefined(found) { + t.Fatal("expected path lookup to find AccountName under HomeScreen > AccountCard") + } + text := found.ToObject(verifier.runtime).Get("text") + if text.String() != "Checking" { + t.Fatalf("text = %q, want Checking", text.String()) + } + unreachable := verifier.runtime.GlobalObject().Get("foundUnreachable").ToObject(verifier.runtime).Get("current") + if !goja.IsUndefined(unreachable) { + t.Fatalf("AccountCard is not under LedgerScreen, expected undefined, got %v", unreachable) + } + allInHome := verifier.runtime.GlobalObject().Get("allInHome").ToObject(verifier.runtime).Get("current") + allObject := allInHome.ToObject(verifier.runtime) + length := allObject.Get("length").ToInteger() + if length != 1 { + t.Fatalf("findAll path length = %d, want 1 (Checking only, not Other in LedgerScreen)", length) + } +} + +// TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step +// dynamic array picks the same element under the same seed across runs. The +// folio spec relies on this to replace Math.random() in account-card taps. +func TestFrom_SeededReplayIsDeterministic(t *testing.T) { + pickedSequence := func(seed uint64) []string { + verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(seed, 0)))) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => { + const cards = ["card_a", "card_b", "card_c", "card_d"]; + return [__sanderling__.tap({ on: __sanderling__.from(cards).generate() })]; + }); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + var picks []string + for range 20 { + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + picks = append(picks, action.On) + } + return picks + } + first := pickedSequence(1234) + second := pickedSequence(1234) + for i := range first { + if first[i] != second[i] { + t.Fatalf("step %d: %q != %q (replay not deterministic)", i, first[i], second[i]) + } + } + other := pickedSequence(5678) + identical := true + for i := range first { + if first[i] != other[i] { + identical = false + break + } + } + if identical { + t.Fatal("expected different seeds to produce different pick sequences") + } +} + +// PredicateError must reflect the most recent step's predicate result, not a +// latched first-step error. The runner logs PredicateError once per step; if it +// stays pinned to step 1 forever, downstream debugging looks frozen even though +// the underlying state is changing. +func TestPredicateError_ReflectsCurrentStepNotFirstStep(t *testing.T) { + const spec = ` +globalThis.counter = __sanderling__.extract(state => state.snapshots["count"]); +globalThis.properties = { + reportsCounter: __sanderling__.always(() => { throw new Error("count=" + counter.current); }), +}; +` + verifier := newVerifier(t) + mustLoad(t, verifier, spec) + + for step := 1; step <= 3; step++ { + raw := json.RawMessage([]byte{'"', byte('0' + step), '"'}) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"count": raw}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + + got := verifier.PredicateError("reportsCounter") + if got == nil { + t.Fatalf("step %d: PredicateError = nil, want non-nil", step) + } + want := "count=" + string(rune('0'+step)) + if !strings.Contains(got.Error(), want) { + t.Errorf("step %d: PredicateError = %q, want to contain %q", step, got.Error(), want) + } + } +} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 24fd683..0ac7729 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -20,6 +20,7 @@ type Verifier struct { properties map[string]int // property name -> formula-spec index actionGenerator goja.Value + setupGenerator goja.Value evaluators map[string]*ltl.Evaluator @@ -56,8 +57,9 @@ func New(options ...Option) (*Verifier, error) { } // Load executes the bundled spec source. The spec is expected to assign its -// property formulas to globalThis.properties and its root action generator -// to globalThis.actions. +// property formulas to globalThis.properties, its root action generator to +// globalThis.actions, and optionally a setup (precondition) action generator +// to globalThis.setup. func (v *Verifier) Load(source string) error { if _, err := v.runtime.RunString(source); err != nil { return fmt.Errorf("run spec: %w", err) @@ -88,6 +90,10 @@ func (v *Verifier) Load(source string) error { v.actionGenerator = actionsValue } + if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) { + v.setupGenerator = setupValue + } + return nil } @@ -216,6 +222,10 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error { if err := v.runtime.GlobalObject().Set("state", state); err != nil { return fmt.Errorf("set state: %w", err) } + // Extractor previous/current advance exactly once per PushSnapshot. + // Predicate thunks read these slots but never trigger advancement, so + // invoking a thunk multiple times between snapshots is value-stable. + // refreshPredicateErrors relies on this to safely re-call predicates. for index, extractor := range v.extractors { previous := extractor.handle.Get("current") _ = extractor.handle.Set("previous", previous) @@ -254,6 +264,7 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict { for name, evaluator := range v.evaluators { verdicts[name] = evaluator.ObserveAt(stepTime) } + v.refreshPredicateErrors() return verdicts } @@ -273,12 +284,22 @@ func (v *Verifier) Residuals() map[string]ltl.Formula { return residuals } -// NextAction resolves the root action generator into a single Action. -// Returns ErrNoAction when no branch of the generator produces one after a -// small number of retries. Retrying avoids wedging when most branches of a -// weighted generator produce no action on the current screen (e.g. a gated -// login-phone generator when the app is already past login). +// NextAction resolves an action for the current step. The setup generator, +// when registered, runs first; if it yields an action, that wins. When setup +// returns ErrNoAction (all branches empty) the call falls through to the +// root action generator with the existing retry semantics. Setup is consulted +// every step, so state regression (e.g. a logout under fuzz) automatically +// re-engages the precondition. func (v *Verifier) NextAction() (Action, error) { + if v.setupGenerator != nil { + action, err := v.resolveGenerator(v.setupGenerator) + if err == nil { + return action, nil + } + if !errors.Is(err, ErrNoAction) { + return Action{}, err + } + } if v.actionGenerator == nil { return Action{}, ErrNoAction } @@ -302,15 +323,38 @@ func (v *Verifier) formulaThunk(index int) func() bool { formula := v.formulas[index] result, err := formula.predicate(goja.Undefined()) if err != nil { - if formula.err == nil { - formula.err = err - } + formula.err = err return false } + formula.err = nil return result.ToBoolean() } } +// refreshPredicateErrors re-invokes every registered predicate so that +// formula.err reflects the current step rather than a latched first-step +// throw. EvaluateProperties short-circuits once a property has latched to +// violated, so without this refresh the runner's per-step "predicate error" +// log freezes on whatever the predicate threw at step 1. The refreshed errors +// have no effect on verdicts. +// +// Invariant: predicates may be re-invoked here outside the LTL gate that +// would normally skip them (e.g. an `implies` consequent whose antecedent is +// false). They must therefore be side-effect-free reads of extractor state; +// any spec that asserts internal preconditions inside a predicate could +// surface a spurious error in the inspect UI without affecting verdicts. +func (v *Verifier) refreshPredicateErrors() { + for _, formula := range v.formulas { + result, err := formula.predicate(goja.Undefined()) + if err != nil { + formula.err = err + continue + } + _ = result + formula.err = nil + } +} + // PredicateError returns the first goja error raised by any thunk in the // named property's formula tree, or nil if none fired. Callers typically // consult this after EvaluateProperties reports a violation to distinguish diff --git a/pkg/spec/README.md b/pkg/spec/README.md index 6894b96..c651ddf 100644 --- a/pkg/spec/README.md +++ b/pkg/spec/README.md @@ -13,10 +13,12 @@ npm install --save-dev @sanderling/spec ## Usage ```ts -import { extract, always, eventually, now, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; +import { extract, always, eventually, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); const balance = extract((s) => (s.snapshots.balance as number) ?? 0); +const emailField = extract((s) => s.ax.find("id:email-field")); +const submitButton = extract((s) => s.ax.find("id:sign-in-button")); export const properties = { balanceNeverNegative: always(() => balance.current >= 0), @@ -25,17 +27,41 @@ export const properties = { const doLogin = actions(() => { if (loggedIn.current) return []; - const email = state.ax.find("id:email-field"); - const submit = state.ax.find("id:sign-in-button"); + const email = emailField.current; + const submit = submitButton.current; if (!email || !submit) return []; return [InputText({ into: email, text: "test@example.com" }), Tap({ on: submit })]; }); -export const actions = weighted( +export const actionsRoot = weighted( [50, doLogin], [10, taps], [2, swipes], ); ``` +## Setup actions + +Some action generators are not fuzz targets but preconditions: they drive the +app from a fresh state into the surface you actually want to fuzz (login, +onboarding, permission grants, seed data). Export them as `setup` instead of +mixing them into `actionsRoot`. The runner tries `setup` first; if it yields +no action, it falls through to `actionsRoot`. State regressing back across the +precondition (e.g. logout under fuzz) automatically re-engages setup. + +```ts +const login = actions(() => { + if (loggedIn.current) return []; + return [InputText({ into: emailField.current!, text: "demo@app.test" }), Tap({ on: submitButton.current! })]; +}); + +export const setup = login; +export const actionsRoot = weighted([60, browse], [40, edit]); + +(globalThis as { setup?: unknown }).setup = setup; +``` + +Setup is just an `ActionGenerator`; compose with `actions`, `weighted`, or +`whenRoute` exactly like the main pool. + Works identically across Android, iOS, and web targets. diff --git a/pkg/spec/src/actions.ts b/pkg/spec/src/actions.ts index 9e2f160..1010ea3 100644 --- a/pkg/spec/src/actions.ts +++ b/pkg/spec/src/actions.ts @@ -17,6 +17,19 @@ export function actions(generator: () => Action[]): ActionGenerator { return globalThis.__sanderling__.actions(generator); } +export function whenRoute( + routeExtractor: { readonly current: string | null }, + routes: string | readonly string[], + body: () => Action[], +): ActionGenerator { + const allowed = typeof routes === "string" ? [routes] : routes; + return actions(() => { + const current = routeExtractor.current; + if (current === null || !allowed.includes(current)) return []; + return body(); + }); +} + export function weighted(...entries: WeightedEntry[]): ActionGenerator { return globalThis.__sanderling__.weighted(...entries); } diff --git a/pkg/spec/src/extract.ts b/pkg/spec/src/extract.ts index 320e6e2..4c08087 100644 --- a/pkg/spec/src/extract.ts +++ b/pkg/spec/src/extract.ts @@ -1,5 +1,17 @@ -import type { Extracted, State } from "./types.ts"; +import type { AccessibilityElement, Extracted, State } from "./types.ts"; export function extract(getter: (state: State) => T): Extracted { return globalThis.__sanderling__.extract(getter); } + +const KEY_DELIMITER = "\x1f"; + +export function keyedBy( + element: AccessibilityElement | undefined, + tags: readonly string[], +): string { + if (!element) return ""; + return tags + .map(tag => element.find({ testTag: tag })?.text ?? "") + .join(KEY_DELIMITER); +} diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index f046b41..e60355f 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -13,6 +13,7 @@ export type { Point, PressKeyAction, Sampler, + SelectorPath, Snapshots, State, SwipeAction, @@ -22,7 +23,7 @@ export type { WeightedEntry, } from "./types.ts"; -export { extract } from "./extract.ts"; +export { extract, keyedBy } from "./extract.ts"; export { always, eventually, next, now } from "./ltl.ts"; export { InputText, @@ -37,4 +38,5 @@ export { taps, waitOnce, weighted, + whenRoute, } from "./actions.ts"; diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index c1c1ef7..a3c53ea 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -1,6 +1,8 @@ export type Snapshots = Record; export type AttrSelector = Record; +export type SelectorPath = readonly AttrSelector[]; + export interface AccessibilityElement { id?: string; text?: string; @@ -15,13 +17,13 @@ export interface AccessibilityElement { x?: number; y?: number; attrs?: Record; - find(selector: string | AttrSelector): AccessibilityElement | undefined; - findAll(selector: string | AttrSelector): AccessibilityElement[]; + find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined; + findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[]; } export interface AccessibilityTree { - find(selector: string | AttrSelector): AccessibilityElement | undefined; - findAll(selector: string | AttrSelector): AccessibilityElement[]; + find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined; + findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[]; } export interface LogEntry { diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index 891db82..e97502a 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -12,6 +12,7 @@ import { eventually, extract, from, + keyedBy, next, now, pressKey, @@ -19,6 +20,7 @@ import { taps, waitOnce, weighted, + whenRoute, } from "../src/index.ts"; import type { AccessibilityElement, @@ -217,7 +219,11 @@ test("Tap returns a TapAction with the supplied selector", () => { test("Tap accepts an AccessibilityElement", () => { installFakeRuntime(); - const element: AccessibilityElement = { id: "login_continue" }; + const element: AccessibilityElement = { + id: "login_continue", + find: () => undefined, + findAll: () => [], + }; const action = Tap({ on: element }); assert.equal(action.kind, "Tap"); assert.equal(action.on, element); @@ -277,6 +283,82 @@ test("from forwards items to the runtime", () => { assert.equal(sampler.generate(), "a"); }); +function elementWithChildren( + cells: Record, +): AccessibilityElement { + return { + find: selector => { + if (typeof selector === "string" || Array.isArray(selector)) return undefined; + const tag = (selector as Record).testTag; + if (!tag) return undefined; + const text = cells[tag]; + if (text === undefined) return undefined; + return { text, find: () => undefined, findAll: () => [] }; + }, + findAll: () => [], + }; +} + +test("keyedBy joins testTag-resolved texts with a stable delimiter", () => { + installFakeRuntime(); + const row = elementWithChildren({ + TxnDate: "2026-04-26", + TxnNote: "Coffee", + TxnAmount: "$5.00", + }); + const key = keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]); + assert.equal(key, "2026-04-26\x1fCoffee\x1f$5.00"); +}); + +test("keyedBy returns empty string for an undefined element", () => { + installFakeRuntime(); + assert.equal(keyedBy(undefined, ["TxnDate"]), ""); +}); + +test("keyedBy substitutes empty strings for missing children", () => { + installFakeRuntime(); + const row = elementWithChildren({ TxnDate: "2026-04-26" }); + assert.equal( + keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), + "2026-04-26\x1f\x1f", + ); +}); + +test("whenRoute returns [] when current route does not match", () => { + installFakeRuntime(); + const route = { current: "home" as string | null }; + let bodyCalled = false; + const generator = whenRoute(route, "ledger", () => { + bodyCalled = true; + return [Tap({ on: "id:x" })]; + }); + assert.deepEqual(generator.generate(), []); + assert.equal(bodyCalled, false); +}); + +test("whenRoute calls body when current route matches", () => { + installFakeRuntime(); + const route = { current: "ledger" as string | null }; + const generator = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]); + const result = generator.generate(); + assert.equal(result.length, 1); + assert.equal(result[0]?.kind, "Tap"); +}); + +test("whenRoute accepts an array of allowed routes", () => { + installFakeRuntime(); + const route = { current: "add-account" as string | null }; + const generator = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]); + assert.equal(generator.generate().length, 1); +}); + +test("whenRoute returns [] for null route", () => { + installFakeRuntime(); + const route = { current: null as string | null }; + const generator = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]); + assert.deepEqual(generator.generate(), []); +}); + test("default generators proxy through to the runtime", () => { installFakeRuntime(); assert.equal(taps.__sanderlingActionGenerator, true);