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:
pj authored and GitHub committed 2026-04-27 00:33:57 +07:00
1 parent 28909d954b
commit c76745e5f1
111 files changed
+2330 -1071

No files matched your search

@@ -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
}
}
@@ -0,0 +1,7 @@
package app.folio.ui
import androidx.compose.runtime.Composable
import androidx.activity.compose.BackHandler as AndroidBackHandler
@Composable
actual fun BackHandler(onBack: () -> Unit) = AndroidBackHandler(onBack = onBack)
@@ -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 }
@@ -0,0 +1,9 @@
package app.folio.util
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val DATE_FMT = SimpleDateFormat("MMM d, h:mm a", Locale.US)
actual fun formatDate(epochMillis: Long): String = DATE_FMT.format(Date(epochMillis))
@@ -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 ...).")
}
@@ -0,0 +1,79 @@
package app.folio.feature.account
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.material3.Text
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.unit.dp
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
import app.folio.ui.component.ButtonStyle
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.TextInput
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.Type
@Composable
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
BackHandler { onEvent(AddAccountEvent.Back) }
Screen(
modifier = Modifier.testTag("AddAccountScreen"),
header = {
Header(title = "New account", left = { BackButton(onClick = { onEvent(AddAccountEvent.Back) }) })
},
footer = {
AppButton(
text = "Create account",
onClick = { onEvent(AddAccountEvent.Submit) },
style = ButtonStyle.Primary,
enabled = state.name.trim().isNotEmpty(),
testTag = "AddAccountSubmit",
)
},
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Account name")
TextInput(
value = state.name,
onChange = { onEvent(AddAccountEvent.NameChange(it)) },
placeholder = "e.g. Checking",
invalid = state.error.isNotEmpty(),
label = "Account name",
testTag = "AccountNameField",
)
}
ErrorText(state.error)
Text(
"Use a short, recognizable name. You can create as many accounts as you need.",
style = Type.caption,
color = t.textMuted,
)
}
}
}
@@ -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") }
}
}
}
}
@@ -0,0 +1,86 @@
package app.folio.feature.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.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.unit.dp
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
import app.folio.ui.component.ErrorText
import app.folio.ui.component.FieldLabel
import app.folio.ui.component.Screen
import app.folio.ui.component.TextInput
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.Type
@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(state: LoginUiState, onEvent: (LoginEvent) -> Unit) {
val t = LocalTokens.current
Screen(modifier = Modifier.testTag("LoginScreen")) {
Spacer(Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Email")
TextInput(
value = state.email,
onChange = { onEvent(LoginEvent.EmailChange(it)) },
placeholder = DEMO_EMAIL,
invalid = state.error.isNotEmpty(),
keyboardType = KeyboardType.Email,
label = "Email",
testTag = "LoginEmail",
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Password")
TextInput(
value = state.password,
onChange = { onEvent(LoginEvent.PasswordChange(it)) },
placeholder = "••••••••",
password = true,
invalid = state.error.isNotEmpty(),
keyboardType = KeyboardType.Password,
label = "Password",
testTag = "LoginPassword",
)
}
ErrorText(state.error)
AppButton(
text = "Sign in",
onClick = { onEvent(LoginEvent.Submit) },
style = ButtonStyle.Primary,
testTag = "LoginSubmit",
)
Spacer(Modifier.height(4.dp))
Card(dashed = true) {
Text("Demo credentials", style = Type.caption, color = t.textMuted)
Text("email: $DEMO_EMAIL", style = Type.body, color = t.text)
Text("password: $DEMO_PASSWORD", style = Type.body, color = t.text)
}
}
}
}
@@ -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
}
@@ -0,0 +1,165 @@
package app.folio.feature.home
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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
import app.folio.ui.component.Header
import app.folio.ui.component.IconButton
import app.folio.ui.component.Screen
import app.folio.ui.icon.Icons
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.RadiusLg
import app.folio.ui.theme.Type
@Composable
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 total = balanceOf(state.transactions)
Screen(
modifier = Modifier.testTag("HomeScreen"),
header = {
Header(
title = "Accounts",
subtitle = state.user,
right = {
IconButton(
onClick = { onEvent(HomeEvent.Logout) },
label = "Log out",
icon = Icons.Logout,
testTag = "LogoutButton",
)
},
)
},
footer = {
Row(verticalAlignment = Alignment.Bottom) {
Column {
Text("TOTAL BALANCE", style = Type.label, color = t.textMuted)
Text(formatCents(total), style = Type.balance, color = t.text)
}
Box(Modifier.weight(1f))
Text(
"${state.accounts.size} account${if (state.accounts.size == 1) "" else "s"}",
style = Type.caption,
color = t.textFaint,
)
}
AppButton(
text = "+ Add account",
onClick = { onEvent(HomeEvent.AddAccount) },
style = ButtonStyle.Primary,
testTag = "AddAccountButton",
)
},
) {
if (state.accounts.isEmpty()) {
EmptyState(
title = "No accounts yet",
subtitle = "Create your first account to start tracking transactions.",
icon = Icons.Bank,
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
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(
name = a.name,
initials = initialsOf(a.name),
count = count,
balance = bal,
onClick = { onEvent(HomeEvent.OpenAccount(a.id)) },
)
}
}
}
}
}
@Composable
private fun AccountCard(
name: String,
initials: String,
count: Int,
balance: Long,
onClick: () -> Unit,
) {
val t = LocalTokens.current
val txnLabel = if (count == 1) "1 transaction" else "$count transactions"
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RadiusLg))
.background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
.testTag("AccountCard")
.clickable(role = Role.Button, onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(t.surface3),
contentAlignment = Alignment.Center,
) {
Text(initials, style = Type.bodyStrong, color = t.text)
}
Column(Modifier.weight(1f)) {
Text(
name,
style = Type.bodyStrong,
color = t.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("AccountName"),
)
Text(txnLabel, style = Type.caption, color = t.textMuted)
}
Text(
formatCents(balance),
style = Type.bodyStrong,
color = t.text,
modifier = Modifier.testTag("AccountBalance"),
)
}
}
@@ -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))
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
@@ -0,0 +1,177 @@
package app.folio.feature.ledger
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
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.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
import app.folio.ui.component.ButtonStyle
import app.folio.ui.component.Card
import app.folio.ui.component.EmptyState
import app.folio.ui.component.Header
import app.folio.ui.component.Screen
import app.folio.ui.icon.Icons
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.RadiusLg
import app.folio.ui.theme.Type
@Composable
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
BackHandler { onEvent(LedgerEvent.Back) }
val account = state.account
if (account == null) {
Screen(
header = {
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 = { onEvent(LedgerEvent.BackToHome) },
style = ButtonStyle.Secondary,
)
}
return
}
val balance = balanceOf(state.transactions)
Screen(
modifier = Modifier.testTag("LedgerScreen"),
header = {
Header(
title = account.name,
subtitle = "Ledger",
left = { BackButton { onEvent(LedgerEvent.Back) } },
)
},
footer = {
AppButton(
text = "+ Add transaction",
onClick = { onEvent(LedgerEvent.AddTransaction) },
style = ButtonStyle.Primary,
testTag = "AddTransactionButton",
)
},
) {
Card {
Text("BALANCE", style = Type.label, color = t.textMuted)
Text(
formatCents(balance),
style = Type.balance,
color = t.text,
modifier = Modifier.testTag("LedgerBalance"),
)
}
Text(
"ACTIVITY",
style = Type.label,
color = t.textFaint,
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
)
if (state.transactions.isEmpty()) {
EmptyState(
title = "No transactions yet",
subtitle = "Add your first credit or debit to see it here.",
icon = Icons.Lines,
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RadiusLg))
.background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
.padding(horizontal = 16.dp),
) {
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))
}
}
}
}
}
}
@Composable
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)
.testTag("LedgerRow"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(t.surface2)
.border(1.dp, t.border, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Text(if (type == TxnType.credit) "+" else "-", style = Type.bodyStrong, color = t.text)
}
Column(Modifier.weight(1f)) {
Text(
if (note.isNotEmpty()) note else if (type == TxnType.credit) "Credit" else "Debit",
style = Type.body,
color = t.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("TxnNote"),
)
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.testTag("TxnAmount"),
)
}
}
@@ -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
}
@@ -0,0 +1,6 @@
package app.folio.ui
import androidx.compose.runtime.Composable
@Composable
expect fun BackHandler(onBack: () -> Unit)
@@ -0,0 +1,5 @@
package app.folio.ui
import androidx.compose.ui.Modifier
expect fun Modifier.testTagsAsResourceId(): Modifier
@@ -0,0 +1,73 @@
package app.folio.util
import app.folio.core.data.Transaction
import app.folio.core.data.TxnType
import kotlin.math.absoluteValue
private const val CURRENCY = "$"
private val AMOUNT_REGEX = Regex("""^\d+(\.\d{1,2})?$""")
fun formatCents(cents: Long, signed: Boolean = false): String {
val abs = cents.absoluteValue
val dollars = abs / 100
val rem = (abs % 100).toInt()
val dollarsStr = withThousandsSeparator(dollars)
val remStr = rem.toString().padStart(2, '0')
val body = "$CURRENCY$dollarsStr.$remStr"
return when {
cents < 0 -> "-$body"
signed && cents > 0 -> "+$body"
else -> body
}
}
private fun withThousandsSeparator(value: Long): String {
val s = value.toString()
if (s.length <= 3) return s
val out = StringBuilder()
var count = 0
for (i in s.length - 1 downTo 0) {
out.append(s[i])
count++
if (count == 3 && i != 0) {
out.append(',')
count = 0
}
}
return out.reverse().toString()
}
fun parseCents(input: String): Long? {
val trimmed = input.trim().replace(",", "")
if (trimmed.isEmpty()) return null
if (!AMOUNT_REGEX.matches(trimmed)) return null
val dot = trimmed.indexOf('.')
val whole: String
val frac: String
if (dot < 0) {
whole = trimmed
frac = ""
} else {
whole = trimmed.substring(0, dot)
frac = trimmed.substring(dot + 1)
}
val fracPadded = (frac + "00").substring(0, 2)
val wholeLong = whole.toLongOrNull() ?: return null
val fracLong = fracPadded.toLongOrNull() ?: return null
val total = wholeLong * 100 + fracLong
if (total < 0) return null
return total
}
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
fun balanceOf(txns: List<Transaction>): Long = txns.sumOf { signedAmount(it) }
fun initialsOf(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (parts.isEmpty()) return "?"
if (parts.size == 1) return parts[0].take(2).uppercase()
return (parts.first().first().toString() + parts.last().first().toString()).uppercase()
}
expect fun formatDate(epochMillis: Long): String
@@ -0,0 +1,45 @@
package app.folio
import androidx.compose.ui.window.ComposeUIViewController
import app.folio.core.data.DriverFactory
import app.folio.db.LedgerDatabase
import app.folio.di.AppGraph
import app.folio.ui.IosBackGesture
import dev.zacsweers.metro.createGraphFactory
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCAction
import platform.Foundation.NSSelectorFromString
import platform.UIKit.UIGestureRecognizerStateEnded
import platform.UIKit.UIRectEdgeLeft
import platform.UIKit.UIScreenEdgePanGestureRecognizer
import platform.UIKit.UIViewController
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
class BackGestureTarget : NSObject() {
@ObjCAction
fun handleGesture(gesture: UIScreenEdgePanGestureRecognizer) {
if (gesture.state == UIGestureRecognizerStateEnded) {
IosBackGesture.dispatch()
}
}
}
private val backGestureTarget = BackGestureTarget()
@OptIn(ExperimentalForeignApi::class)
fun MainViewController(): UIViewController {
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:"),
)
gesture.edges = UIRectEdgeLeft
vc.view.addGestureRecognizer(gesture)
return vc
}
@@ -0,0 +1,15 @@
package app.folio.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
@Composable
actual fun BackHandler(onBack: () -> Unit) {
val current by rememberUpdatedState(onBack)
DisposableEffect(Unit) {
val id = IosBackGesture.register { current() }
onDispose { IosBackGesture.unregister(id) }
}
}
@@ -0,0 +1,22 @@
package app.folio.ui
object IosBackGesture {
private var nextId = 0
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
fun register(onBack: () -> Unit): Int {
val id = nextId++
callbacks.add(id to onBack)
return id
}
fun unregister(id: Int) {
callbacks.removeAll { it.first == id }
}
fun dispatch(): Boolean {
val top = callbacks.lastOrNull() ?: return false
top.second()
return true
}
}
@@ -0,0 +1,5 @@
package app.folio.ui
import androidx.compose.ui.Modifier
actual fun Modifier.testTagsAsResourceId(): Modifier = this
@@ -0,0 +1,16 @@
package app.folio.util
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSLocale
import platform.Foundation.dateWithTimeIntervalSince1970
private val DATE_FMT = NSDateFormatter().apply {
locale = NSLocale("en_US_POSIX")
dateFormat = "MMM d, h:mm a"
}
private fun date(epochMillis: Long): NSDate =
NSDate.dateWithTimeIntervalSince1970(epochMillis / 1000.0)
actual fun formatDate(epochMillis: Long): String = DATE_FMT.stringFromDate(date(epochMillis))
@@ -0,0 +1,36 @@
package app.folio.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
@Composable
actual fun BackHandler(onBack: () -> Unit) {
val current by rememberUpdatedState(onBack)
DisposableEffect(Unit) {
val id = WebBackGesture.register { current() }
onDispose { WebBackGesture.unregister(id) }
}
}
object WebBackGesture {
private var nextId = 0
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
fun register(onBack: () -> Unit): Int {
val id = nextId++
callbacks.add(id to onBack)
return id
}
fun unregister(id: Int) {
callbacks.removeAll { it.first == id }
}
fun dispatch(): Boolean {
val top = callbacks.lastOrNull() ?: return false
top.second()
return true
}
}
@@ -0,0 +1,5 @@
package app.folio.ui
import androidx.compose.ui.Modifier
actual fun Modifier.testTagsAsResourceId(): Modifier = this
@@ -0,0 +1,27 @@
package app.folio.util
private val MONTHS = arrayOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
actual fun formatDate(epochMillis: Long): String {
val month = jsDateMonth(epochMillis.toDouble())
val day = jsDateDay(epochMillis.toDouble())
val hour24 = jsDateHours(epochMillis.toDouble())
val minute = jsDateMinutes(epochMillis.toDouble())
val am = hour24 < 12
val hour12 = when {
hour24 == 0 -> 12
hour24 > 12 -> hour24 - 12
else -> hour24
}
val minuteStr = minute.toString().padStart(2, '0')
val suffix = if (am) "AM" else "PM"
return "${MONTHS[month]} $day, $hour12:$minuteStr $suffix"
}
private fun jsDateMonth(ms: Double): Int = js("new Date(ms).getMonth()")
private fun jsDateDay(ms: Double): Int = js("new Date(ms).getDate()")
private fun jsDateHours(ms: Double): Int = js("new Date(ms).getHours()")
private fun jsDateMinutes(ms: Double): Int = js("new Date(ms).getMinutes()")