mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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<AppGraph.Factory>().
* 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).
This commit is contained in:
111 files changed
+2317
-1058
No files matched your search
@@ -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/
|
||||
|
||||
+15
-10
@@ -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/<timestamp>/`.
|
||||
|
||||
## 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.
|
||||
@@ -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)
|
||||
}
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:name=".FolioApplication"
|
||||
android:label="Folio"
|
||||
android:allowBackup="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
@@ -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<AppGraph.Factory>()
|
||||
setContent {
|
||||
App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) })
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import Shared
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+10
@@ -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 }
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -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<AppGraph?>(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<Route.Login> { LoginRoute() }
|
||||
composable<Route.Home> { HomeRoute() }
|
||||
composable<Route.AddAccount> { AddAccountRoute() }
|
||||
composable<Route.Ledger> { entry ->
|
||||
LedgerRoute(accountId = entry.toRoute<Route.Ledger>().accountId)
|
||||
}
|
||||
composable<Route.AddTransaction> { entry ->
|
||||
AddTransactionRoute(accountId = entry.toRoute<Route.AddTransaction>().accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.folio.di
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
val LocalAppGraph = staticCompositionLocalOf<AppGraph> {
|
||||
error("AppGraph not provided. Wrap content in CompositionLocalProvider(LocalAppGraph provides ...).")
|
||||
}
|
||||
+22
-45
@@ -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,
|
||||
+61
@@ -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<AddAccountUiState> = _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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-46
@@ -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 = "[email protected]"
|
||||
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) {
|
||||
+65
@@ -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 = "[email protected]"
|
||||
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<LoginUiState> = _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
|
||||
}
|
||||
+37
-30
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+56
@@ -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<Account> = emptyList(),
|
||||
val transactions: List<Transaction> = 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<HomeUiState> = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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<AddTransactionUiState> = 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
|
||||
}
|
||||
}
|
||||
+38
-36
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -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<Transaction> = 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<LedgerUiState> = 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
package app.folio.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
expect fun Modifier.testTagsAsResourceId(): Modifier
|
||||
+3
-3
@@ -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 = "$"
|
||||
+9
-9
@@ -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<AppGraph.Factory>()
|
||||
val vc = ComposeUIViewController {
|
||||
App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) })
|
||||
}
|
||||
val gesture = UIScreenEdgePanGestureRecognizer(
|
||||
target = backGestureTarget,
|
||||
action = NSSelectorFromString("handleGesture:"),
|
||||
+1
-1
@@ -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) {
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
package app.folio.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
actual fun Modifier.testTagsAsResourceId(): Modifier = this
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.util
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSDateFormatter
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
package app.folio.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
actual fun Modifier.testTagsAsResourceId(): Modifier = this
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.util
|
||||
|
||||
private val MONTHS = arrayOf(
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-3
@@ -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")
|
||||
}
|
||||
File renamed without changes.
+5
-7
@@ -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<String>,
|
||||
onSelect: (Int) -> Unit,
|
||||
descriptions: List<String>? = null,
|
||||
testTags: List<String>? = 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,
|
||||
+6
-12
@@ -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"
|
||||
},
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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<AppGraph.Factory>()
|
||||
|
||||
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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Folio</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
|
||||
#app { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="composeApp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 } });
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
-10
@@ -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))
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null)
|
||||
val current: StateFlow<String?> = _current.asStateFlow()
|
||||
|
||||
fun enter(id: String) {
|
||||
_current.value = id
|
||||
}
|
||||
|
||||
fun leave(id: String) {
|
||||
if (_current.value == id) _current.value = null
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package app.folio.data
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface LedgerStore {
|
||||
val accounts: StateFlow<List<Account>>
|
||||
val transactions: StateFlow<List<Transaction>>
|
||||
val session: StateFlow<Session?>
|
||||
|
||||
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
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package app.folio.feature.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object AddAccountUiState {
|
||||
val addAccountError = MutableStateFlow("")
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package app.folio.feature.auth
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object LoginUiState {
|
||||
val loginError = MutableStateFlow("")
|
||||
}
|
||||
-162
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package app.folio.feature.ledger
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object AddTransactionUiState {
|
||||
val txnError = MutableStateFlow("")
|
||||
val txnFormType = MutableStateFlow<String?>(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<Route>().apply { addLast(Route.Home) }
|
||||
private val _current = MutableStateFlow<Route>(Route.Home)
|
||||
val current: StateFlow<Route> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package app.folio.data
|
||||
|
||||
actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory())
|
||||
@@ -1,65 +0,0 @@
|
||||
package app.folio.data
|
||||
|
||||
internal data class Snapshot(
|
||||
val accounts: List<Account>,
|
||||
val transactions: List<Transaction>,
|
||||
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<Account>()
|
||||
val transactions = mutableListOf<Transaction>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<Account>>(emptyList())
|
||||
private val _transactions = MutableStateFlow<List<Transaction>>(emptyList())
|
||||
private val _session = MutableStateFlow<Session?>(null)
|
||||
|
||||
override val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
||||
override val transactions: StateFlow<List<Transaction>> = _transactions.asStateFlow()
|
||||
override val session: StateFlow<Session?> = _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()
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Folio</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; }
|
||||
body { display: flex; justify-content: center; }
|
||||
#app { width: 100%; max-width: 480px; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="composeApp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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")
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.core.platform
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.data
|
||||
package app.folio.core.data
|
||||
|
||||
data class Account(
|
||||
val id: String,
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.folio.core.data
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface LedgerStore {
|
||||
val accounts: StateFlow<List<Account>>
|
||||
val transactions: StateFlow<List<Transaction>>
|
||||
val session: StateFlow<Session?>
|
||||
|
||||
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()
|
||||
}
|
||||
+15
-19
@@ -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<List<Account>> = store.accounts
|
||||
val transactions: StateFlow<List<Transaction>> = store.transactions
|
||||
val session: StateFlow<Session?> = store.session
|
||||
|
||||
val accounts: StateFlow<List<Account>> get() = store.accounts
|
||||
val transactions: StateFlow<List<Transaction>> get() = store.transactions
|
||||
val session: StateFlow<Session?> 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()
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.data
|
||||
package app.folio.core.data
|
||||
|
||||
data class Session(
|
||||
val user: String,
|
||||
+23
-18
@@ -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<List<Account>> =
|
||||
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<List<Transaction>> =
|
||||
q.selectAllTxns()
|
||||
queries.selectAllTxns()
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.map { rows -> rows.map { it.toDomain() } }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val session: StateFlow<Session?> =
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.data
|
||||
package app.folio.core.data
|
||||
|
||||
enum class TxnType { credit, debit }
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.core.platform
|
||||
|
||||
expect object Platform {
|
||||
fun now(): Long
|
||||
File renamed without changes.
@@ -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")
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.core.platform
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSUUID
|
||||
@@ -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" })""")
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package app.folio.platform
|
||||
package app.folio.core.platform
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -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" }
|
||||
+21
-7
@@ -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
|
||||
|
||||
@@ -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/[email protected]":
|
||||
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==
|
||||
@@ -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<string | null>(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<Account[]>(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<LedgerRow[]>(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 = "[email protected]";
|
||||
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 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 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;
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user