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:
pj authored and GitHub committed 2026-04-20 16:04:20 +07:00
1 parent c1de4bf57a
commit a2e96af1af
88 files changed
+949 -900

No files matched your search

+12
View File
@@ -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
+78
View File
@@ -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
+7
View File
@@ -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
}
+130
View File
@@ -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() }
}
}
@@ -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")
}
@@ -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))
@@ -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,
)
@@ -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,
)
}
}
}
@@ -0,0 +1,7 @@
package app.folio.feature.account
import kotlinx.coroutines.flow.MutableStateFlow
object AddAccountUiState {
val addAccountError = MutableStateFlow("")
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -0,0 +1,8 @@
package app.folio.feature.ledger
import kotlinx.coroutines.flow.MutableStateFlow
object AddTransactionUiState {
val txnError = MutableStateFlow("")
val txnFormType = MutableStateFlow<String?>(null)
}
@@ -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>
+10
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+36
View File
@@ -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>
+20
View File
@@ -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) {}
}
+43
View File
@@ -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
+99
View File
@@ -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==
+20
View File
@@ -0,0 +1,20 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
mavenLocal()
}
}
rootProject.name = "folio"
include(":composeApp")
+20
View File
@@ -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"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "folio",
"private": true,
"description": "Minimal uatu integration example.",
"devDependencies": {
"@uatu/spec": "next"
}
}
+441
View File
@@ -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;