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

+1 -1
View File
@@ -8,5 +8,5 @@ Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in
- [Getting started](https://priyanshujain.github.io/uatu/manual/getting-started.html) - [Getting started](https://priyanshujain.github.io/uatu/manual/getting-started.html)
- [Writing specs](https://priyanshujain.github.io/uatu/manual/writing-specs.html) - [Writing specs](https://priyanshujain.github.io/uatu/manual/writing-specs.html)
- [Example](https://github.com/priyanshujain/uatu/tree/master/examples/sample-app) - [Example](https://github.com/priyanshujain/uatu/tree/master/examples/folio)
- [Architecture](https://priyanshujain.github.io/uatu/development/architecture.html) - [Architecture](https://priyanshujain.github.io/uatu/development/architecture.html)
+15 -2
View File
@@ -9,17 +9,30 @@ import (
) )
func main() { func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: bundle-check <spec.ts>")
os.Exit(1)
}
entryFile, err := filepath.Abs(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "resolve spec path: %v\n", err)
os.Exit(1)
}
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "getwd: %v\n", err) fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
os.Exit(1) os.Exit(1)
} }
specApiPath := filepath.Join(repoRoot, "pkg/spec-api/src/index.ts") specApiPath := filepath.Join(repoRoot, "pkg/spec-api/src/index.ts")
defaultPropertiesPath := filepath.Join(repoRoot, "pkg/spec-api/src/defaults/properties.ts")
result, err := bundler.Bundle(bundler.Options{ result, err := bundler.Bundle(bundler.Options{
EntryFile: "examples/sample-app/spec.ts", EntryFile: entryFile,
Aliases: map[string]string{ Aliases: map[string]string{
"@uatu/spec": specApiPath, "@uatu/spec": specApiPath,
"@uatu/spec/defaults/properties": defaultPropertiesPath,
}, },
}) })
if err != nil { if err != nil {
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"github.com/priyanshujain/uatu/internal/agent"
)
func main() {
port := flag.Int("port", 0, "TCP port to listen on (0 = ephemeral)")
socketName := flag.String("socket-name", "uatu-agent", "abstract socket name on the device")
cycles := flag.Int("cycles", 3, "number of snapshot cycles to run")
serial := flag.String("serial", "", "adb device serial (empty = default)")
cycleInterval := flag.Duration("interval", 500*time.Millisecond, "delay between snapshot cycles")
flag.Parse()
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", *port))
if err != nil {
die("listen: %v", err)
}
defer listener.Close()
listenerAddr := listener.Addr().(*net.TCPAddr)
fmt.Printf("listening on 127.0.0.1:%d\n", listenerAddr.Port)
if err := adbReverse(*serial, *socketName, listenerAddr.Port); err != nil {
die("adb reverse: %v", err)
}
defer func() {
if err := adbReverseRemove(*serial, *socketName); err != nil {
fmt.Fprintf(os.Stderr, "adb reverse cleanup: %v\n", err)
}
}()
fmt.Printf("forwarded localabstract:%s -> tcp:%d on device\n", *socketName, listenerAddr.Port)
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
server := agent.NewServer(listener)
fmt.Println("waiting for SDK to connect (launch the sample app on the device)...")
conn, err := server.Accept(ctx)
if err != nil {
die("accept: %v", err)
}
defer conn.Close()
hello := conn.Hello()
fmt.Printf("HELLO from platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
for index := 1; index <= *cycles; index++ {
snapshotCtx, snapshotCancel := context.WithTimeout(ctx, 5*time.Second)
state, err := conn.Snapshot(snapshotCtx)
snapshotCancel()
if err != nil {
die("snapshot #%d: %v", index, err)
}
marshalled, _ := json.Marshal(state.Snapshots)
fmt.Printf("STATE %d: %s\n", index, marshalled)
if err := conn.Release(ctx); err != nil {
die("release #%d: %v", index, err)
}
if index < *cycles {
time.Sleep(*cycleInterval)
}
}
fmt.Println("round-trip complete")
}
func adbReverse(serial, socketName string, port int) error {
commandArguments := []string{"reverse", fmt.Sprintf("localabstract:%s", socketName), fmt.Sprintf("tcp:%d", port)}
if serial != "" {
commandArguments = append([]string{"-s", serial}, commandArguments...)
}
command := exec.Command("adb", commandArguments...)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
return command.Run()
}
func adbReverseRemove(serial, socketName string) error {
commandArguments := []string{"reverse", "--remove", fmt.Sprintf("localabstract:%s", socketName)}
if serial != "" {
commandArguments = append([]string{"-s", serial}, commandArguments...)
}
return exec.Command("adb", commandArguments...).Run()
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...)
os.Exit(1)
}
+2 -2
View File
@@ -50,14 +50,14 @@ dependencies {
## Your first run ## Your first run
The repo ships a working sample at `examples/sample-app`. From that directory: The repo ships a working sample at `examples/folio`. From that directory:
```sh ```sh
npm install npm install
(cd android && ./gradlew installDebug) (cd android && ./gradlew installDebug)
uatu test \ uatu test \
--spec spec.ts \ --spec spec.ts \
--bundle-id dev.uatu.sample \ --bundle-id app.folio \
--platform android \ --platform android \
--duration 2m --duration 2m
``` ```
@@ -6,6 +6,7 @@ iosApp/build/
iosApp/iosApp.xcodeproj/ iosApp/iosApp.xcodeproj/
iosApp/iosApp.xcodeproj/** iosApp/iosApp.xcodeproj/**
.DS_Store .DS_Store
runs/ uatu/node_modules/
uatu/runs/
.playwright-mcp/ .playwright-mcp/
web-*.png web-*.png
@@ -1,9 +1,9 @@
# uatu sample app # Folio
A minimal Kotlin Multiplatform ledger app that mirrors the React reference: A minimal Kotlin Multiplatform personal-ledger app: login with demo
login with demo credentials, create accounts, add credits and debits. Same credentials, create accounts, add credits and debits. Shared UI across
features, monospace look, and demo creds, shared across Android and iOS via Android, iOS, and Web via Compose Multiplatform. Doubles as the example
Compose Multiplatform. uatu runs its property-based specs against.
## Stack ## Stack
@@ -12,10 +12,6 @@ Compose Multiplatform.
- kotlinx.coroutines for state flows - kotlinx.coroutines for state flows
- uatu `sdk-android` for harness integration on Android - uatu `sdk-android` for harness integration on Android
Everything in `composeApp/src/commonMain/kotlin/dev/uatu/sample/` is shared
between platforms. Platform-specific I/O (file storage, clock, UUID) lives
in `androidMain/` and `iosMain/` as `actual`s of the `Platform` expect object.
## Prerequisites ## Prerequisites
- `just` - `just`
@@ -46,7 +42,7 @@ launches.
## Demo credentials ## Demo credentials
``` ```
email: demo@ledger.app email: demo@folio.app
password: ledger123 password: ledger123
``` ```
@@ -60,7 +56,7 @@ If no device is connected, uatu boots the single AVD it finds. With multiple
AVDs, pick one: AVDs, pick one:
```sh ```sh
just AVD=Pixel_7 test AVD=Pixel_7 just test
``` ```
Persistent settings can live in `.env` alongside the justfile: Persistent settings can live in `.env` alongside the justfile:
@@ -70,28 +66,13 @@ AVD=Pixel_7
DURATION=5m DURATION=5m
``` ```
Traces land in `./runs/<timestamp>/`. Traces land in `./uatu/runs/<timestamp>/`.
## Layout
```
composeApp/
src/commonMain/kotlin/dev/uatu/sample/ shared domain, state, UI
src/androidMain/ Android Application + Activity
src/iosMain/ iOS UIViewController entry
iosApp/
project.yml xcodegen spec
iosApp/iOSApp.swift SwiftUI host
iosApp/Info.plist
justfile
spec.ts uatu test spec
```
## How it connects to uatu ## How it connects to uatu
- `composeApp/src/androidMain/.../SampleApplication.kt` calls `Uatu.start(this)` - `composeApp/src/androidMain/.../FolioApplication.kt` calls `Uatu.start(this)`
and registers snapshot extractors (`logged_in`, `account_count`, and registers snapshot extractors (`logged_in`, `account_count`,
`total_balance`, `route`) `total_balance`, `route`)
- `spec.ts` imports `@uatu/spec`, reads those snapshots, asserts properties, - `uatu/spec.ts` imports `@uatu/spec`, reads those snapshots, asserts
and weights the actions the fuzzer picks from properties, and weights the actions the fuzzer picks from
- `just test` invokes `uatu test` against the installed APK - `just test` invokes `uatu test` against the installed APK
File renamed without changes.
@@ -35,7 +35,7 @@ kotlin {
target.binaries.framework { target.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
isStatic = true isStatic = true
binaryOption("bundleId", "dev.uatu.sample") binaryOption("bundleId", "app.folio")
} }
} }
@@ -93,11 +93,11 @@ kotlin {
} }
android { android {
namespace = "dev.uatu.sample" namespace = "app.folio"
compileSdk = 36 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "dev.uatu.sample" applicationId = "app.folio"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
@@ -124,7 +124,7 @@ android {
sqldelight { sqldelight {
databases { databases {
create("LedgerDatabase") { create("LedgerDatabase") {
packageName.set("dev.uatu.sample.db") packageName.set("app.folio.db")
} }
} }
} }
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:name=".SampleApplication" android:name=".FolioApplication"
android:label="Uatu Sample" android:label="Folio"
android:allowBackup="false" android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity <activity
@@ -1,9 +1,19 @@
package dev.uatu.sample package app.folio
import android.app.Application 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 import dev.uatu.sdk.Uatu
class SampleApplication : Application() { class FolioApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AndroidLedgerContext.context = applicationContext AndroidLedgerContext.context = applicationContext
@@ -72,12 +82,12 @@ class SampleApplication : Application() {
else balanceOf(Repository.transactions.value.filter { it.accountId == active }) else balanceOf(Repository.transactions.value.filter { it.accountId == active })
} }
Uatu.extract("focused_input") { FocusTracker.current.value } Uatu.extract("focused_input") { FocusTracker.current.value }
Uatu.extract("txn_form_type") { UiState.txnFormType.value } Uatu.extract("txn_form_type") { AddTransactionUiState.txnFormType.value }
Uatu.extract("txn_form_account_id") { Uatu.extract("txn_form_account_id") {
(Navigator.current.value as? Route.AddTransaction)?.accountId (Navigator.current.value as? Route.AddTransaction)?.accountId
} }
Uatu.extract("login_error") { UiState.loginError.value } Uatu.extract("login_error") { LoginUiState.loginError.value }
Uatu.extract("add_account_error") { UiState.addAccountError.value } Uatu.extract("add_account_error") { AddAccountUiState.addAccountError.value }
Uatu.extract("txn_error") { UiState.txnError.value } Uatu.extract("txn_error") { AddTransactionUiState.txnError.value }
} }
} }
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -1,9 +1,9 @@
package dev.uatu.sample package app.folio.data
import android.content.Context import android.content.Context
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import dev.uatu.sample.db.LedgerDatabase import app.folio.db.LedgerDatabase
actual class DriverFactory(private val context: Context) { actual class DriverFactory(private val context: Context) {
actual fun create(): SqlDriver = actual fun create(): SqlDriver =
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.data
import android.content.Context import android.content.Context
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
import java.util.UUID import java.util.UUID
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.activity.compose.BackHandler as AndroidBackHandler import androidx.activity.compose.BackHandler as AndroidBackHandler
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -13,14 +13,17 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dev.uatu.sample.ui.AddAccountPage import app.folio.data.Repository
import dev.uatu.sample.ui.AddTransactionPage import app.folio.feature.account.AddAccountScreen
import dev.uatu.sample.ui.HomePage import app.folio.feature.auth.LoginScreen
import dev.uatu.sample.ui.LedgerPage import app.folio.feature.home.HomeScreen
import dev.uatu.sample.ui.LedgerTheme import app.folio.feature.ledger.AddTransactionScreen
import dev.uatu.sample.ui.LocalTokens import app.folio.feature.ledger.LedgerScreen
import dev.uatu.sample.ui.LoginPage import app.folio.navigation.Navigator
import dev.uatu.sample.ui.Tokens import app.folio.navigation.Route
import app.folio.ui.theme.LedgerTheme
import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.Tokens
@Composable @Composable
fun App() { fun App() {
@@ -46,17 +49,17 @@ fun App() {
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
when (val r = route) { when (val r = route) {
Route.Login -> LoginPage(onLoggedIn = { Navigator.replace(Route.Home) }) Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
Route.Home -> HomePage( Route.Home -> HomeScreen(
user = session?.user ?: "", user = session?.user ?: "",
onLogout = { onLogout = {
Repository.clearSession() Repository.clearSession()
Navigator.replace(Route.Login) Navigator.replace(Route.Login)
}, },
) )
Route.AddAccount -> AddAccountPage() Route.AddAccount -> AddAccountScreen()
is Route.Ledger -> LedgerPage(accountId = r.accountId) is Route.Ledger -> LedgerScreen(accountId = r.accountId)
is Route.AddTransaction -> AddTransactionPage(accountId = r.accountId) is Route.AddTransaction -> AddTransactionScreen(accountId = r.accountId)
} }
} }
} }
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -0,0 +1,7 @@
package app.folio.data
data class Account(
val id: String,
val name: String,
val createdAt: Long,
)
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.data
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -1,5 +1,6 @@
package dev.uatu.sample package app.folio.data
import app.folio.platform.Platform
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
object Repository { object Repository {
@@ -0,0 +1,6 @@
package app.folio.data
data class Session(
val user: String,
val loggedInAt: Long,
)
@@ -1,10 +1,4 @@
package dev.uatu.sample package app.folio.data
data class Account(
val id: String,
val name: String,
val createdAt: Long,
)
enum class TxnType { credit, debit } enum class TxnType { credit, debit }
@@ -16,8 +10,3 @@ data class Transaction(
val note: String, val note: String,
val createdAt: Long, val createdAt: Long,
) )
data class Session(
val user: String,
val loggedInAt: Long,
)
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.feature.account
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,37 +14,47 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator import app.folio.data.Repository
import dev.uatu.sample.Repository import app.folio.navigation.Navigator
import dev.uatu.sample.Route import app.folio.navigation.Route
import dev.uatu.sample.UiState 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 @Composable
fun AddAccountPage() { fun AddAccountScreen() {
val t = LocalTokens.current val t = LocalTokens.current
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
val err by UiState.addAccountError.collectAsState() val err by AddAccountUiState.addAccountError.collectAsState()
BackHandler { Navigator.back(Route.Home) } BackHandler { Navigator.back(Route.Home) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { UiState.addAccountError.value = "" } onDispose { AddAccountUiState.addAccountError.value = "" }
} }
fun submit() { fun submit() {
val trimmed = name.trim() val trimmed = name.trim()
if (trimmed.isEmpty()) { if (trimmed.isEmpty()) {
UiState.addAccountError.value = "Account name is required"; return AddAccountUiState.addAccountError.value = "Account name is required"; return
} }
if (trimmed.length > 40) { if (trimmed.length > 40) {
UiState.addAccountError.value = "Name is too long (max 40 characters)"; return AddAccountUiState.addAccountError.value = "Name is too long (max 40 characters)"; return
} }
try { try {
Repository.createAccount(trimmed) Repository.createAccount(trimmed)
UiState.addAccountError.value = "" AddAccountUiState.addAccountError.value = ""
Navigator.replace(Route.Home) Navigator.replace(Route.Home)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
UiState.addAccountError.value = e.message ?: "Could not create account" AddAccountUiState.addAccountError.value = e.message ?: "Could not create account"
} }
} }
@@ -70,7 +80,7 @@ fun AddAccountPage() {
FieldLabel("Account name") FieldLabel("Account name")
TextInput( TextInput(
value = name, value = name,
onChange = { name = it; UiState.addAccountError.value = "" }, onChange = { name = it; AddAccountUiState.addAccountError.value = "" },
placeholder = "e.g. Checking", placeholder = "e.g. Checking",
invalid = err.isNotEmpty(), invalid = err.isNotEmpty(),
label = "Account name", label = "Account name",
@@ -0,0 +1,7 @@
package app.folio.feature.account
import kotlinx.coroutines.flow.MutableStateFlow
object AddAccountUiState {
val addAccountError = MutableStateFlow("")
}
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.feature.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -17,31 +17,43 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.uatu.sample.DEMO_EMAIL import app.folio.data.Repository
import dev.uatu.sample.DEMO_PASSWORD import app.folio.ui.component.AppButton
import dev.uatu.sample.Repository import app.folio.ui.component.ButtonStyle
import dev.uatu.sample.UiState import app.folio.ui.component.Card
import dev.uatu.sample.checkCredentials 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 @Composable
fun LoginPage(onLoggedIn: (String) -> Unit) { fun LoginScreen(onLoggedIn: (String) -> Unit) {
val t = LocalTokens.current val t = LocalTokens.current
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
val err by UiState.loginError.collectAsState() val err by LoginUiState.loginError.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { UiState.loginError.value = "" } onDispose { LoginUiState.loginError.value = "" }
} }
fun submit() { fun submit() {
if (email.isBlank() || password.isEmpty()) { if (email.isBlank() || password.isEmpty()) {
UiState.loginError.value = "Enter email and password"; return LoginUiState.loginError.value = "Enter email and password"; return
} }
if (!checkCredentials(email, password)) { if (!checkCredentials(email, password)) {
UiState.loginError.value = "Invalid email or password"; return LoginUiState.loginError.value = "Invalid email or password"; return
} }
UiState.loginError.value = "" LoginUiState.loginError.value = ""
val user = email.trim().lowercase() val user = email.trim().lowercase()
Repository.setSession(user) Repository.setSession(user)
onLoggedIn(user) onLoggedIn(user)
@@ -57,7 +69,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
FieldLabel("Email") FieldLabel("Email")
TextInput( TextInput(
value = email, value = email,
onChange = { email = it; UiState.loginError.value = "" }, onChange = { email = it; LoginUiState.loginError.value = "" },
placeholder = DEMO_EMAIL, placeholder = DEMO_EMAIL,
invalid = err.isNotEmpty(), invalid = err.isNotEmpty(),
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
@@ -69,7 +81,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
FieldLabel("Password") FieldLabel("Password")
TextInput( TextInput(
value = password, value = password,
onChange = { password = it; UiState.loginError.value = "" }, onChange = { password = it; LoginUiState.loginError.value = "" },
placeholder = "••••••••", placeholder = "••••••••",
password = true, password = true,
invalid = err.isNotEmpty(), invalid = err.isNotEmpty(),
@@ -0,0 +1,7 @@
package app.folio.feature.auth
import kotlinx.coroutines.flow.MutableStateFlow
object LoginUiState {
val loginError = MutableStateFlow("")
}
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.feature.home
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -23,16 +23,26 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import dev.uatu.sample.Navigator import app.folio.data.Repository
import dev.uatu.sample.Repository import app.folio.navigation.Navigator
import dev.uatu.sample.Route import app.folio.navigation.Route
import dev.uatu.sample.balanceOf import app.folio.platform.balanceOf
import dev.uatu.sample.formatCents import app.folio.platform.formatCents
import dev.uatu.sample.initialsOf import app.folio.platform.initialsOf
import dev.uatu.sample.signedAmount 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 @Composable
fun HomePage(user: String, onLogout: () -> Unit) { fun HomeScreen(user: String, onLogout: () -> Unit) {
val t = LocalTokens.current val t = LocalTokens.current
val accounts by Repository.accounts.collectAsState() val accounts by Repository.accounts.collectAsState()
val txns by Repository.transactions.collectAsState() val txns by Repository.transactions.collectAsState()
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.feature.ledger
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,17 +15,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator import app.folio.data.Repository
import dev.uatu.sample.Repository import app.folio.data.TxnType
import dev.uatu.sample.Route import app.folio.navigation.Navigator
import dev.uatu.sample.TxnType import app.folio.navigation.Route
import dev.uatu.sample.UiState import app.folio.platform.parseCents
import dev.uatu.sample.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})?$""") private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
@Composable @Composable
fun AddTransactionPage(accountId: String) { fun AddTransactionScreen(accountId: String) {
val accounts by Repository.accounts.collectAsState() val accounts by Repository.accounts.collectAsState()
val account = accounts.firstOrNull { it.id == accountId } val account = accounts.firstOrNull { it.id == accountId }
@@ -50,31 +61,31 @@ fun AddTransactionPage(accountId: String) {
var type by remember { mutableStateOf(TxnType.credit) } var type by remember { mutableStateOf(TxnType.credit) }
var amount by remember { mutableStateOf("") } var amount by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") } var note by remember { mutableStateOf("") }
val err by UiState.txnError.collectAsState() val err by AddTransactionUiState.txnError.collectAsState()
DisposableEffect(type) { DisposableEffect(type) {
UiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit" AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit"
onDispose { UiState.txnFormType.value = null } onDispose { AddTransactionUiState.txnFormType.value = null }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { UiState.txnError.value = "" } onDispose { AddTransactionUiState.txnError.value = "" }
} }
fun submit() { fun submit() {
val cents = parseCents(amount) val cents = parseCents(amount)
if (cents == null) { if (cents == null) {
UiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return AddTransactionUiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return
} }
if (cents <= 0) { if (cents <= 0) {
UiState.txnError.value = "Amount must be greater than zero"; return AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return
} }
try { try {
Repository.createTransaction(accountId, type, cents, note) Repository.createTransaction(accountId, type, cents, note)
UiState.txnError.value = "" AddTransactionUiState.txnError.value = ""
Navigator.back(Route.Home) Navigator.back(Route.Home)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
UiState.txnError.value = e.message ?: "Could not save transaction" AddTransactionUiState.txnError.value = e.message ?: "Could not save transaction"
} }
} }
@@ -104,7 +115,7 @@ fun AddTransactionPage(accountId: String) {
selected = if (type == TxnType.credit) 0 else 1, selected = if (type == TxnType.credit) 0 else 1,
labels = listOf("Credit", "Debit"), labels = listOf("Credit", "Debit"),
onSelect = { onSelect = {
UiState.txnError.value = "" AddTransactionUiState.txnError.value = ""
type = if (it == 0) TxnType.credit else TxnType.debit type = if (it == 0) TxnType.credit else TxnType.debit
}, },
descriptions = listOf("txn_credit", "txn_debit"), descriptions = listOf("txn_credit", "txn_debit"),
@@ -114,7 +125,7 @@ fun AddTransactionPage(accountId: String) {
TextInput( TextInput(
value = amount, value = amount,
onChange = { onChange = {
UiState.txnError.value = "" AddTransactionUiState.txnError.value = ""
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
amount = it amount = it
} }
@@ -133,7 +144,7 @@ fun AddTransactionPage(accountId: String) {
TextInput( TextInput(
value = note, value = note,
onChange = { onChange = {
UiState.txnError.value = "" AddTransactionUiState.txnError.value = ""
note = it.take(80) note = it.take(80)
}, },
placeholder = "What's this for?", placeholder = "What's this for?",
@@ -1,10 +1,8 @@
package dev.uatu.sample package app.folio.feature.ledger
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
object UiState { object AddTransactionUiState {
val loginError = MutableStateFlow("")
val addAccountError = MutableStateFlow("")
val txnError = MutableStateFlow("") val txnError = MutableStateFlow("")
val txnFormType = MutableStateFlow<String?>(null) val txnFormType = MutableStateFlow<String?>(null)
} }
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.feature.ledger
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -22,17 +22,29 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator import app.folio.data.Repository
import dev.uatu.sample.Repository import app.folio.navigation.Navigator
import dev.uatu.sample.Route import app.folio.navigation.Route
import dev.uatu.sample.TxnType import app.folio.data.TxnType
import dev.uatu.sample.balanceOf import app.folio.platform.balanceOf
import dev.uatu.sample.formatCents import app.folio.platform.formatCents
import dev.uatu.sample.formatDate import app.folio.platform.formatDate
import dev.uatu.sample.signedAmount 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 @Composable
fun LedgerPage(accountId: String) { fun LedgerScreen(accountId: String) {
val t = LocalTokens.current val t = LocalTokens.current
val accounts by Repository.accounts.collectAsState() val accounts by Repository.accounts.collectAsState()
val allTxns by Repository.transactions.collectAsState() val allTxns by Repository.transactions.collectAsState()
@@ -1,17 +1,9 @@
package dev.uatu.sample package app.folio.navigation
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
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
}
object Navigator { object Navigator {
private val stack = ArrayDeque<Route>().apply { addLast(Route.Home) } private val stack = ArrayDeque<Route>().apply { addLast(Route.Home) }
private val _current = MutableStateFlow<Route>(Route.Home) private val _current = MutableStateFlow<Route>(Route.Home)
@@ -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
}
@@ -1,5 +1,7 @@
package dev.uatu.sample package app.folio.platform
import app.folio.data.Transaction
import app.folio.data.TxnType
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
private const val CURRENCY = "$" private const val CURRENCY = "$"
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
expect object Platform { expect object Platform {
fun now(): Long fun now(): Long
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -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"
},
)
}
}
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui.icon
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -6,11 +6,7 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
data class Tokens( data class Tokens(
val bg: Color = Color(0xFFFFFFFF), val bg: Color = Color(0xFFFFFFFF),
@@ -27,20 +23,6 @@ data class Tokens(
val LocalTokens = staticCompositionLocalOf { Tokens() } val LocalTokens = staticCompositionLocalOf { Tokens() }
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)
}
@Composable @Composable
fun LedgerTheme(content: @Composable () -> Unit) { fun LedgerTheme(content: @Composable () -> Unit) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
@@ -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)
}
@@ -1,7 +1,8 @@
package dev.uatu.sample package app.folio
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
import dev.uatu.sample.ui.IosBackGesture import app.folio.data.Repository
import app.folio.ui.IosBackGesture
import kotlin.native.ObjCName import kotlin.native.ObjCName
import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
@@ -1,8 +1,8 @@
package dev.uatu.sample package app.folio.data
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver
import dev.uatu.sample.db.LedgerDatabase import app.folio.db.LedgerDatabase
actual class DriverFactory { actual class DriverFactory {
actual fun create(): SqlDriver = actual fun create(): SqlDriver =
@@ -1,3 +1,3 @@
package dev.uatu.sample package app.folio.data
actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory()) actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory())
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter import platform.Foundation.NSDateFormatter
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSUUID import platform.Foundation.NSUUID
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui
object IosBackGesture { object IosBackGesture {
private var nextId = 0 private var nextId = 0
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.data
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
@@ -1,9 +1,9 @@
package dev.uatu.sample package app.folio.data
import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToList
import app.cash.sqldelight.coroutines.mapToOneOrNull import app.cash.sqldelight.coroutines.mapToOneOrNull
import dev.uatu.sample.db.LedgerDatabase import app.folio.db.LedgerDatabase
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -58,7 +58,7 @@ class SqlLedgerStore(factory: DriverFactory) : LedgerStore {
} }
} }
private fun dev.uatu.sample.db.Txns.toDomain(): Transaction = private fun app.folio.db.Txns.toDomain(): Transaction =
Transaction( Transaction(
id = id, id = id,
accountId = accountId, accountId = accountId,
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.data
internal data class Snapshot( internal data class Snapshot(
val accounts: List<Account>, val accounts: List<Account>,
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.data
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -1,8 +1,9 @@
package dev.uatu.sample package app.folio
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import dev.uatu.sample.ui.WebBackGesture import app.folio.data.Repository
import app.folio.ui.WebBackGesture
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window import kotlinx.browser.window
import org.w3c.dom.events.Event import org.w3c.dom.events.Event
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
private val MONTHS = arrayOf( private val MONTHS = arrayOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -1,4 +1,4 @@
package dev.uatu.sample package app.folio.platform
import kotlin.random.Random import kotlin.random.Random
@@ -1,4 +1,4 @@
package dev.uatu.sample.ui package app.folio.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Uatu Sample</title> <title>Folio</title>
<style> <style>
html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; } html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; }
body { display: flex; justify-content: center; } body { display: flex; justify-content: center; }
@@ -5,6 +5,6 @@ kotlin.code.style=official
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# Pin the uatu SDK version consumed by this sample. Override with # Pin the uatu SDK version consumed by this app. Override with
# `-Puatu.version=<version>`. # `-Puatu.version=<version>`.
uatu.version=0.0.0-dev uatu.version=0.0.0-dev
File renamed without changes.
File renamed without changes.
@@ -11,9 +11,9 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Uatu Sample</string> <string>Folio</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Uatu Sample</string> <string>Folio</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -1,6 +1,6 @@
name: iosApp name: iosApp
options: options:
bundleIdPrefix: dev.uatu.sample bundleIdPrefix: app.folio
deploymentTarget: deploymentTarget:
iOS: "15.0" iOS: "15.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
@@ -24,7 +24,7 @@ targets:
- sdk: libsqlite3.tbd - sdk: libsqlite3.tbd
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: dev.uatu.sample PRODUCT_BUNDLE_IDENTIFIER: app.folio
PRODUCT_NAME: iosApp PRODUCT_NAME: iosApp
INFOPLIST_FILE: iosApp/Info.plist INFOPLIST_FILE: iosApp/Info.plist
FRAMEWORK_SEARCH_PATHS: FRAMEWORK_SEARCH_PATHS:
@@ -5,7 +5,7 @@ uatu := env_var_or_default("UATU", "uatu")
avd := env_var_or_default("AVD", "") avd := env_var_or_default("AVD", "")
duration := env_var_or_default("DURATION", "1m") duration := env_var_or_default("DURATION", "1m")
seed := env_var_or_default("SEED", "0") seed := env_var_or_default("SEED", "0")
output := env_var_or_default("OUTPUT", justfile_directory() / "runs") output := env_var_or_default("OUTPUT", justfile_directory() / "uatu" / "runs")
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro") ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
default: default:
@@ -35,14 +35,14 @@ _android-home:
echo "could not locate Android SDK (set ANDROID_HOME)" >&2 echo "could not locate Android SDK (set ANDROID_HOME)" >&2
exit 1 exit 1
# Build and install the sample APK on a running emulator/device. # Build and install the folio APK on a running emulator/device.
install: install:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
export ANDROID_HOME="$(just _android-home)" export ANDROID_HOME="$(just _android-home)"
./gradlew :composeApp:installDebug ./gradlew :composeApp:installDebug
# Remove the sample APK from the connected device. # Remove the folio APK from the connected device.
uninstall: uninstall:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -79,9 +79,9 @@ ios:
-derivedDataPath iosApp/build \ -derivedDataPath iosApp/build \
build | tail -5 build | tail -5
xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app
xcrun simctl launch booted dev.uatu.sample xcrun simctl launch booted app.folio
# Run 'uatu test' against the sample app. Uses a connected device if one is # Run 'uatu test' against the folio app. Uses a connected device if one is
# online, otherwise boots AVD=<name> if provided. # online, otherwise boots AVD=<name> if provided.
test: test:
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -91,8 +91,8 @@ test:
avd_flag=(--avd "{{avd}}") avd_flag=(--avd "{{avd}}")
fi fi
"{{uatu}}" test \ "{{uatu}}" test \
--spec "{{justfile_directory()}}/spec.ts" \ --spec "{{justfile_directory()}}/uatu/spec.ts" \
--bundle-id dev.uatu.sample \ --bundle-id app.folio \
"${avd_flag[@]}" \ "${avd_flag[@]}" \
--duration "{{duration}}" \ --duration "{{duration}}" \
--seed "{{seed}}" \ --seed "{{seed}}" \
@@ -15,6 +15,6 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "uatu-sample" rootProject.name = "folio"
include(":composeApp") include(":composeApp")
@@ -1,10 +1,10 @@
{ {
"name": "uatu-sample", "name": "folio",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "uatu-sample", "name": "folio",
"devDependencies": { "devDependencies": {
"@uatu/spec": "next" "@uatu/spec": "next"
} }
@@ -1,5 +1,5 @@
{ {
"name": "uatu-sample", "name": "folio",
"private": true, "private": true,
"description": "Minimal uatu integration example.", "description": "Minimal uatu integration example.",
"devDependencies": { "devDependencies": {
@@ -274,7 +274,7 @@ const liveness = {
txnErrorClears, txnErrorClears,
}; };
const DEMO_EMAIL = "demo@ledger.app"; const DEMO_EMAIL = "demo@folio.app";
const DEMO_PASSWORD = "ledger123"; const DEMO_PASSWORD = "ledger123";
const loginHelper = actions(() => { const loginHelper = actions(() => {
@@ -1,8 +0,0 @@
package dev.uatu.sample
const val DEMO_EMAIL = "[email protected]"
const val DEMO_PASSWORD = "ledger123"
fun checkCredentials(email: String, password: String): Boolean {
return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD
}
@@ -1,162 +0,0 @@
package dev.uatu.sample.ui
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.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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@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()
}
}
}
}
@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()
}
}
@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)
}
@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)
}
}
@@ -1,208 +0,0 @@
package dev.uatu.sample.ui
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.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.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.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
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 dev.uatu.sample.FocusTracker
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)
}
}
@Composable
fun FieldLabel(text: String) {
val t = LocalTokens.current
Text(text.uppercase(), style = Type.label, color = t.textMuted)
}
@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"
},
)
}
}
@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() }
}
@Composable
fun ErrorText(err: String?) {
val t = LocalTokens.current
Text(
err ?: "",
style = Type.caption,
color = t.text,
modifier = Modifier.fillMaxWidth().height(18.dp),
)
}
@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)
}
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestWriteMeta_RoundTrip(t *testing.T) {
meta := Meta{ meta := Meta{
Seed: 42, Seed: 42,
SpecPath: "examples/sample-app/spec.ts", SpecPath: "spec.ts",
BundleSHA256: "deadbeef", BundleSHA256: "deadbeef",
Platform: "android", Platform: "android",
BundleID: "in.okcredit.merchant", BundleID: "in.okcredit.merchant",
+19
View File
@@ -0,0 +1,19 @@
package verifier
const listHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.widget.TextView" text="Items" bounds="[100,200][900,300]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[980,80][1060,160]" />
</node>
</hierarchy>`
const formHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="test.app" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="text_field" clickable="true" enabled="true" bounds="[64,320][1016,440]" />
<node index="1" class="android.view.View" content-desc="primary_action" clickable="true" enabled="true" bounds="[64,2200][1016,2320]" />
<node index="2" class="android.view.View" content-desc="secondary_action" clickable="true" enabled="true" bounds="[32,80][112,160]" />
</node>
</hierarchy>`
+59 -196
View File
@@ -10,62 +10,12 @@ import (
"github.com/priyanshujain/uatu/internal/ltl" "github.com/priyanshujain/uatu/internal/ltl"
) )
const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?> // bundleIntegrationSpec bundles testdata/integration_spec.ts via the real
<hierarchy rotation="0"> // @uatu/spec API so the integration test exercises the same path the CLI
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]"> // uses, with no reference to any specific example app.
<node index="0" class="android.widget.LinearLayout" bounds="[64,96][1016,2336]"> func bundleIntegrationSpec(t *testing.T) string {
<node index="0" class="android.widget.TextView" text="Sign in" bounds="[100,200][900,300]" />
<node index="1" class="android.view.View" content-desc="login_email" clickable="true" enabled="true" bounds="[100,400][900,520]" />
<node index="2" class="android.view.View" content-desc="login_password" clickable="true" enabled="true" bounds="[100,560][900,680]" />
<node index="3" class="android.view.View" content-desc="login_submit" clickable="true" enabled="true" bounds="[100,800][900,920]" />
</node>
</node>
</hierarchy>`
const homeHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="logout_button" clickable="true" bounds="[980,80][1060,160]" />
<node index="1" class="android.view.View" content-desc="account_card:acc-1" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="account_card:acc-2" clickable="true" bounds="[64,460][1016,580]" />
<node index="3" class="android.view.View" content-desc="add_account_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addAccountHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="account_name_field" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="add_account_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const ledgerHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="add_txn_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addTxnHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="txn_credit" clickable="true" bounds="[64,280][540,360]" />
<node index="2" class="android.view.View" content-desc="txn_debit" clickable="true" bounds="[540,280][1016,360]" />
<node index="3" class="android.view.View" content-desc="txn_amount" clickable="true" bounds="[64,440][1016,560]" />
<node index="4" class="android.view.View" content-desc="txn_note" clickable="true" bounds="[64,600][1016,720]" />
<node index="5" class="android.view.View" content-desc="txn_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
// bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real
// @uatu/spec API so the integration test exercises the same path the CLI uses.
func bundleSampleAppSpec(t *testing.T) string {
t.Helper() t.Helper()
specPath, err := filepath.Abs("../../examples/sample-app/spec.ts") specPath, err := filepath.Abs("testdata/integration_spec.ts")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -90,155 +40,98 @@ func bundleSampleAppSpec(t *testing.T) string {
return string(bundle.JavaScript) return string(bundle.JavaScript)
} }
func loginSnapshots() Snapshots { func listSnapshots() Snapshots {
return Snapshots{ return Snapshots{
"route": json.RawMessage(`"login"`), "route": json.RawMessage(`"list"`),
"logged_in": json.RawMessage(`false`), "item_count": json.RawMessage(`0`),
"auth_status": json.RawMessage(`"logged-out"`), "has_submitted": json.RawMessage(`false`),
"account_count": json.RawMessage(`0`),
"accounts": json.RawMessage(`[]`),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
} }
} }
// twoAccountsJSON is shared between home, ledger, and add-transaction func formSnapshots() Snapshots {
// snapshots so invariants that correlate accounts with ledger rows stay
// consistent across routes.
const twoAccountsJSON = `[` +
`{"id":"acc-1","name":"Checking","balance":0,"txnCount":0},` +
`{"id":"acc-2","name":"Savings","balance":0,"txnCount":0}` +
`]`
func homeSnapshots() Snapshots {
return Snapshots{ return Snapshots{
"route": json.RawMessage(`"home"`), "route": json.RawMessage(`"form"`),
"logged_in": json.RawMessage(`true`), "item_count": json.RawMessage(`0`),
"auth_status": json.RawMessage(`"logged-in"`), "has_submitted": json.RawMessage(`false`),
"account_count": json.RawMessage(`2`),
"accounts": json.RawMessage(twoAccountsJSON),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
} }
} }
func addAccountSnapshots() Snapshots { // TestIntegrationSpecFiresInputActions verifies the bundled neutral spec
s := homeSnapshots() // emits an InputText action on the text field and a Tap action on the
s["route"] = json.RawMessage(`"add-account"`) // primary button when both are present in the hierarchy.
return s func TestIntegrationSpecFiresInputActions(t *testing.T) {
}
func ledgerSnapshots() Snapshots {
s := homeSnapshots()
s["route"] = json.RawMessage(`"ledger"`)
s["active_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
func addTxnSnapshots() Snapshots {
s := ledgerSnapshots()
s["route"] = json.RawMessage(`"add-transaction"`)
s["txn_form_type"] = json.RawMessage(`"credit"`)
s["txn_form_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// emits Tap actions targeting the login screen elements when they are present
// in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) {
v := newVerifier(t) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(sampleAppHierarchyXML) tree, err := hierarchy.Parse(formHierarchyXML)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil { if err := v.PushSnapshot(SnapshotInput{Snapshots: formSnapshots(), Tree: tree}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
typeEmailHits := 0 typeFieldHits := 0
tapSubmitHits := 0 tapPrimaryHits := 0
for range 400 { for range 400 {
action, err := v.NextAction() action, err := v.NextAction()
if err != nil { if err != nil {
continue continue
} }
switch { switch {
case action.Kind == ActionKindInputText && action.On == "desc:login_email": case action.Kind == ActionKindInputText && action.On == "desc:text_field":
typeEmailHits++ typeFieldHits++
case action.Kind == ActionKindTap && action.On == "desc:login_submit": case action.Kind == ActionKindTap && action.On == "desc:primary_action":
tapSubmitHits++ tapPrimaryHits++
} }
} }
if typeEmailHits == 0 { if typeFieldHits == 0 {
t.Fatal("loginHelper never typed into desc:login_email on sample-app hierarchy") t.Fatal("typeIntoField never typed into desc:text_field on form hierarchy")
} }
if tapSubmitHits == 0 { if tapPrimaryHits == 0 {
t.Fatal("adversarialLogin never tapped desc:login_submit on sample-app hierarchy") t.Fatal("tapPrimary never tapped desc:primary_action on form hierarchy")
} }
} }
// TestSampleAppSpecPropertiesEvaluate checks the properties declared in the // TestIntegrationSpecPropertiesEvaluate checks the properties declared in
// sample-app spec evaluate sensibly across a small snapshot sequence. The // the neutral spec evaluate sensibly across a small snapshot sequence. The
// spec mixes safety and liveness properties; Pending verdicts are expected // spec mixes safety and liveness properties; Pending verdicts are expected
// for liveness properties that haven't had time to resolve yet. // for liveness properties that haven't had time to resolve yet.
func TestSampleAppSpecPropertiesEvaluate(t *testing.T) { func TestIntegrationSpecPropertiesEvaluate(t *testing.T) {
v := newVerifier(t) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(sampleAppHierarchyXML) tree, err := hierarchy.Parse(listHierarchyXML)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil { if err := v.PushSnapshot(SnapshotInput{Snapshots: listSnapshots(), Tree: tree}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
verdicts := v.EvaluateProperties() verdicts := v.EvaluateProperties()
if verdicts["accountCountNonNegative"] != ltl.VerdictHolds { if verdicts["itemCountNonNegative"] != ltl.VerdictHolds {
t.Errorf("accountCountNonNegative: got %v, want holds", verdicts["accountCountNonNegative"]) t.Errorf("itemCountNonNegative: got %v, want holds", verdicts["itemCountNonNegative"])
}
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
}
if verdicts["authStatusIsKnown"] != ltl.VerdictHolds {
t.Errorf("authStatusIsKnown: got %v, want holds", verdicts["authStatusIsKnown"])
} }
if verdicts["routeIsKnown"] != ltl.VerdictHolds { if verdicts["routeIsKnown"] != ltl.VerdictHolds {
t.Errorf("routeIsKnown: got %v, want holds", verdicts["routeIsKnown"]) t.Errorf("routeIsKnown: got %v, want holds", verdicts["routeIsKnown"])
} }
// Liveness: loginReachable hasn't resolved yet. if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
if verdicts["loginReachable"] != ltl.VerdictPending { t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"]) }
// Liveness: submitEventually hasn't resolved yet.
if verdicts["submitEventually"] != ltl.VerdictPending {
t.Errorf("submitEventually: got %v, want pending", verdicts["submitEventually"])
} }
} }
// TestSampleAppSpecActionsFireOnEachRoute pushes a hierarchy + snapshot pair // TestIntegrationSpecActionsFireOnEachRoute pushes a hierarchy + snapshot
// representative of each sample-app route and verifies the expected action // pair representative of each route and verifies the expected action
// generators fire against that state. Guards against silent breakage of any // generators fire against that state.
// one route's generators (a regression only e2e would otherwise catch). func TestIntegrationSpecActionsFireOnEachRoute(t *testing.T) {
func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
xml string xml string
@@ -247,47 +140,25 @@ func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
expectOns []string expectOns []string
}{ }{
{ {
name: "home", name: "list",
xml: homeHierarchyXML, xml: listHierarchyXML,
snapshots: homeSnapshots(), snapshots: listSnapshots(),
expectKind: ActionKindTap, expectKind: ActionKindTap,
expectOns: []string{ expectOns: []string{"desc:primary_action", "desc:secondary_action"},
"desc:add_account_button",
"desc:logout_button",
"descPrefix:account_card:",
},
}, },
{ {
name: "add-account", name: "form",
xml: addAccountHierarchyXML, xml: formHierarchyXML,
snapshots: addAccountSnapshots(), snapshots: formSnapshots(),
expectKind: ActionKindTap, expectKind: ActionKindTap,
expectOns: []string{"desc:add_account_submit", "desc:Back"}, expectOns: []string{"desc:primary_action", "desc:secondary_action"},
},
{
name: "ledger",
xml: ledgerHierarchyXML,
snapshots: ledgerSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:add_txn_button", "desc:Back"},
},
{
name: "add-transaction",
xml: addTxnHierarchyXML,
snapshots: addTxnSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{
"desc:txn_submit",
"desc:txn_debit",
"desc:Back",
},
}, },
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
v := newVerifier(t) v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil { if err := v.Load(bundleIntegrationSpec(t)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
tree, err := hierarchy.Parse(tc.xml) tree, err := hierarchy.Parse(tc.xml)
@@ -316,16 +187,8 @@ func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn)) t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn))
} }
} }
if tc.name == "add-account" && !sawInputText["desc:account_name_field"] { if tc.name == "form" && !sawInputText["desc:text_field"] {
t.Errorf("add-account: typeAccountName never fired; saw %v", keysOf(sawInputText)) t.Errorf("form: typeIntoField never fired; saw %v", keysOf(sawInputText))
}
if tc.name == "add-transaction" {
if !sawInputText["desc:txn_amount"] {
t.Errorf("add-transaction: typeAmount never fired; saw %v", keysOf(sawInputText))
}
if !sawInputText["desc:txn_note"] {
t.Errorf("add-transaction: typeNote never fired; saw %v", keysOf(sawInputText))
}
} }
}) })
} }
+68
View File
@@ -0,0 +1,68 @@
import {
InputText,
Tap,
actions,
always,
eventually,
extract,
weighted,
} from "@uatu/spec";
import { noUncaughtExceptions } from "@uatu/spec/defaults/properties";
const route = extract<string>(
(state) => (state.snapshots.route as string) ?? "",
);
const itemCount = extract<number>(
(state) => (state.snapshots.item_count as number) ?? 0,
);
const hasSubmitted = extract<boolean>(
(state) => (state.snapshots.has_submitted as boolean) ?? false,
);
const primaryAction = extract((state) => state.ax.find("desc:primary_action"));
const secondaryAction = extract((state) =>
state.ax.find("desc:secondary_action"),
);
const textField = extract((state) => state.ax.find("desc:text_field"));
const itemCountNonNegative = always(() => itemCount.current >= 0);
const routeIsKnown = always(
() => route.current === "list" || route.current === "form",
);
const submitEventually = eventually(() => hasSubmitted.current).within(
30,
"seconds",
);
const typeIntoField = actions(() => {
if (route.current !== "form") return [];
const field = textField.current;
if (!field) return [];
return [InputText({ into: field, text: "hello" })];
});
const tapPrimary = actions(() => {
const button = primaryAction.current;
return button ? [Tap({ on: button })] : [];
});
const tapSecondary = actions(() => {
const button = secondaryAction.current;
return button ? [Tap({ on: button })] : [];
});
export const properties = {
itemCountNonNegative,
routeIsKnown,
submitEventually,
noUncaughtExceptions,
};
export const actionsRoot = weighted(
[40, typeIntoField],
[30, tapPrimary],
[30, tapSecondary],
);
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
(globalThis as { properties?: unknown }).properties = properties;
@@ -9,10 +9,10 @@ class ResolveActivityTest {
@Test fun extractsActivityFromBriefOutput() { @Test fun extractsActivityFromBriefOutput() {
val output = """ val output = """
priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false
dev.uatu.sample/.MainActivity com.example.app/.MainActivity
""".trimIndent() """.trimIndent()
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output) val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output)
assertEquals(".MainActivity", activity) assertEquals(".MainActivity", activity)
} }
@@ -26,14 +26,14 @@ class ResolveActivityTest {
@Test fun returnsNullWhenPackageNotFound() { @Test fun returnsNullWhenPackageNotFound() {
val output = "No activity found" val output = "No activity found"
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output) val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output)
assertNull(activity) assertNull(activity)
} }
@Test fun doesNotMatchDifferentPackagePrefix() { @Test fun doesNotMatchDifferentPackagePrefix() {
val output = "other.pkg/.MainActivity" val output = "other.pkg/.MainActivity"
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output) val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output)
assertNull(activity) assertNull(activity)
} }
} }