mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
WIP: rename sample app to Folio (#20)
* refactor: rename examples/sample-app to examples/folio Directory-level rename and path references in Go tests, bundle-check, top-level README, and getting-started docs. Package declarations, Gradle config, iOS bundle IDs, and class names follow in later commits. * refactor(folio): rename Kotlin package dev.uatu.sample to app.folio Moves source dirs and sqldelight schema from dev/uatu/sample to app/folio, updates package declarations and imports, and switches Android namespace/applicationId, iOS binaryOption bundleId, and sqldelight database packageName to the new identifier. * refactor(folio): rename SampleApplication to FolioApplication Android manifest now points at .FolioApplication with label 'Folio' instead of 'Uatu Sample'. * refactor(folio): set iOS bundle id and display name to Folio bundleIdPrefix + PRODUCT_BUNDLE_IDENTIFIER -> app.folio. CFBundleName + CFBundleDisplayName -> 'Folio'. * refactor(folio): update demo email to [email protected] * refactor(folio): point justfile at app.folio bundle id Updates xcrun simctl launch target, uatu test --bundle-id, and the build/uninstall comments to reference folio instead of sample. * test: update fixture package ids to app.folio Sidecar activity-resolver test and verifier spec-integration XML fixtures referenced the old dev.uatu.sample Android package. Updates them to match the folio app's real package id so the tests stay representative of what the CLI sees on-device. * test(verifier): rename SampleApp identifiers to Folio Renames TestSampleAppSpec* functions, bundleSampleAppSpec helper, and sampleAppHierarchyXML const (now loginHierarchyXML for consistency with the other per-screen fixtures). Updates trailing sample-app mentions in comments and assertion messages. * refactor(folio): rename Gradle/npm/wasm project identifiers to folio settings.gradle.kts rootProject.name, package.json + package-lock.json name, and the WasmJS index.html <title> all still read 'uatu-sample' / 'Uatu Sample'. Realigns them with the Folio brand. * docs(folio): rewrite README title + getting-started bundle id examples/folio/README.md is now titled 'Folio' with the Kotlin source paths corrected to app/folio. Getting-started example uses --bundle-id app.folio. Harness launch message is now generic ('app under test') since uatu-sample-harness is not specific to folio. * chore(folio): drop trailing 'sample' reference in gradle.properties * refactor(folio): rename LoginPage composable to LoginScreen Align with KMP/Android industry convention (NowInAndroid, Cash App, JetBrains samples use Screen, not Page). * refactor(folio): rename HomePage composable to HomeScreen * refactor(folio): rename AddAccountPage composable to AddAccountScreen * refactor(folio): rename LedgerPage composable to LedgerScreen * refactor(folio): rename AddTransactionPage composable to AddTransactionScreen * refactor(folio): split Models.kt into app.folio.data package Account, Transaction (with TxnType), and Session move into their own files under app.folio.data, matching NowInAndroid-style per-type organization. * refactor(folio): move data layer into app.folio.data package Repository, LedgerStore (expect + interface), SqlLedgerStore, WebLedgerStore, DriverFactory (expect + actuals), AndroidLedgerContext, and Snapshot move into app.folio.data. Update all consumer imports. * refactor(folio): move Navigation into app.folio.navigation package Split the former Navigation.kt into Route.kt (sealed interface) and Navigator.kt (singleton). Update consumer imports across screens, App.kt, and FolioApplication. * refactor(folio): move Platform and Format into app.folio.platform Both files carry expect declarations (Platform object, formatDate); grouping them into a dedicated platform package makes the KMP seam obvious and mirrors the structure used by JetBrains samples. * refactor(folio): move login into feature/auth package Create app.folio.feature.auth with LoginScreen + LoginUiState. Inline the former Auth.kt (DEMO_EMAIL, DEMO_PASSWORD, checkCredentials) into LoginScreen since it is the sole caller. * refactor(folio): move HomeScreen into feature/home package * refactor(folio): move account creation into feature/account package AddAccountScreen gets its own AddAccountUiState colocated with the screen, replacing the shared UiState.addAccountError. * refactor(folio): move ledger screens into feature/ledger package LedgerScreen and AddTransactionScreen move into app.folio.feature.ledger with AddTransactionUiState (txnError, txnFormType) colocated. The former catch-all UiState.kt is removed now that each screen owns its state alongside its UI. * refactor(folio): split Theme.kt; move theme and icons to subpackages Theme split into Theme.kt (tokens, layout dims, LedgerTheme) and Type.kt (typography) under app.folio.ui.theme. Icons moves to app.folio.ui.icon. Update every consumer's imports to match. * refactor(folio): split ui components into per-file under ui/component Former Widgets.kt and Components.kt become 10 focused files: AppButton, Card, EmptyState, ErrorText, FieldLabel, Header, IconButton (w/ BackButton), Screen, Segmented, TextInput. Matches NowInAndroid style of one composable per file in a designsystem/component package. * chore(folio): consolidate uatu testing files under uatu/ folder Move spec.ts, package.json, package-lock.json into examples/folio/uatu so all uatu-specific testing artifacts live in one place. runs/ and node_modules/ follow the same convention (both remain gitignored). Update justfile, README, and the two Go consumers (bundle-check tool + verifier/trace tests) that referenced the old path. * refactor(trace): drop folio path in writer test Round-trip only needs a non-empty string; neutralize to keep the library free of folio references. * refactor(sidecar): neutralize ResolveActivity test fixtures Swap app.folio for com.example.app in the fixture strings so the sidecar tests don't reference the example app by name. * refactor(bundle-check): take spec path as argument Previously the tool hardcoded examples/folio/uatu/spec.ts. Accept a positional spec path instead so the tool works for any example and leaves no folio reference in the library surface. * test(verifier): add neutral integration spec and hierarchy fixtures Adds testdata/integration_spec.ts with two routes ("list", "form"), an InputText on text_field, a Tap on primary/secondary_action, a safety property (itemCountNonNegative), and a liveness property (submitEventually). Adds hierarchies_test.go with matching XML fixtures. Constants intentionally go in a _test.go at package root rather than testdata/hierarchies.go because go skips .go files under testdata/. * refactor(verifier): replace folio integration tests with neutral ones Renames bundleFolioSpec -> bundleIntegrationSpec and the three Test* entry points to TestIntegrationSpec*. Uses the synthetic spec and hierarchies added in the previous commit so the library's test suite no longer references examples/folio at all. Folio-specific coverage remains covered by examples/folio/justfile's 'just test' which exercises the real spec on device/emulator. * chore: remove cmd/uatu-sample-harness Not referenced by Makefile, docs, CI, or any script. Duplicates the adb reverse helpers already in cmd/uatu/test_run.go, and its name implies ownership by the sample app which violates the library/ example decoupling. If a bare-protocol debugging tool is later needed it belongs inside cmd/uatu/. * docs(folio): drop Layout section and KMP layout paragraph; fix AVD override syntax The directory-tree Layout section rots faster than the code and duplicates what ls shows for free. The expect/actual paragraph in Stack was the same kind of filler. The README also claimed 'just AVD=Pixel_7 test' but the justfile reads AVD as an env var via env_var_or_default, so the correct invocation is 'AVD=Pixel_7 just test'.
This commit is contained in:
88 files changed
+949
-900
No files matched your search
@@ -0,0 +1,12 @@
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
local.properties
|
||||
iosApp/build/
|
||||
iosApp/iosApp.xcodeproj/
|
||||
iosApp/iosApp.xcodeproj/**
|
||||
.DS_Store
|
||||
uatu/node_modules/
|
||||
uatu/runs/
|
||||
.playwright-mcp/
|
||||
web-*.png
|
||||
@@ -0,0 +1,78 @@
|
||||
# Folio
|
||||
|
||||
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
|
||||
uatu runs its property-based specs against.
|
||||
|
||||
## Stack
|
||||
|
||||
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
||||
- kotlinx.serialization for file-backed persistence
|
||||
- kotlinx.coroutines for state flows
|
||||
- uatu `sdk-android` for harness integration on Android
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `just`
|
||||
- JDK 17
|
||||
- Android SDK (auto-discovered under `$ANDROID_HOME`, `~/Library/Android/sdk`,
|
||||
or the Homebrew cask)
|
||||
- Xcode 16+ and `xcodegen` (`brew install xcodegen`) for iOS
|
||||
|
||||
## Android
|
||||
|
||||
```sh
|
||||
just install # build + install on a booted emulator / device
|
||||
just uninstall
|
||||
just clean
|
||||
```
|
||||
|
||||
## iOS
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
## Demo credentials
|
||||
|
||||
```
|
||||
email: [email protected]
|
||||
password: ledger123
|
||||
```
|
||||
|
||||
## Run a uatu test (Android)
|
||||
|
||||
```sh
|
||||
just test
|
||||
```
|
||||
|
||||
If no device is connected, uatu boots the single AVD it finds. With multiple
|
||||
AVDs, pick one:
|
||||
|
||||
```sh
|
||||
AVD=Pixel_7 just test
|
||||
```
|
||||
|
||||
Persistent settings can live in `.env` alongside the justfile:
|
||||
|
||||
```
|
||||
AVD=Pixel_7
|
||||
DURATION=5m
|
||||
```
|
||||
|
||||
Traces land in `./uatu/runs/<timestamp>/`.
|
||||
|
||||
## How it connects to uatu
|
||||
|
||||
- `composeApp/src/androidMain/.../FolioApplication.kt` calls `Uatu.start(this)`
|
||||
and registers snapshot extractors (`logged_in`, `account_count`,
|
||||
`total_balance`, `route`)
|
||||
- `uatu/spec.ts` imports `@uatu/spec`, reads those snapshots, asserts
|
||||
properties, and weights the actions the fuzzer picks from
|
||||
- `just test` invokes `uatu test` against the installed APK
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 uatuVersion = findProperty("uatu.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")
|
||||
implementation("io.github.priyanshujain:sdk-android:$uatuVersion")
|
||||
}
|
||||
|
||||
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 = uatuVersion
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,93 @@
|
||||
package app.folio
|
||||
|
||||
import android.app.Application
|
||||
import app.folio.data.AndroidLedgerContext
|
||||
import app.folio.data.Repository
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.feature.account.AddAccountUiState
|
||||
import app.folio.feature.auth.LoginUiState
|
||||
import app.folio.feature.ledger.AddTransactionUiState
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.signedAmount
|
||||
import dev.uatu.sdk.Uatu
|
||||
|
||||
class FolioApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AndroidLedgerContext.context = applicationContext
|
||||
Repository.init()
|
||||
Uatu.start(this)
|
||||
Uatu.extract("logged_in") { Repository.session.value != null }
|
||||
Uatu.extract("account_count") { Repository.accounts.value.size }
|
||||
Uatu.extract("total_balance") { balanceOf(Repository.transactions.value) }
|
||||
Uatu.extract("route") {
|
||||
when (Navigator.current.value) {
|
||||
Route.Login -> "login"
|
||||
Route.Home -> "home"
|
||||
Route.AddAccount -> "add-account"
|
||||
is Route.Ledger -> "ledger"
|
||||
is Route.AddTransaction -> "add-transaction"
|
||||
}
|
||||
}
|
||||
Uatu.extract("auth_status") {
|
||||
if (Repository.session.value != null) "logged-in" else "logged-out"
|
||||
}
|
||||
Uatu.extract("accounts") {
|
||||
val txns = Repository.transactions.value
|
||||
Repository.accounts.value.map { a ->
|
||||
val rows = txns.filter { it.accountId == a.id }
|
||||
mapOf(
|
||||
"id" to a.id,
|
||||
"name" to a.name,
|
||||
"balance" to balanceOf(rows),
|
||||
"txnCount" to rows.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
Uatu.extract("active_account_id") {
|
||||
when (val r = Navigator.current.value) {
|
||||
is Route.Ledger -> r.accountId
|
||||
is Route.AddTransaction -> r.accountId
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
Uatu.extract("ledger_rows") {
|
||||
val active = when (val r = Navigator.current.value) {
|
||||
is Route.Ledger -> r.accountId
|
||||
is Route.AddTransaction -> r.accountId
|
||||
else -> null
|
||||
}
|
||||
if (active == null) emptyList()
|
||||
else Repository.transactions.value
|
||||
.filter { it.accountId == active }
|
||||
.map {
|
||||
mapOf(
|
||||
"id" to it.id,
|
||||
"accountId" to it.accountId,
|
||||
"type" to if (it.type == TxnType.credit) "credit" else "debit",
|
||||
"amount" to it.amount,
|
||||
"signed" to signedAmount(it),
|
||||
)
|
||||
}
|
||||
}
|
||||
Uatu.extract("ledger_balance") {
|
||||
val active = when (val r = Navigator.current.value) {
|
||||
is Route.Ledger -> r.accountId
|
||||
is Route.AddTransaction -> r.accountId
|
||||
else -> null
|
||||
}
|
||||
if (active == null) 0L
|
||||
else balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
||||
}
|
||||
Uatu.extract("focused_input") { FocusTracker.current.value }
|
||||
Uatu.extract("txn_form_type") { AddTransactionUiState.txnFormType.value }
|
||||
Uatu.extract("txn_form_account_id") {
|
||||
(Navigator.current.value as? Route.AddTransaction)?.accountId
|
||||
}
|
||||
Uatu.extract("login_error") { LoginUiState.loginError.value }
|
||||
Uatu.extract("add_account_error") { AddAccountUiState.addAccountError.value }
|
||||
Uatu.extract("txn_error") { AddTransactionUiState.txnError.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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() }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package app.folio.data
|
||||
|
||||
import android.content.Context
|
||||
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")
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package app.folio.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AndroidLedgerContext {
|
||||
lateinit var context: Context
|
||||
}
|
||||
|
||||
actual fun createLedgerStore(): LedgerStore =
|
||||
SqlLedgerStore(DriverFactory(AndroidLedgerContext.context))
|
||||
@@ -0,0 +1,9 @@
|
||||
package app.folio.platform
|
||||
|
||||
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))
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package app.folio.platform
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
actual object Platform {
|
||||
actual fun now(): Long = System.currentTimeMillis()
|
||||
actual fun makeId(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
@@ -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,68 @@
|
||||
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.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 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.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()
|
||||
|
||||
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),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.folio.data
|
||||
|
||||
data class Account(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.folio.data
|
||||
|
||||
import app.folio.platform.Platform
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
object Repository {
|
||||
private var _store: LedgerStore? = null
|
||||
private val store: LedgerStore
|
||||
get() = _store ?: error("Repository not initialized")
|
||||
|
||||
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 {
|
||||
val trimmed = name.trim()
|
||||
require(trimmed.isNotEmpty()) { "Name is required" }
|
||||
require(trimmed.length <= 40) { "Name is too long (max 40)" }
|
||||
require(!store.accountExistsByName(trimmed)) { "An account with that name already exists" }
|
||||
val account = Account(id = Platform.makeId(), name = trimmed, createdAt = Platform.now())
|
||||
store.insertAccount(account.id, account.name, account.createdAt)
|
||||
return account
|
||||
}
|
||||
|
||||
fun getAccount(id: String): Account? = accounts.value.firstOrNull { it.id == id }
|
||||
|
||||
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(
|
||||
id = Platform.makeId(),
|
||||
accountId = accountId,
|
||||
type = type,
|
||||
amount = amount,
|
||||
note = note.trim().take(80),
|
||||
createdAt = Platform.now(),
|
||||
)
|
||||
store.insertTxn(txn.id, txn.accountId, txn.type, txn.amount, txn.note, txn.createdAt)
|
||||
return txn
|
||||
}
|
||||
|
||||
fun setSession(user: String) = store.upsertSession(user, Platform.now())
|
||||
|
||||
fun clearSession() = store.clearSession()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package app.folio.data
|
||||
|
||||
data class Session(
|
||||
val user: String,
|
||||
val loggedInAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package app.folio.data
|
||||
|
||||
enum class TxnType { credit, debit }
|
||||
|
||||
data class Transaction(
|
||||
val id: String,
|
||||
val accountId: String,
|
||||
val type: TxnType,
|
||||
val amount: Long,
|
||||
val note: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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.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.unit.dp
|
||||
import app.folio.data.Repository
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
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 AddAccountScreen() {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
Screen(
|
||||
header = {
|
||||
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
|
||||
},
|
||||
footer = {
|
||||
AppButton(
|
||||
text = "Create account",
|
||||
onClick = ::submit,
|
||||
style = ButtonStyle.Primary,
|
||||
enabled = name.trim().isNotEmpty(),
|
||||
description = "add_account_submit",
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FieldLabel("Account name")
|
||||
TextInput(
|
||||
value = name,
|
||||
onChange = { name = it; AddAccountUiState.addAccountError.value = "" },
|
||||
placeholder = "e.g. Checking",
|
||||
invalid = err.isNotEmpty(),
|
||||
label = "Account name",
|
||||
description = "account_name_field",
|
||||
)
|
||||
}
|
||||
ErrorText(err)
|
||||
Text(
|
||||
"Use a short, recognizable name. You can create as many accounts as you need.",
|
||||
style = Type.caption,
|
||||
color = t.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package app.folio.feature.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object AddAccountUiState {
|
||||
val addAccountError = MutableStateFlow("")
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
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.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.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.data.Repository
|
||||
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
|
||||
|
||||
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 LoginScreen(onLoggedIn: (String) -> 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 {
|
||||
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 = email,
|
||||
onChange = { email = it; LoginUiState.loginError.value = "" },
|
||||
placeholder = DEMO_EMAIL,
|
||||
invalid = err.isNotEmpty(),
|
||||
keyboardType = KeyboardType.Email,
|
||||
label = "Email",
|
||||
description = "login_email",
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FieldLabel("Password")
|
||||
TextInput(
|
||||
value = password,
|
||||
onChange = { password = it; LoginUiState.loginError.value = "" },
|
||||
placeholder = "••••••••",
|
||||
password = true,
|
||||
invalid = err.isNotEmpty(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
label = "Password",
|
||||
description = "login_password",
|
||||
)
|
||||
}
|
||||
ErrorText(err)
|
||||
AppButton(
|
||||
text = "Sign in",
|
||||
onClick = ::submit,
|
||||
style = ButtonStyle.Primary,
|
||||
description = "login_submit",
|
||||
)
|
||||
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,7 @@
|
||||
package app.folio.feature.auth
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object LoginUiState {
|
||||
val loginError = MutableStateFlow("")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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 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 HomeScreen(user: String, onLogout: () -> Unit) {
|
||||
val t = LocalTokens.current
|
||||
val accounts by Repository.accounts.collectAsState()
|
||||
val txns by Repository.transactions.collectAsState()
|
||||
val total = balanceOf(txns)
|
||||
|
||||
Screen(
|
||||
header = {
|
||||
Header(
|
||||
title = "Accounts",
|
||||
subtitle = user,
|
||||
right = {
|
||||
IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout)
|
||||
},
|
||||
)
|
||||
},
|
||||
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(
|
||||
"${accounts.size} account${if (accounts.size == 1) "" else "s"}",
|
||||
style = Type.caption,
|
||||
color = t.textFaint,
|
||||
)
|
||||
}
|
||||
AppButton(
|
||||
text = "+ Add account",
|
||||
onClick = { Navigator.push(Route.AddAccount) },
|
||||
style = ButtonStyle.Primary,
|
||||
description = "add_account_button",
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (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)) {
|
||||
accounts.forEach { a ->
|
||||
val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
|
||||
val count = txns.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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountCard(
|
||||
id: String,
|
||||
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))
|
||||
.semantics(mergeDescendants = true) { contentDescription = "account_card:$id" }
|
||||
.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,
|
||||
)
|
||||
Text(txnLabel, style = Type.caption, color = t.textMuted)
|
||||
}
|
||||
Text(formatCents(balance), style = Type.bodyStrong, color = t.text)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
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.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(
|
||||
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
@@ -0,0 +1,8 @@
|
||||
package app.folio.feature.ledger
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object AddTransactionUiState {
|
||||
val txnError = MutableStateFlow("")
|
||||
val txnFormType = MutableStateFlow<String?>(null)
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
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.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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 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 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 LedgerScreen(accountId: String) {
|
||||
val t = LocalTokens.current
|
||||
val accounts by Repository.accounts.collectAsState()
|
||||
val allTxns by Repository.transactions.collectAsState()
|
||||
|
||||
BackHandler { Navigator.back(Route.Home) }
|
||||
|
||||
val account = accounts.firstOrNull { it.id == accountId }
|
||||
if (account == null) {
|
||||
Screen(
|
||||
header = {
|
||||
Header(title = "Account", left = { BackButton { Navigator.back(Route.Home) } })
|
||||
},
|
||||
) {
|
||||
EmptyState(title = "Account not found", subtitle = "It may have been deleted.")
|
||||
AppButton(
|
||||
text = "Back to accounts",
|
||||
onClick = { Navigator.replace(Route.Home) },
|
||||
style = ButtonStyle.Secondary,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val txns = allTxns
|
||||
.filter { it.accountId == accountId }
|
||||
.sortedByDescending { it.createdAt }
|
||||
val balance = balanceOf(txns)
|
||||
|
||||
Screen(
|
||||
header = {
|
||||
Header(
|
||||
title = account.name,
|
||||
subtitle = "Ledger",
|
||||
left = { BackButton { Navigator.back(Route.Home) } },
|
||||
)
|
||||
},
|
||||
footer = {
|
||||
AppButton(
|
||||
text = "+ Add transaction",
|
||||
onClick = { Navigator.push(Route.AddTransaction(accountId)) },
|
||||
style = ButtonStyle.Primary,
|
||||
description = "add_txn_button",
|
||||
)
|
||||
},
|
||||
) {
|
||||
Card {
|
||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||
Text(formatCents(balance), style = Type.balance, color = t.text)
|
||||
}
|
||||
Text(
|
||||
"ACTIVITY",
|
||||
style = Type.label,
|
||||
color = t.textFaint,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
|
||||
)
|
||||
if (txns.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),
|
||||
) {
|
||||
txns.forEachIndexed { i, txn ->
|
||||
TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
|
||||
if (i != txns.lastIndex) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TxnRow(id: String, 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(mergeDescendants = true) { contentDescription = "txn_row:$id" },
|
||||
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,
|
||||
)
|
||||
Text(date, style = Type.caption, color = t.textFaint)
|
||||
}
|
||||
Text(
|
||||
formatCents(signed, signed = true),
|
||||
style = Type.bodyStrong,
|
||||
color = t.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package app.folio.platform
|
||||
|
||||
import app.folio.data.Transaction
|
||||
import app.folio.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,6 @@
|
||||
package app.folio.platform
|
||||
|
||||
expect object Platform {
|
||||
fun now(): Long
|
||||
fun makeId(): String
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package app.folio.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
expect fun BackHandler(onBack: () -> Unit)
|
||||
@@ -0,0 +1,57 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
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.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
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
enum class ButtonStyle { Primary, Secondary, Ghost }
|
||||
|
||||
@Composable
|
||||
fun AppButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
style: ButtonStyle = ButtonStyle.Secondary,
|
||||
enabled: Boolean = true,
|
||||
description: String? = null,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
val (bg, fg, border) = when (style) {
|
||||
ButtonStyle.Primary -> Triple(t.text, t.onAccent, t.text)
|
||||
ButtonStyle.Secondary -> Triple(t.surface2, t.text, t.borderStrong)
|
||||
ButtonStyle.Ghost -> Triple(t.bg, t.textMuted, t.bg)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
)
|
||||
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.padding(vertical = 14.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = Type.button, color = if (enabled) fg else t.textFaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.RadiusLg
|
||||
|
||||
@Composable
|
||||
fun Card(
|
||||
modifier: Modifier = Modifier,
|
||||
dashed: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RadiusLg))
|
||||
.background(if (dashed) t.surface2 else t.surface)
|
||||
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) { content() }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
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.Spacer
|
||||
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.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun EmptyState(title: String, subtitle: String, icon: ImageVector? = null) {
|
||||
val t = LocalTokens.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.background(t.surface2, CircleShape)
|
||||
.border(1.dp, t.border, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(icon),
|
||||
contentDescription = null,
|
||||
tint = t.textMuted,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
Text(title, style = Type.bodyStrong, color = t.text, textAlign = TextAlign.Center)
|
||||
Text(subtitle, style = Type.caption, color = t.textMuted, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun ErrorText(err: String?) {
|
||||
val t = LocalTokens.current
|
||||
Text(
|
||||
err ?: "",
|
||||
style = Type.caption,
|
||||
color = t.text,
|
||||
modifier = Modifier.fillMaxWidth().height(18.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun FieldLabel(text: String) {
|
||||
val t = LocalTokens.current
|
||||
Text(text.uppercase(), style = Type.label, color = t.textMuted)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.ScreenPad
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun Header(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
left: @Composable (() -> Unit)? = null,
|
||||
right: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = ScreenPad, end = ScreenPad, top = 14.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (left != null) left()
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
style = Type.title,
|
||||
color = t.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, style = Type.caption, color = t.textMuted)
|
||||
}
|
||||
}
|
||||
if (right != null) right()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.icon.Icons
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
onClick: () -> Unit,
|
||||
description: String,
|
||||
icon: ImageVector,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(t.surface2)
|
||||
.border(1.dp, t.border, RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = description },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(icon),
|
||||
contentDescription = null,
|
||||
tint = t.text,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackButton(onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick, description = "Back", icon = Icons.Back)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.ScreenPad
|
||||
|
||||
@Composable
|
||||
fun Screen(
|
||||
header: @Composable (() -> Unit)? = null,
|
||||
footer: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (header != null) header()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ScreenPad, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
content()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
if (footer != null) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(t.bg)
|
||||
.padding(horizontal = ScreenPad, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
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.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
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.RadiusMd
|
||||
import app.folio.ui.theme.RadiusSm
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun Segmented(
|
||||
selected: Int,
|
||||
labels: List<String>,
|
||||
onSelect: (Int) -> Unit,
|
||||
descriptions: List<String>? = null,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RadiusMd))
|
||||
.background(t.surface)
|
||||
.border(1.dp, t.border, RoundedCornerShape(RadiusMd))
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
labels.forEachIndexed { i, label ->
|
||||
val active = i == selected
|
||||
val desc = descriptions?.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
|
||||
}
|
||||
.clickable(role = Role.Tab) { onSelect(i) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = Type.button, color = if (active) t.text else t.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package app.folio.ui.component
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun TextInput(
|
||||
value: String,
|
||||
onChange: (String) -> Unit,
|
||||
placeholder: String = "",
|
||||
invalid: Boolean = false,
|
||||
password: Boolean = false,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
textAlign: TextAlign = TextAlign.Start,
|
||||
textStyle: TextStyle = Type.body,
|
||||
label: String? = null,
|
||||
description: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val t = LocalTokens.current
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val borderColor = if (focused || invalid) t.text else t.border
|
||||
val bg = if (focused) t.surface2 else t.surface
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(RadiusMd))
|
||||
.background(bg)
|
||||
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(RadiusMd))
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
contentAlignment = if (textAlign == TextAlign.Center) Alignment.Center else Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty() && placeholder.isNotEmpty()) {
|
||||
Text(
|
||||
placeholder,
|
||||
style = textStyle.copy(color = t.textFaint, textAlign = textAlign),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
textStyle = textStyle.copy(color = t.text, textAlign = textAlign),
|
||||
singleLine = true,
|
||||
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
cursorBrush = SolidColor(t.text),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (description != null) {
|
||||
if (it.isFocused) FocusTracker.enter(description)
|
||||
else FocusTracker.leave(description)
|
||||
}
|
||||
}
|
||||
.semantics {
|
||||
val desc = description ?: label
|
||||
if (desc != null) contentDescription = desc
|
||||
if (invalid) stateDescription = "Invalid"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app.folio.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object Icons {
|
||||
val Back: ImageVector = strokeIcon("M15 18 L9 12 L15 6")
|
||||
|
||||
val Logout: ImageVector = strokeIcon(
|
||||
"M9 21 H5 A2 2 0 0 1 3 19 V5 A2 2 0 0 1 5 3 H9 " +
|
||||
"M16 17 L21 12 L16 7 " +
|
||||
"M21 12 H9",
|
||||
)
|
||||
|
||||
val Bank: ImageVector = strokeIcon(
|
||||
"M3 6 H21 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 Z " +
|
||||
"M3 10 H21",
|
||||
)
|
||||
|
||||
val Lines: ImageVector = strokeIcon(
|
||||
"M3 6 H21 " +
|
||||
"M3 12 H21 " +
|
||||
"M3 18 H15",
|
||||
)
|
||||
}
|
||||
|
||||
private fun strokeIcon(path: String): ImageVector =
|
||||
ImageVector.Builder(
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
).addPath(
|
||||
pathData = addPathNodes(path),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f,
|
||||
strokeLineCap = StrokeCap.Round,
|
||||
strokeLineJoin = StrokeJoin.Round,
|
||||
).build()
|
||||
@@ -0,0 +1,45 @@
|
||||
package app.folio.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class Tokens(
|
||||
val bg: Color = Color(0xFFFFFFFF),
|
||||
val surface: Color = Color(0xFFFAFAFA),
|
||||
val surface2: Color = Color(0xFFF4F4F4),
|
||||
val surface3: Color = Color(0xFFEBEBEB),
|
||||
val border: Color = Color(0xFFE7E7E7),
|
||||
val borderStrong: Color = Color(0xFFD6D6D6),
|
||||
val text: Color = Color(0xFF000000),
|
||||
val textMuted: Color = Color(0xFF6B6B6B),
|
||||
val textFaint: Color = Color(0xFF9A9A9A),
|
||||
val onAccent: Color = Color(0xFFFFFFFF),
|
||||
)
|
||||
|
||||
val LocalTokens = staticCompositionLocalOf { Tokens() }
|
||||
|
||||
@Composable
|
||||
fun LedgerTheme(content: @Composable () -> Unit) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val dark = isSystemInDarkTheme()
|
||||
val scheme = lightColorScheme(
|
||||
background = Color(0xFFFFFFFF),
|
||||
surface = Color(0xFFFAFAFA),
|
||||
primary = Color(0xFF000000),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
)
|
||||
MaterialTheme(colorScheme = scheme) { content() }
|
||||
}
|
||||
|
||||
val FrameWidth = 420.dp
|
||||
val FrameHeight = 900.dp
|
||||
val ScreenPad = 20.dp
|
||||
val RadiusSm = 8.dp
|
||||
val RadiusMd = 12.dp
|
||||
val RadiusLg = 16.dp
|
||||
val RadiusPill = 999.dp
|
||||
@@ -0,0 +1,20 @@
|
||||
package app.folio.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Mono: FontFamily = FontFamily.Monospace
|
||||
|
||||
object Type {
|
||||
val body = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Normal)
|
||||
val bodyStrong = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
val title = TextStyle(fontFamily = Mono, fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
||||
val caption = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal)
|
||||
val label = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.7.sp)
|
||||
val balance = TextStyle(fontFamily = Mono, fontSize = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
||||
val amountInput = TextStyle(fontFamily = Mono, fontSize = 36.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
||||
val button = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
val brand = TextStyle(fontFamily = Mono, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE Accounts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE Txns (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
accountId TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX txn_account ON Txns(accountId);
|
||||
CREATE INDEX txn_created ON Txns(createdAt);
|
||||
|
||||
CREATE TABLE AppSession (
|
||||
key INTEGER NOT NULL PRIMARY KEY CHECK (key = 1),
|
||||
user TEXT NOT NULL,
|
||||
loggedInAt INTEGER NOT NULL
|
||||
);
|
||||
|
||||
selectAllAccounts:
|
||||
SELECT * FROM Accounts ORDER BY createdAt ASC;
|
||||
|
||||
accountExistsByName:
|
||||
SELECT EXISTS(SELECT 1 FROM Accounts WHERE name = :name COLLATE NOCASE);
|
||||
|
||||
insertAccount:
|
||||
INSERT INTO Accounts(id, name, createdAt) VALUES (?, ?, ?);
|
||||
|
||||
selectAllTxns:
|
||||
SELECT * FROM Txns;
|
||||
|
||||
insertTxn:
|
||||
INSERT INTO Txns(id, accountId, type, amount, note, createdAt) VALUES (?, ?, ?, ?, ?, ?);
|
||||
|
||||
selectSession:
|
||||
SELECT user, loggedInAt FROM AppSession WHERE key = 1;
|
||||
|
||||
upsertSession:
|
||||
INSERT OR REPLACE INTO AppSession(key, user, loggedInAt) VALUES (1, ?, ?);
|
||||
|
||||
clearSession:
|
||||
DELETE FROM AppSession WHERE key = 1;
|
||||
@@ -0,0 +1,40 @@
|
||||
package app.folio
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
import app.folio.data.Repository
|
||||
import app.folio.ui.IosBackGesture
|
||||
import kotlin.native.ObjCName
|
||||
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 {
|
||||
Repository.init()
|
||||
val vc = ComposeUIViewController { App() }
|
||||
val gesture = UIScreenEdgePanGestureRecognizer(
|
||||
target = backGestureTarget,
|
||||
action = NSSelectorFromString("handleGesture:"),
|
||||
)
|
||||
gesture.edges = UIRectEdgeLeft
|
||||
vc.view.addGestureRecognizer(gesture)
|
||||
return vc
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package app.folio.data
|
||||
|
||||
actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory())
|
||||
@@ -0,0 +1,16 @@
|
||||
package app.folio.platform
|
||||
|
||||
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,10 @@
|
||||
package app.folio.platform
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSUUID
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
|
||||
actual object Platform {
|
||||
actual fun now(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||
actual fun makeId(): String = NSUUID().UUIDString
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.folio.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
@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,7 @@
|
||||
package app.folio.data
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
expect class DriverFactory {
|
||||
fun create(): SqlDriver
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app.folio.data
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||
import app.folio.db.LedgerDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
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
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override val accounts: StateFlow<List<Account>> =
|
||||
q.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()
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.map { rows -> rows.map { it.toDomain() } }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
override val session: StateFlow<Session?> =
|
||||
q.selectSession()
|
||||
.asFlow()
|
||||
.mapToOneOrNull(Dispatchers.Default)
|
||||
.map { it?.let { row -> Session(row.user, row.loggedInAt) } }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
override fun accountExistsByName(name: String): Boolean =
|
||||
q.accountExistsByName(name).executeAsOne()
|
||||
|
||||
override fun insertAccount(id: String, name: String, createdAt: Long) {
|
||||
q.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 fun upsertSession(user: String, loggedInAt: Long) {
|
||||
q.upsertSession(user, loggedInAt)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
q.clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun app.folio.db.Txns.toDomain(): Transaction =
|
||||
Transaction(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
type = TxnType.valueOf(type),
|
||||
amount = amount,
|
||||
note = note,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 = "uatu.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()
|
||||
@@ -0,0 +1,22 @@
|
||||
package app.folio
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import app.folio.data.Repository
|
||||
import app.folio.ui.WebBackGesture
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import org.w3c.dom.events.Event
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
Repository.init()
|
||||
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() }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package app.folio.platform
|
||||
|
||||
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()")
|
||||
@@ -0,0 +1,22 @@
|
||||
package app.folio.platform
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
actual object Platform {
|
||||
actual fun now(): Long = jsNow().toLong()
|
||||
actual fun makeId(): String = cryptoRandomUuid() ?: fallbackUuid()
|
||||
}
|
||||
|
||||
private fun jsNow(): Double = js("Date.now()")
|
||||
|
||||
private fun cryptoRandomUuid(): String? = js(
|
||||
"(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') ? crypto.randomUUID() : null"
|
||||
)
|
||||
|
||||
private fun fallbackUuid(): String {
|
||||
val bytes = ByteArray(16) { Random.nextInt(0, 256).toByte() }
|
||||
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
|
||||
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
|
||||
val hex = bytes.joinToString("") { ((it.toInt() and 0xff) or 0x100).toString(16).substring(1) }
|
||||
return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}"
|
||||
}
|
||||
@@ -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,17 @@
|
||||
<!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,10 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
kotlin.code.style=official
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Pin the uatu SDK version consumed by this app. Override with
|
||||
# `-Puatu.version=<version>`.
|
||||
uatu.version=0.0.0-dev
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Folio</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Folio</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ComposeView()
|
||||
.ignoresSafeArea(.all)
|
||||
.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
name: iosApp
|
||||
options:
|
||||
bundleIdPrefix: app.folio
|
||||
deploymentTarget:
|
||||
iOS: "15.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
SWIFT_VERSION: "5.0"
|
||||
ONLY_ACTIVE_ARCH: YES
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64
|
||||
|
||||
targets:
|
||||
iosApp:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- iosApp
|
||||
dependencies:
|
||||
- framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework
|
||||
embed: false
|
||||
- sdk: libsqlite3.tbd
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: app.folio
|
||||
PRODUCT_NAME: iosApp
|
||||
INFOPLIST_FILE: iosApp/Info.plist
|
||||
FRAMEWORK_SEARCH_PATHS:
|
||||
- $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
|
||||
OTHER_LDFLAGS:
|
||||
- -ObjC
|
||||
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
|
||||
preBuildScripts:
|
||||
- name: Build Kotlin framework
|
||||
script: |
|
||||
cd "$SRCROOT/.."
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
|
||||
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
|
||||
outputFiles:
|
||||
- $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp
|
||||
basedOnDependencyAnalysis: false
|
||||
@@ -0,0 +1,99 @@
|
||||
set shell := ["bash", "-cu"]
|
||||
set dotenv-load := true
|
||||
|
||||
uatu := env_var_or_default("UATU", "uatu")
|
||||
avd := env_var_or_default("AVD", "")
|
||||
duration := env_var_or_default("DURATION", "1m")
|
||||
seed := env_var_or_default("SEED", "0")
|
||||
output := env_var_or_default("OUTPUT", justfile_directory() / "uatu" / "runs")
|
||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Resolve ANDROID_HOME from the env or a short list of canonical install
|
||||
# locations, so Gradle can find the Android SDK without the user having to
|
||||
# export anything.
|
||||
_android-home:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ -n "${ANDROID_HOME:-}" && -d "$ANDROID_HOME" ]]; then
|
||||
echo "$ANDROID_HOME"; exit 0
|
||||
fi
|
||||
if [[ -n "${ANDROID_SDK_ROOT:-}" && -d "$ANDROID_SDK_ROOT" ]]; then
|
||||
echo "$ANDROID_SDK_ROOT"; exit 0
|
||||
fi
|
||||
for candidate in \
|
||||
"$HOME/Library/Android/sdk" \
|
||||
"$HOME/Android/Sdk" \
|
||||
"/opt/homebrew/share/android-commandlinetools" \
|
||||
"/usr/local/share/android-commandlinetools"; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
echo "$candidate"; exit 0
|
||||
fi
|
||||
done
|
||||
echo "could not locate Android SDK (set ANDROID_HOME)" >&2
|
||||
exit 1
|
||||
|
||||
# Build and install the folio APK on a running emulator/device.
|
||||
install:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
./gradlew :composeApp: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
|
||||
|
||||
# Remove gradle + iOS build directories.
|
||||
clean:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
./gradlew clean
|
||||
rm -rf iosApp/build
|
||||
|
||||
# Regenerate iosApp.xcodeproj from project.yml.
|
||||
ios-gen:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd iosApp && xcodegen generate
|
||||
|
||||
# Build + install + launch on a booted iOS simulator (boots IOS_DEVICE if none).
|
||||
ios:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
if ! xcrun simctl list devices booted | grep -q Booted; then
|
||||
xcrun simctl boot "{{ios_device}}"
|
||||
open -a Simulator
|
||||
sleep 3
|
||||
fi
|
||||
just ios-gen
|
||||
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
|
||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
||||
-derivedDataPath iosApp/build \
|
||||
build | tail -5
|
||||
xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app
|
||||
xcrun simctl launch booted app.folio
|
||||
|
||||
# Run 'uatu test' against the folio app. Uses a connected device if one is
|
||||
# online, otherwise boots AVD=<name> if provided.
|
||||
test:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
avd_flag=()
|
||||
if [[ -n "{{avd}}" ]]; then
|
||||
avd_flag=(--avd "{{avd}}")
|
||||
fi
|
||||
"{{uatu}}" test \
|
||||
--spec "{{justfile_directory()}}/uatu/spec.ts" \
|
||||
--bundle-id app.folio \
|
||||
"${avd_flag[@]}" \
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
--output "{{output}}"
|
||||
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@js-joda/[email protected]":
|
||||
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==
|
||||
@@ -0,0 +1,20 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "folio"
|
||||
|
||||
include(":composeApp")
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "folio",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "folio",
|
||||
"devDependencies": {
|
||||
"@uatu/spec": "next"
|
||||
}
|
||||
},
|
||||
"node_modules/@uatu/spec": {
|
||||
"version": "0.0.1-rc3",
|
||||
"resolved": "https://registry.npmjs.org/@uatu/spec/-/spec-0.0.1-rc3.tgz",
|
||||
"integrity": "sha512-mCayssOqM2wfzrQDnFGE8FAlUlDzBl1fx2L/MLDIuPrQHBMlUC350xBB8Z7I/DYI98eCWJMqjtw3iU6yVWKtIQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "folio",
|
||||
"private": true,
|
||||
"description": "Minimal uatu integration example.",
|
||||
"devDependencies": {
|
||||
"@uatu/spec": "next"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
InputText,
|
||||
Tap,
|
||||
actions,
|
||||
always,
|
||||
eventually,
|
||||
extract,
|
||||
from,
|
||||
next,
|
||||
now,
|
||||
pressKey,
|
||||
swipes,
|
||||
taps,
|
||||
waitOnce,
|
||||
weighted,
|
||||
} from "@uatu/spec";
|
||||
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
|
||||
|
||||
interface AccountSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
txnCount: number;
|
||||
}
|
||||
|
||||
interface LedgerRow {
|
||||
id: string;
|
||||
accountId: string;
|
||||
type: "credit" | "debit";
|
||||
amount: number;
|
||||
signed: number;
|
||||
}
|
||||
|
||||
const loggedIn = extract<boolean>(
|
||||
(state) => (state.snapshots.logged_in as boolean) ?? false,
|
||||
);
|
||||
const authStatus = extract<string>(
|
||||
(state) => (state.snapshots.auth_status as string) ?? "",
|
||||
);
|
||||
const route = extract<string>(
|
||||
(state) => (state.snapshots.route as string) ?? "",
|
||||
);
|
||||
const accounts = extract<AccountSnapshot[]>(
|
||||
(state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [],
|
||||
);
|
||||
const totalBalance = extract<number>(
|
||||
(state) => (state.snapshots.total_balance as number) ?? 0,
|
||||
);
|
||||
const accountCount = extract<number>(
|
||||
(state) => (state.snapshots.account_count as number) ?? 0,
|
||||
);
|
||||
const activeAccountId = extract<string | null>(
|
||||
(state) => (state.snapshots.active_account_id as string | null) ?? null,
|
||||
);
|
||||
const ledgerRows = extract<LedgerRow[]>(
|
||||
(state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [],
|
||||
);
|
||||
const ledgerBalance = extract<number>(
|
||||
(state) => (state.snapshots.ledger_balance as number) ?? 0,
|
||||
);
|
||||
const focusedInput = extract<string | null>(
|
||||
(state) => (state.snapshots.focused_input as string | null) ?? null,
|
||||
);
|
||||
const txnFormType = extract<string | null>(
|
||||
(state) => (state.snapshots.txn_form_type as string | null) ?? null,
|
||||
);
|
||||
const txnFormAccountId = extract<string | null>(
|
||||
(state) => (state.snapshots.txn_form_account_id as string | null) ?? null,
|
||||
);
|
||||
const loginError = extract<string>(
|
||||
(state) => (state.snapshots.login_error as string) ?? "",
|
||||
);
|
||||
const addAccountError = extract<string>(
|
||||
(state) => (state.snapshots.add_account_error as string) ?? "",
|
||||
);
|
||||
const txnError = extract<string>(
|
||||
(state) => (state.snapshots.txn_error as string) ?? "",
|
||||
);
|
||||
const loginEmailField = extract((state) => state.ax.find("desc:login_email"));
|
||||
const loginPasswordField = extract((state) => state.ax.find("desc:login_password"));
|
||||
const loginSubmitButton = extract((state) => state.ax.find("desc:login_submit"));
|
||||
const addAccountButton = extract((state) => state.ax.find("desc:add_account_button"));
|
||||
const logoutButton = extract((state) => state.ax.find("desc:logout_button"));
|
||||
const accountNameField = extract((state) => state.ax.find("desc:account_name_field"));
|
||||
const addAccountSubmit = extract((state) => state.ax.find("desc:add_account_submit"));
|
||||
const addTxnButton = extract((state) => state.ax.find("desc:add_txn_button"));
|
||||
const txnAmountField = extract((state) => state.ax.find("desc:txn_amount"));
|
||||
const txnNoteField = extract((state) => state.ax.find("desc:txn_note"));
|
||||
const txnCredit = extract((state) => state.ax.find("desc:txn_credit"));
|
||||
const txnDebit = extract((state) => state.ax.find("desc:txn_debit"));
|
||||
const txnSubmit = extract((state) => state.ax.find("desc:txn_submit"));
|
||||
const backButton = extract((state) => state.ax.find("desc:Back"));
|
||||
const allAccountCards = extract((state) =>
|
||||
state.ax.findAll("descPrefix:account_card:"),
|
||||
);
|
||||
|
||||
const accountCountNonNegative = always(() => accountCount.current >= 0);
|
||||
|
||||
const onHome = () => route.current === "home";
|
||||
const onLedger = () =>
|
||||
route.current === "ledger" || route.current === "add-transaction";
|
||||
const isInteger = (n: number) => Number.isFinite(n) && Math.floor(n) === n;
|
||||
|
||||
const totalBalanceMatchesAccounts = always(
|
||||
now(onHome).implies(
|
||||
now(() => {
|
||||
const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0);
|
||||
return sum === totalBalance.current;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const ledgerBalanceMatchesRows = always(
|
||||
now(onLedger).implies(
|
||||
now(() => {
|
||||
const sum = ledgerRows.current.reduce((acc, r) => acc + r.signed, 0);
|
||||
return sum === ledgerBalance.current;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const ledgerRowsWellFormed = always(() => {
|
||||
for (const row of ledgerRows.current) {
|
||||
if (row.type !== "credit" && row.type !== "debit") return false;
|
||||
if (!(row.amount > 0)) return false;
|
||||
const expected = row.type === "credit" ? row.amount : -row.amount;
|
||||
if (row.signed !== expected) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const balancesAreIntegerCents = always(() => {
|
||||
if (!isInteger(totalBalance.current)) return false;
|
||||
if (!isInteger(ledgerBalance.current)) return false;
|
||||
for (const a of accounts.current) if (!isInteger(a.balance)) return false;
|
||||
for (const r of ledgerRows.current) {
|
||||
if (!isInteger(r.amount) || !isInteger(r.signed)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const accountCountMatchesList = always(
|
||||
() => accountCount.current === accounts.current.length,
|
||||
);
|
||||
|
||||
const ledgerCountMatchesRows = always(
|
||||
now(onLedger).implies(
|
||||
now(() => {
|
||||
const active = activeAccountId.current;
|
||||
if (active === null) return true;
|
||||
const fromAccounts = accounts.current.find((a) => a.id === active);
|
||||
if (!fromAccounts) return true;
|
||||
return fromAccounts.txnCount === ledgerRows.current.length;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const zeroTxnsMeansZeroBalance = always(() => {
|
||||
for (const a of accounts.current) {
|
||||
if (a.txnCount === 0 && a.balance !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const noOrphanTransactions = always(() => {
|
||||
const active = activeAccountId.current;
|
||||
if (active === null) return ledgerRows.current.length === 0;
|
||||
return ledgerRows.current.every((r) => r.accountId === active);
|
||||
});
|
||||
|
||||
const uniqueAccountNames = always(() => {
|
||||
const seen = new Set<string>();
|
||||
for (const a of accounts.current) {
|
||||
const key = a.name.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const accountingInvariants = {
|
||||
totalBalanceMatchesAccounts,
|
||||
ledgerBalanceMatchesRows,
|
||||
ledgerRowsWellFormed,
|
||||
balancesAreIntegerCents,
|
||||
accountCountMatchesList,
|
||||
ledgerCountMatchesRows,
|
||||
zeroTxnsMeansZeroBalance,
|
||||
noOrphanTransactions,
|
||||
uniqueAccountNames,
|
||||
};
|
||||
|
||||
const accountsOnlyGrow = always(
|
||||
now(() => true).implies(
|
||||
next(() => accounts.current.length >= (accounts.previous?.length ?? 0)),
|
||||
),
|
||||
);
|
||||
|
||||
const ledgerOnlyGrowsPerAccount = always(
|
||||
now(() => activeAccountId.current !== null).implies(
|
||||
next(() => {
|
||||
if (activeAccountId.current !== activeAccountId.previous) return true;
|
||||
return ledgerRows.current.length >= (ledgerRows.previous?.length ?? 0);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const authStatusIsKnown = always(
|
||||
() => authStatus.current === "logged-in" || authStatus.current === "logged-out",
|
||||
);
|
||||
|
||||
const routeIsKnown = always(() => {
|
||||
const r = route.current;
|
||||
return (
|
||||
r === "login" ||
|
||||
r === "home" ||
|
||||
r === "add-account" ||
|
||||
r === "ledger" ||
|
||||
r === "add-transaction"
|
||||
);
|
||||
});
|
||||
|
||||
const loggedInLeavesLogin = always(
|
||||
now(() => loggedIn.current).implies(
|
||||
eventually(() => route.current !== "login").within(3, "seconds"),
|
||||
),
|
||||
);
|
||||
|
||||
const loggedOutReachesLogin = always(
|
||||
now(() => !loggedIn.current).implies(
|
||||
eventually(() => route.current === "login").within(3, "seconds"),
|
||||
),
|
||||
);
|
||||
|
||||
const stateMachine = {
|
||||
accountsOnlyGrow,
|
||||
ledgerOnlyGrowsPerAccount,
|
||||
authStatusIsKnown,
|
||||
routeIsKnown,
|
||||
loggedInLeavesLogin,
|
||||
loggedOutReachesLogin,
|
||||
};
|
||||
|
||||
const loginReachable = eventually(() => loggedIn.current).within(90, "seconds");
|
||||
const accountCreationReachable = eventually(
|
||||
() => accounts.current.length > 0,
|
||||
).within(180, "seconds");
|
||||
const someTransactionExists = eventually(() =>
|
||||
accounts.current.some((a) => a.txnCount > 0),
|
||||
).within(300, "seconds");
|
||||
|
||||
const loginErrorClears = always(
|
||||
now(() => loginError.current !== "").implies(
|
||||
eventually(() => loginError.current === "").within(30, "seconds"),
|
||||
),
|
||||
);
|
||||
const addAccountErrorClears = always(
|
||||
now(() => addAccountError.current !== "").implies(
|
||||
eventually(() => addAccountError.current === "").within(30, "seconds"),
|
||||
),
|
||||
);
|
||||
const txnErrorClears = always(
|
||||
now(() => txnError.current !== "").implies(
|
||||
eventually(() => txnError.current === "").within(30, "seconds"),
|
||||
),
|
||||
);
|
||||
|
||||
const liveness = {
|
||||
loginReachable,
|
||||
accountCreationReachable,
|
||||
someTransactionExists,
|
||||
loginErrorClears,
|
||||
addAccountErrorClears,
|
||||
txnErrorClears,
|
||||
};
|
||||
|
||||
const DEMO_EMAIL = "[email protected]";
|
||||
const DEMO_PASSWORD = "ledger123";
|
||||
|
||||
const loginHelper = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
const focus = focusedInput.current;
|
||||
const email = loginEmailField.current;
|
||||
const password = loginPasswordField.current;
|
||||
const submit = loginSubmitButton.current;
|
||||
|
||||
if (focus === "login_password") {
|
||||
return submit ? [Tap({ on: submit })] : [];
|
||||
}
|
||||
if (focus === "login_email") {
|
||||
return password ? [InputText({ into: password, text: DEMO_PASSWORD })] : [];
|
||||
}
|
||||
return email ? [InputText({ into: email, text: DEMO_EMAIL })] : [];
|
||||
});
|
||||
|
||||
const adversarialLogin = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
if (focusedInput.current !== null) return [];
|
||||
const submit = loginSubmitButton.current;
|
||||
if (!submit) return [];
|
||||
return [Tap({ on: submit })];
|
||||
});
|
||||
|
||||
const accountNameSampler = from([
|
||||
"Checking",
|
||||
"Savings",
|
||||
"Travel",
|
||||
"Rent",
|
||||
"Emergency Fund",
|
||||
"Investments",
|
||||
"Groceries",
|
||||
" ",
|
||||
"Checking",
|
||||
"A".repeat(41),
|
||||
"Petty Cash",
|
||||
]);
|
||||
|
||||
const typeAccountName = actions(() => {
|
||||
if (route.current !== "add-account") return [];
|
||||
const field = accountNameField.current;
|
||||
if (!field) return [];
|
||||
return [InputText({ into: field, text: accountNameSampler.generate() })];
|
||||
});
|
||||
|
||||
const submitAddAccount = actions(() => {
|
||||
if (route.current !== "add-account") return [];
|
||||
const submit = addAccountSubmit.current;
|
||||
return submit ? [Tap({ on: submit })] : [];
|
||||
});
|
||||
|
||||
const openAddAccount = actions(() => {
|
||||
if (route.current !== "home") return [];
|
||||
const button = addAccountButton.current;
|
||||
return button ? [Tap({ on: button })] : [];
|
||||
});
|
||||
|
||||
const openRandomAccount = actions(() => {
|
||||
if (route.current !== "home") return [];
|
||||
const cards = allAccountCards.current;
|
||||
if (cards.length === 0) return [];
|
||||
const card = cards[Math.floor(Math.random() * cards.length)];
|
||||
return [Tap({ on: card })];
|
||||
});
|
||||
|
||||
const logoutAction = actions(() => {
|
||||
if (route.current !== "home") return [];
|
||||
const button = logoutButton.current;
|
||||
return button ? [Tap({ on: button })] : [];
|
||||
});
|
||||
|
||||
const goBack = actions(() => {
|
||||
const button = backButton.current;
|
||||
return button ? [Tap({ on: button })] : [];
|
||||
});
|
||||
|
||||
const amountSampler = from([
|
||||
"12.34",
|
||||
"100",
|
||||
"0.01",
|
||||
"999.99",
|
||||
"5.5",
|
||||
"42",
|
||||
"0",
|
||||
"",
|
||||
"1e4",
|
||||
"0.001",
|
||||
"-5",
|
||||
]);
|
||||
|
||||
const typeAmount = actions(() => {
|
||||
if (route.current !== "add-transaction") return [];
|
||||
const field = txnAmountField.current;
|
||||
if (!field) return [];
|
||||
return [InputText({ into: field, text: amountSampler.generate() })];
|
||||
});
|
||||
|
||||
const noteSampler = from([
|
||||
"Coffee",
|
||||
"Paycheck",
|
||||
"Gas",
|
||||
"Refund",
|
||||
"",
|
||||
"Groceries for the week",
|
||||
]);
|
||||
|
||||
const typeNote = actions(() => {
|
||||
if (route.current !== "add-transaction") return [];
|
||||
const field = txnNoteField.current;
|
||||
if (!field) return [];
|
||||
return [InputText({ into: field, text: noteSampler.generate() })];
|
||||
});
|
||||
|
||||
const toggleTxnType = actions(() => {
|
||||
if (route.current !== "add-transaction") return [];
|
||||
const current = txnFormType.current;
|
||||
const target = current === "credit" ? txnDebit.current : txnCredit.current;
|
||||
return target ? [Tap({ on: target })] : [];
|
||||
});
|
||||
|
||||
const submitTxn = actions(() => {
|
||||
if (route.current !== "add-transaction") return [];
|
||||
const submit = txnSubmit.current;
|
||||
return submit ? [Tap({ on: submit })] : [];
|
||||
});
|
||||
|
||||
const openAddTxn = actions(() => {
|
||||
if (route.current !== "ledger") return [];
|
||||
const button = addTxnButton.current;
|
||||
return button ? [Tap({ on: button })] : [];
|
||||
});
|
||||
|
||||
export const properties = {
|
||||
accountCountNonNegative,
|
||||
...accountingInvariants,
|
||||
...stateMachine,
|
||||
...liveness,
|
||||
noUncaughtExceptions,
|
||||
};
|
||||
|
||||
export const actionsRoot = weighted(
|
||||
[30, loginHelper],
|
||||
[2, adversarialLogin],
|
||||
[18, typeAccountName],
|
||||
[14, submitAddAccount],
|
||||
[18, typeAmount],
|
||||
[8, typeNote],
|
||||
[6, toggleTxnType],
|
||||
[16, submitTxn],
|
||||
[14, openAddAccount],
|
||||
[14, openRandomAccount],
|
||||
[12, openAddTxn],
|
||||
[6, goBack],
|
||||
[1, logoutAction],
|
||||
[4, taps],
|
||||
[2, swipes],
|
||||
[2, waitOnce],
|
||||
[2, pressKey],
|
||||
);
|
||||
|
||||
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
|
||||
(globalThis as { properties?: unknown }).properties = properties;
|
||||
Reference in new issue
Block a user