mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07: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
@@ -6,6 +6,7 @@ iosApp/build/
|
||||
iosApp/iosApp.xcodeproj/
|
||||
iosApp/iosApp.xcodeproj/**
|
||||
.DS_Store
|
||||
runs/
|
||||
uatu/node_modules/
|
||||
uatu/runs/
|
||||
.playwright-mcp/
|
||||
web-*.png
|
||||
@@ -1,9 +1,9 @@
|
||||
# uatu sample app
|
||||
# Folio
|
||||
|
||||
A minimal Kotlin Multiplatform ledger app that mirrors the React reference:
|
||||
login with demo credentials, create accounts, add credits and debits. Same
|
||||
features, monospace look, and demo creds, shared across Android and iOS via
|
||||
Compose Multiplatform.
|
||||
A minimal Kotlin Multiplatform personal-ledger app: login with demo
|
||||
credentials, create accounts, add credits and debits. Shared UI across
|
||||
Android, iOS, and Web via Compose Multiplatform. Doubles as the example
|
||||
uatu runs its property-based specs against.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -12,10 +12,6 @@ Compose Multiplatform.
|
||||
- kotlinx.coroutines for state flows
|
||||
- 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
|
||||
|
||||
- `just`
|
||||
@@ -46,7 +42,7 @@ launches.
|
||||
## Demo credentials
|
||||
|
||||
```
|
||||
email: demo@ledger.app
|
||||
email: demo@folio.app
|
||||
password: ledger123
|
||||
```
|
||||
|
||||
@@ -60,7 +56,7 @@ If no device is connected, uatu boots the single AVD it finds. With multiple
|
||||
AVDs, pick one:
|
||||
|
||||
```sh
|
||||
just AVD=Pixel_7 test
|
||||
AVD=Pixel_7 just test
|
||||
```
|
||||
|
||||
Persistent settings can live in `.env` alongside the justfile:
|
||||
@@ -70,28 +66,13 @@ AVD=Pixel_7
|
||||
DURATION=5m
|
||||
```
|
||||
|
||||
Traces land in `./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
|
||||
```
|
||||
Traces land in `./uatu/runs/<timestamp>/`.
|
||||
|
||||
## 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`,
|
||||
`total_balance`, `route`)
|
||||
- `spec.ts` imports `@uatu/spec`, reads those snapshots, asserts properties,
|
||||
and weights the actions the fuzzer picks from
|
||||
- `uatu/spec.ts` imports `@uatu/spec`, reads those snapshots, asserts
|
||||
properties, and weights the actions the fuzzer picks from
|
||||
- `just test` invokes `uatu test` against the installed APK
|
||||
File renamed without changes.
+4
-4
@@ -35,7 +35,7 @@ kotlin {
|
||||
target.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
binaryOption("bundleId", "dev.uatu.sample")
|
||||
binaryOption("bundleId", "app.folio")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@ kotlin {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.uatu.sample"
|
||||
namespace = "app.folio"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.uatu.sample"
|
||||
applicationId = "app.folio"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
@@ -124,7 +124,7 @@ android {
|
||||
sqldelight {
|
||||
databases {
|
||||
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"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:name=".SampleApplication"
|
||||
android:label="Uatu Sample"
|
||||
android:name=".FolioApplication"
|
||||
android:label="Folio"
|
||||
android:allowBackup="false"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
+16
-6
@@ -1,9 +1,19 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio
|
||||
|
||||
import android.app.Application
|
||||
import app.folio.data.AndroidLedgerContext
|
||||
import app.folio.data.Repository
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.feature.account.AddAccountUiState
|
||||
import app.folio.feature.auth.LoginUiState
|
||||
import app.folio.feature.ledger.AddTransactionUiState
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.signedAmount
|
||||
import dev.uatu.sdk.Uatu
|
||||
|
||||
class SampleApplication : Application() {
|
||||
class FolioApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AndroidLedgerContext.context = applicationContext
|
||||
@@ -72,12 +82,12 @@ class SampleApplication : Application() {
|
||||
else balanceOf(Repository.transactions.value.filter { it.accountId == active })
|
||||
}
|
||||
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") {
|
||||
(Navigator.current.value as? Route.AddTransaction)?.accountId
|
||||
}
|
||||
Uatu.extract("login_error") { UiState.loginError.value }
|
||||
Uatu.extract("add_account_error") { UiState.addAccountError.value }
|
||||
Uatu.extract("txn_error") { UiState.txnError.value }
|
||||
Uatu.extract("login_error") { LoginUiState.loginError.value }
|
||||
Uatu.extract("add_account_error") { AddAccountUiState.addAccountError.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 androidx.activity.ComponentActivity
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
import android.content.Context
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
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 fun create(): SqlDriver =
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
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.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.layout.Box
|
||||
@@ -13,14 +13,17 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.uatu.sample.ui.AddAccountPage
|
||||
import dev.uatu.sample.ui.AddTransactionPage
|
||||
import dev.uatu.sample.ui.HomePage
|
||||
import dev.uatu.sample.ui.LedgerPage
|
||||
import dev.uatu.sample.ui.LedgerTheme
|
||||
import dev.uatu.sample.ui.LocalTokens
|
||||
import dev.uatu.sample.ui.LoginPage
|
||||
import dev.uatu.sample.ui.Tokens
|
||||
import app.folio.data.Repository
|
||||
import app.folio.feature.account.AddAccountScreen
|
||||
import app.folio.feature.auth.LoginScreen
|
||||
import app.folio.feature.home.HomeScreen
|
||||
import app.folio.feature.ledger.AddTransactionScreen
|
||||
import app.folio.feature.ledger.LedgerScreen
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.ui.theme.LedgerTheme
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Tokens
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
@@ -46,17 +49,17 @@ fun App() {
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
when (val r = route) {
|
||||
Route.Login -> LoginPage(onLoggedIn = { Navigator.replace(Route.Home) })
|
||||
Route.Home -> HomePage(
|
||||
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
|
||||
Route.Home -> HomeScreen(
|
||||
user = session?.user ?: "",
|
||||
onLogout = {
|
||||
Repository.clearSession()
|
||||
Navigator.replace(Route.Login)
|
||||
},
|
||||
)
|
||||
Route.AddAccount -> AddAccountPage()
|
||||
is Route.Ledger -> LedgerPage(accountId = r.accountId)
|
||||
is Route.AddTransaction -> AddTransactionPage(accountId = r.accountId)
|
||||
Route.AddAccount -> AddAccountScreen()
|
||||
is Route.Ledger -> LedgerScreen(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.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
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
import app.folio.platform.Platform
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
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
|
||||
|
||||
data class Account(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
package app.folio.data
|
||||
|
||||
enum class TxnType { credit, debit }
|
||||
|
||||
@@ -16,8 +10,3 @@ data class Transaction(
|
||||
val note: String,
|
||||
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.Column
|
||||
@@ -14,37 +14,47 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.uatu.sample.Navigator
|
||||
import dev.uatu.sample.Repository
|
||||
import dev.uatu.sample.Route
|
||||
import dev.uatu.sample.UiState
|
||||
import app.folio.data.Repository
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.ui.BackHandler
|
||||
import app.folio.ui.component.AppButton
|
||||
import app.folio.ui.component.BackButton
|
||||
import app.folio.ui.component.ButtonStyle
|
||||
import app.folio.ui.component.ErrorText
|
||||
import app.folio.ui.component.FieldLabel
|
||||
import app.folio.ui.component.Header
|
||||
import app.folio.ui.component.Screen
|
||||
import app.folio.ui.component.TextInput
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun AddAccountPage() {
|
||||
fun AddAccountScreen() {
|
||||
val t = LocalTokens.current
|
||||
var name by remember { mutableStateOf("") }
|
||||
val err by UiState.addAccountError.collectAsState()
|
||||
val err by AddAccountUiState.addAccountError.collectAsState()
|
||||
|
||||
BackHandler { Navigator.back(Route.Home) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { UiState.addAccountError.value = "" }
|
||||
onDispose { AddAccountUiState.addAccountError.value = "" }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
UiState.addAccountError.value = "Account name is required"; return
|
||||
AddAccountUiState.addAccountError.value = "Account name is required"; return
|
||||
}
|
||||
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 {
|
||||
Repository.createAccount(trimmed)
|
||||
UiState.addAccountError.value = ""
|
||||
AddAccountUiState.addAccountError.value = ""
|
||||
Navigator.replace(Route.Home)
|
||||
} 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")
|
||||
TextInput(
|
||||
value = name,
|
||||
onChange = { name = it; UiState.addAccountError.value = "" },
|
||||
onChange = { name = it; AddAccountUiState.addAccountError.value = "" },
|
||||
placeholder = "e.g. Checking",
|
||||
invalid = err.isNotEmpty(),
|
||||
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.Column
|
||||
@@ -17,31 +17,43 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.uatu.sample.DEMO_EMAIL
|
||||
import dev.uatu.sample.DEMO_PASSWORD
|
||||
import dev.uatu.sample.Repository
|
||||
import dev.uatu.sample.UiState
|
||||
import dev.uatu.sample.checkCredentials
|
||||
import app.folio.data.Repository
|
||||
import app.folio.ui.component.AppButton
|
||||
import app.folio.ui.component.ButtonStyle
|
||||
import app.folio.ui.component.Card
|
||||
import app.folio.ui.component.ErrorText
|
||||
import app.folio.ui.component.FieldLabel
|
||||
import app.folio.ui.component.Screen
|
||||
import app.folio.ui.component.TextInput
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
const val DEMO_EMAIL = "[email protected]"
|
||||
const val DEMO_PASSWORD = "ledger123"
|
||||
|
||||
private fun checkCredentials(email: String, password: String): Boolean {
|
||||
return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginPage(onLoggedIn: (String) -> Unit) {
|
||||
fun LoginScreen(onLoggedIn: (String) -> Unit) {
|
||||
val t = LocalTokens.current
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val err by UiState.loginError.collectAsState()
|
||||
val err by LoginUiState.loginError.collectAsState()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { UiState.loginError.value = "" }
|
||||
onDispose { LoginUiState.loginError.value = "" }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
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)) {
|
||||
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()
|
||||
Repository.setSession(user)
|
||||
onLoggedIn(user)
|
||||
@@ -57,7 +69,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
|
||||
FieldLabel("Email")
|
||||
TextInput(
|
||||
value = email,
|
||||
onChange = { email = it; UiState.loginError.value = "" },
|
||||
onChange = { email = it; LoginUiState.loginError.value = "" },
|
||||
placeholder = DEMO_EMAIL,
|
||||
invalid = err.isNotEmpty(),
|
||||
keyboardType = KeyboardType.Email,
|
||||
@@ -69,7 +81,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) {
|
||||
FieldLabel("Password")
|
||||
TextInput(
|
||||
value = password,
|
||||
onChange = { password = it; UiState.loginError.value = "" },
|
||||
onChange = { password = it; LoginUiState.loginError.value = "" },
|
||||
placeholder = "••••••••",
|
||||
password = true,
|
||||
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.border
|
||||
@@ -23,16 +23,26 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import dev.uatu.sample.Navigator
|
||||
import dev.uatu.sample.Repository
|
||||
import dev.uatu.sample.Route
|
||||
import dev.uatu.sample.balanceOf
|
||||
import dev.uatu.sample.formatCents
|
||||
import dev.uatu.sample.initialsOf
|
||||
import dev.uatu.sample.signedAmount
|
||||
import app.folio.data.Repository
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.formatCents
|
||||
import app.folio.platform.initialsOf
|
||||
import app.folio.platform.signedAmount
|
||||
import app.folio.ui.component.AppButton
|
||||
import app.folio.ui.component.ButtonStyle
|
||||
import app.folio.ui.component.EmptyState
|
||||
import app.folio.ui.component.Header
|
||||
import app.folio.ui.component.IconButton
|
||||
import app.folio.ui.component.Screen
|
||||
import app.folio.ui.icon.Icons
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.RadiusLg
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun HomePage(user: String, onLogout: () -> Unit) {
|
||||
fun HomeScreen(user: String, onLogout: () -> Unit) {
|
||||
val t = LocalTokens.current
|
||||
val accounts by Repository.accounts.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.Column
|
||||
@@ -15,17 +15,28 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.uatu.sample.Navigator
|
||||
import dev.uatu.sample.Repository
|
||||
import dev.uatu.sample.Route
|
||||
import dev.uatu.sample.TxnType
|
||||
import dev.uatu.sample.UiState
|
||||
import dev.uatu.sample.parseCents
|
||||
import app.folio.data.Repository
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.platform.parseCents
|
||||
import app.folio.ui.BackHandler
|
||||
import app.folio.ui.component.AppButton
|
||||
import app.folio.ui.component.BackButton
|
||||
import app.folio.ui.component.ButtonStyle
|
||||
import app.folio.ui.component.EmptyState
|
||||
import app.folio.ui.component.ErrorText
|
||||
import app.folio.ui.component.FieldLabel
|
||||
import app.folio.ui.component.Header
|
||||
import app.folio.ui.component.Screen
|
||||
import app.folio.ui.component.Segmented
|
||||
import app.folio.ui.component.TextInput
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
|
||||
|
||||
@Composable
|
||||
fun AddTransactionPage(accountId: String) {
|
||||
fun AddTransactionScreen(accountId: String) {
|
||||
val accounts by Repository.accounts.collectAsState()
|
||||
val account = accounts.firstOrNull { it.id == accountId }
|
||||
|
||||
@@ -50,31 +61,31 @@ fun AddTransactionPage(accountId: String) {
|
||||
var type by remember { mutableStateOf(TxnType.credit) }
|
||||
var amount by remember { mutableStateOf("") }
|
||||
var note by remember { mutableStateOf("") }
|
||||
val err by UiState.txnError.collectAsState()
|
||||
val err by AddTransactionUiState.txnError.collectAsState()
|
||||
|
||||
DisposableEffect(type) {
|
||||
UiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit"
|
||||
onDispose { UiState.txnFormType.value = null }
|
||||
AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit"
|
||||
onDispose { AddTransactionUiState.txnFormType.value = null }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { UiState.txnError.value = "" }
|
||||
onDispose { AddTransactionUiState.txnError.value = "" }
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val cents = parseCents(amount)
|
||||
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) {
|
||||
UiState.txnError.value = "Amount must be greater than zero"; return
|
||||
AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return
|
||||
}
|
||||
try {
|
||||
Repository.createTransaction(accountId, type, cents, note)
|
||||
UiState.txnError.value = ""
|
||||
AddTransactionUiState.txnError.value = ""
|
||||
Navigator.back(Route.Home)
|
||||
} 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,
|
||||
labels = listOf("Credit", "Debit"),
|
||||
onSelect = {
|
||||
UiState.txnError.value = ""
|
||||
AddTransactionUiState.txnError.value = ""
|
||||
type = if (it == 0) TxnType.credit else TxnType.debit
|
||||
},
|
||||
descriptions = listOf("txn_credit", "txn_debit"),
|
||||
@@ -114,7 +125,7 @@ fun AddTransactionPage(accountId: String) {
|
||||
TextInput(
|
||||
value = amount,
|
||||
onChange = {
|
||||
UiState.txnError.value = ""
|
||||
AddTransactionUiState.txnError.value = ""
|
||||
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
|
||||
amount = it
|
||||
}
|
||||
@@ -133,7 +144,7 @@ fun AddTransactionPage(accountId: String) {
|
||||
TextInput(
|
||||
value = note,
|
||||
onChange = {
|
||||
UiState.txnError.value = ""
|
||||
AddTransactionUiState.txnError.value = ""
|
||||
note = it.take(80)
|
||||
},
|
||||
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
|
||||
|
||||
object UiState {
|
||||
val loginError = MutableStateFlow("")
|
||||
val addAccountError = MutableStateFlow("")
|
||||
object AddTransactionUiState {
|
||||
val txnError = MutableStateFlow("")
|
||||
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.border
|
||||
@@ -22,17 +22,29 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.uatu.sample.Navigator
|
||||
import dev.uatu.sample.Repository
|
||||
import dev.uatu.sample.Route
|
||||
import dev.uatu.sample.TxnType
|
||||
import dev.uatu.sample.balanceOf
|
||||
import dev.uatu.sample.formatCents
|
||||
import dev.uatu.sample.formatDate
|
||||
import dev.uatu.sample.signedAmount
|
||||
import app.folio.data.Repository
|
||||
import app.folio.navigation.Navigator
|
||||
import app.folio.navigation.Route
|
||||
import app.folio.data.TxnType
|
||||
import app.folio.platform.balanceOf
|
||||
import app.folio.platform.formatCents
|
||||
import app.folio.platform.formatDate
|
||||
import app.folio.platform.signedAmount
|
||||
import app.folio.ui.BackHandler
|
||||
import app.folio.ui.component.AppButton
|
||||
import app.folio.ui.component.BackButton
|
||||
import app.folio.ui.component.ButtonStyle
|
||||
import app.folio.ui.component.Card
|
||||
import app.folio.ui.component.EmptyState
|
||||
import app.folio.ui.component.Header
|
||||
import app.folio.ui.component.Screen
|
||||
import app.folio.ui.icon.Icons
|
||||
import app.folio.ui.theme.LocalTokens
|
||||
import app.folio.ui.theme.RadiusLg
|
||||
import app.folio.ui.theme.Type
|
||||
|
||||
@Composable
|
||||
fun LedgerPage(accountId: String) {
|
||||
fun LedgerScreen(accountId: String) {
|
||||
val t = LocalTokens.current
|
||||
val accounts by Repository.accounts.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.StateFlow
|
||||
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 {
|
||||
private val stack = ArrayDeque<Route>().apply { addLast(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
|
||||
|
||||
private const val CURRENCY = "$"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
expect object Platform {
|
||||
fun now(): Long
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample.ui
|
||||
package app.folio.ui
|
||||
|
||||
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.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.material3.MaterialTheme
|
||||
@@ -6,11 +6,7 @@ import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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.sp
|
||||
|
||||
data class Tokens(
|
||||
val bg: Color = Color(0xFFFFFFFF),
|
||||
@@ -27,20 +23,6 @@ data class 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
|
||||
fun LedgerTheme(content: @Composable () -> Unit) {
|
||||
@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 dev.uatu.sample.ui.IosBackGesture
|
||||
import app.folio.data.Repository
|
||||
import app.folio.ui.IosBackGesture
|
||||
import kotlin.native.ObjCName
|
||||
import kotlinx.cinterop.BetaInteropApi
|
||||
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.driver.native.NativeSqliteDriver
|
||||
import dev.uatu.sample.db.LedgerDatabase
|
||||
import app.folio.db.LedgerDatabase
|
||||
|
||||
actual class DriverFactory {
|
||||
actual fun create(): SqlDriver =
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
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.NSDateFormatter
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
import platform.Foundation.NSDate
|
||||
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.DisposableEffect
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample.ui
|
||||
package app.folio.ui
|
||||
|
||||
object IosBackGesture {
|
||||
private var nextId = 0
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
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.mapToList
|
||||
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||
import dev.uatu.sample.db.LedgerDatabase
|
||||
import app.folio.db.LedgerDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.data
|
||||
|
||||
internal data class Snapshot(
|
||||
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.StateFlow
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
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.window
|
||||
import org.w3c.dom.events.Event
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
private val MONTHS = arrayOf(
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.uatu.sample
|
||||
package app.folio.platform
|
||||
|
||||
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.DisposableEffect
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; }
|
||||
body { display: flex; justify-content: center; }
|
||||
@@ -5,6 +5,6 @@ kotlin.code.style=official
|
||||
android.useAndroidX=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>`.
|
||||
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>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Uatu Sample</string>
|
||||
<string>Folio</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Uatu Sample</string>
|
||||
<string>Folio</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
name: iosApp
|
||||
options:
|
||||
bundleIdPrefix: dev.uatu.sample
|
||||
bundleIdPrefix: app.folio
|
||||
deploymentTarget:
|
||||
iOS: "15.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -24,7 +24,7 @@ targets:
|
||||
- sdk: libsqlite3.tbd
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: dev.uatu.sample
|
||||
PRODUCT_BUNDLE_IDENTIFIER: app.folio
|
||||
PRODUCT_NAME: iosApp
|
||||
INFOPLIST_FILE: iosApp/Info.plist
|
||||
FRAMEWORK_SEARCH_PATHS:
|
||||
@@ -5,7 +5,7 @@ uatu := env_var_or_default("UATU", "uatu")
|
||||
avd := env_var_or_default("AVD", "")
|
||||
duration := env_var_or_default("DURATION", "1m")
|
||||
seed := env_var_or_default("SEED", "0")
|
||||
output := env_var_or_default("OUTPUT", justfile_directory() / "runs")
|
||||
output := env_var_or_default("OUTPUT", justfile_directory() / "uatu" / "runs")
|
||||
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
|
||||
|
||||
default:
|
||||
@@ -35,14 +35,14 @@ _android-home:
|
||||
echo "could not locate Android SDK (set ANDROID_HOME)" >&2
|
||||
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:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
./gradlew :composeApp:installDebug
|
||||
|
||||
# Remove the sample APK from the connected device.
|
||||
# Remove the folio APK from the connected device.
|
||||
uninstall:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -79,9 +79,9 @@ ios:
|
||||
-derivedDataPath iosApp/build \
|
||||
build | tail -5
|
||||
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.
|
||||
test:
|
||||
#!/usr/bin/env bash
|
||||
@@ -91,8 +91,8 @@ test:
|
||||
avd_flag=(--avd "{{avd}}")
|
||||
fi
|
||||
"{{uatu}}" test \
|
||||
--spec "{{justfile_directory()}}/spec.ts" \
|
||||
--bundle-id dev.uatu.sample \
|
||||
--spec "{{justfile_directory()}}/uatu/spec.ts" \
|
||||
--bundle-id app.folio \
|
||||
"${avd_flag[@]}" \
|
||||
--duration "{{duration}}" \
|
||||
--seed "{{seed}}" \
|
||||
File renamed without changes.
@@ -15,6 +15,6 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "uatu-sample"
|
||||
rootProject.name = "folio"
|
||||
|
||||
include(":composeApp")
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "uatu-sample",
|
||||
"name": "folio",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "uatu-sample",
|
||||
"name": "folio",
|
||||
"devDependencies": {
|
||||
"@uatu/spec": "next"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "uatu-sample",
|
||||
"name": "folio",
|
||||
"private": true,
|
||||
"description": "Minimal uatu integration example.",
|
||||
"devDependencies": {
|
||||
@@ -274,7 +274,7 @@ const liveness = {
|
||||
txnErrorClears,
|
||||
};
|
||||
|
||||
const DEMO_EMAIL = "demo@ledger.app";
|
||||
const DEMO_EMAIL = "demo@folio.app";
|
||||
const DEMO_PASSWORD = "ledger123";
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user