mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
WIP: rename sample app to Folio (#20)
* refactor: rename examples/sample-app to examples/folio Directory-level rename and path references in Go tests, bundle-check, top-level README, and getting-started docs. Package declarations, Gradle config, iOS bundle IDs, and class names follow in later commits. * refactor(folio): rename Kotlin package dev.uatu.sample to app.folio Moves source dirs and sqldelight schema from dev/uatu/sample to app/folio, updates package declarations and imports, and switches Android namespace/applicationId, iOS binaryOption bundleId, and sqldelight database packageName to the new identifier. * refactor(folio): rename SampleApplication to FolioApplication Android manifest now points at .FolioApplication with label 'Folio' instead of 'Uatu Sample'. * refactor(folio): set iOS bundle id and display name to Folio bundleIdPrefix + PRODUCT_BUNDLE_IDENTIFIER -> app.folio. CFBundleName + CFBundleDisplayName -> 'Folio'. * refactor(folio): update demo email to [email protected] * refactor(folio): point justfile at app.folio bundle id Updates xcrun simctl launch target, uatu test --bundle-id, and the build/uninstall comments to reference folio instead of sample. * test: update fixture package ids to app.folio Sidecar activity-resolver test and verifier spec-integration XML fixtures referenced the old dev.uatu.sample Android package. Updates them to match the folio app's real package id so the tests stay representative of what the CLI sees on-device. * test(verifier): rename SampleApp identifiers to Folio Renames TestSampleAppSpec* functions, bundleSampleAppSpec helper, and sampleAppHierarchyXML const (now loginHierarchyXML for consistency with the other per-screen fixtures). Updates trailing sample-app mentions in comments and assertion messages. * refactor(folio): rename Gradle/npm/wasm project identifiers to folio settings.gradle.kts rootProject.name, package.json + package-lock.json name, and the WasmJS index.html <title> all still read 'uatu-sample' / 'Uatu Sample'. Realigns them with the Folio brand. * docs(folio): rewrite README title + getting-started bundle id examples/folio/README.md is now titled 'Folio' with the Kotlin source paths corrected to app/folio. Getting-started example uses --bundle-id app.folio. Harness launch message is now generic ('app under test') since uatu-sample-harness is not specific to folio. * chore(folio): drop trailing 'sample' reference in gradle.properties * refactor(folio): rename LoginPage composable to LoginScreen Align with KMP/Android industry convention (NowInAndroid, Cash App, JetBrains samples use Screen, not Page). * refactor(folio): rename HomePage composable to HomeScreen * refactor(folio): rename AddAccountPage composable to AddAccountScreen * refactor(folio): rename LedgerPage composable to LedgerScreen * refactor(folio): rename AddTransactionPage composable to AddTransactionScreen * refactor(folio): split Models.kt into app.folio.data package Account, Transaction (with TxnType), and Session move into their own files under app.folio.data, matching NowInAndroid-style per-type organization. * refactor(folio): move data layer into app.folio.data package Repository, LedgerStore (expect + interface), SqlLedgerStore, WebLedgerStore, DriverFactory (expect + actuals), AndroidLedgerContext, and Snapshot move into app.folio.data. Update all consumer imports. * refactor(folio): move Navigation into app.folio.navigation package Split the former Navigation.kt into Route.kt (sealed interface) and Navigator.kt (singleton). Update consumer imports across screens, App.kt, and FolioApplication. * refactor(folio): move Platform and Format into app.folio.platform Both files carry expect declarations (Platform object, formatDate); grouping them into a dedicated platform package makes the KMP seam obvious and mirrors the structure used by JetBrains samples. * refactor(folio): move login into feature/auth package Create app.folio.feature.auth with LoginScreen + LoginUiState. Inline the former Auth.kt (DEMO_EMAIL, DEMO_PASSWORD, checkCredentials) into LoginScreen since it is the sole caller. * refactor(folio): move HomeScreen into feature/home package * refactor(folio): move account creation into feature/account package AddAccountScreen gets its own AddAccountUiState colocated with the screen, replacing the shared UiState.addAccountError. * refactor(folio): move ledger screens into feature/ledger package LedgerScreen and AddTransactionScreen move into app.folio.feature.ledger with AddTransactionUiState (txnError, txnFormType) colocated. The former catch-all UiState.kt is removed now that each screen owns its state alongside its UI. * refactor(folio): split Theme.kt; move theme and icons to subpackages Theme split into Theme.kt (tokens, layout dims, LedgerTheme) and Type.kt (typography) under app.folio.ui.theme. Icons moves to app.folio.ui.icon. Update every consumer's imports to match. * refactor(folio): split ui components into per-file under ui/component Former Widgets.kt and Components.kt become 10 focused files: AppButton, Card, EmptyState, ErrorText, FieldLabel, Header, IconButton (w/ BackButton), Screen, Segmented, TextInput. Matches NowInAndroid style of one composable per file in a designsystem/component package. * chore(folio): consolidate uatu testing files under uatu/ folder Move spec.ts, package.json, package-lock.json into examples/folio/uatu so all uatu-specific testing artifacts live in one place. runs/ and node_modules/ follow the same convention (both remain gitignored). Update justfile, README, and the two Go consumers (bundle-check tool + verifier/trace tests) that referenced the old path. * refactor(trace): drop folio path in writer test Round-trip only needs a non-empty string; neutralize to keep the library free of folio references. * refactor(sidecar): neutralize ResolveActivity test fixtures Swap app.folio for com.example.app in the fixture strings so the sidecar tests don't reference the example app by name. * refactor(bundle-check): take spec path as argument Previously the tool hardcoded examples/folio/uatu/spec.ts. Accept a positional spec path instead so the tool works for any example and leaves no folio reference in the library surface. * test(verifier): add neutral integration spec and hierarchy fixtures Adds testdata/integration_spec.ts with two routes ("list", "form"), an InputText on text_field, a Tap on primary/secondary_action, a safety property (itemCountNonNegative), and a liveness property (submitEventually). Adds hierarchies_test.go with matching XML fixtures. Constants intentionally go in a _test.go at package root rather than testdata/hierarchies.go because go skips .go files under testdata/. * refactor(verifier): replace folio integration tests with neutral ones Renames bundleFolioSpec -> bundleIntegrationSpec and the three Test* entry points to TestIntegrationSpec*. Uses the synthetic spec and hierarchies added in the previous commit so the library's test suite no longer references examples/folio at all. Folio-specific coverage remains covered by examples/folio/justfile's 'just test' which exercises the real spec on device/emulator. * chore: remove cmd/uatu-sample-harness Not referenced by Makefile, docs, CI, or any script. Duplicates the adb reverse helpers already in cmd/uatu/test_run.go, and its name implies ownership by the sample app which violates the library/ example decoupling. If a bare-protocol debugging tool is later needed it belongs inside cmd/uatu/. * docs(folio): drop Layout section and KMP layout paragraph; fix AVD override syntax The directory-tree Layout section rots faster than the code and duplicates what ls shows for free. The expect/actual paragraph in Stack was the same kind of filler. The README also claimed 'just AVD=Pixel_7 test' but the justfile reads AVD as an env var via env_var_or_default, so the correct invocation is 'AVD=Pixel_7 just test'.
This commit is contained in:
88 files changed
+949
-900
No files matched your search
@@ -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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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.
+4
-4
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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
|
||||||
+16
-6
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.uatu.sample
|
package app.folio.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.uatu.sample
|
package app.folio.platform
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
+1
-1
@@ -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
|
||||||
+17
-14
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.uatu.sample
|
package app.folio.data
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
+2
-1
@@ -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
-12
@@ -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,
|
|
||||||
)
|
|
||||||
+23
-13
@@ -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",
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package app.folio.feature.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
object AddAccountUiState {
|
||||||
|
val addAccountError = MutableStateFlow("")
|
||||||
|
}
|
||||||
+26
-14
@@ -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("")
|
||||||
|
}
|
||||||
+19
-9
@@ -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()
|
||||||
+30
-19
@@ -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?",
|
||||||
+2
-4
@@ -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)
|
||||||
}
|
}
|
||||||
+22
-10
@@ -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
-9
@@ -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
|
||||||
|
}
|
||||||
+3
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-19
@@ -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)
|
||||||
|
}
|
||||||
File renamed without changes.
+3
-2
@@ -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
|
||||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.uatu.sample
|
package app.folio.data
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
|
||||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+3
-2
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package dev.uatu.sample
|
package app.folio.platform
|
||||||
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
+1
-1
@@ -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
-1
@@ -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.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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>
|
||||||
File renamed without changes.
@@ -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}}" \
|
||||||
File renamed without changes.
@@ -15,6 +15,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "uatu-sample"
|
rootProject.name = "folio"
|
||||||
|
|
||||||
include(":composeApp")
|
include(":composeApp")
|
||||||
+2
-2
@@ -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
|
|
||||||
}
|
|
||||||
-162
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>`
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user