Port sample-app to KMP with Android, iOS, Web, and SQLite (#16)

* chore(sample-app): hoist gradle wrapper to sample-app root

* chore(sample-app): add KMP root gradle config

* chore(sample-app): add composeApp KMP module build config

* chore(sample-app): add Android manifest for composeApp

* feat(sample-app): add shared domain models and auth constants

* feat(sample-app): add shared number and date formatting

* feat(sample-app): add cross-platform storage, clock, and id

* feat(sample-app): add shared repository with file-backed state

* feat(sample-app): add shared in-memory navigator

* feat(sample-app): add Compose theme and design tokens

* feat(sample-app): add shared UI components (icons, screen, widgets)

* feat(sample-app): add Login and Home pages

* feat(sample-app): add AddAccount, Ledger, AddTransaction pages

* feat(sample-app): add App root composable with routing

* feat(sample-app): add Android Application and Activity hosting Compose UI

* chore(sample-app): remove legacy android module (replaced by composeApp)

* chore(sample-app): bump to latest stable Kotlin/AGP/Compose deps

* fix(sample-app): make iOS compile (drop @Volatile, set bundleId)

* feat(sample-app): add xcodegen spec, SwiftUI host, and iOS Info.plist

* chore(sample-app): ignore build artifacts and generated xcodeproj

* chore(sample-app): update justfile for composeApp layout, add ios target

* docs(sample-app): rewrite README for KMP + iOS flow

* refactor(sample-app): drop in-app status bar and formatClock

* feat(sample-app): add SQLDelight schema and per-platform drivers

* refactor(sample-app): back Repository with SQLite, drop file serializer

* feat(sample-app): wire native back on Android and iOS edge swipe

* feat(sample-app): semantic roles, labels, and a11y descriptions

* fix(sample-app): link libsqlite3 for iOS target

SQLDelight's native driver needs libsqlite3.tbd on iOS; without it the
linker fails with undefined _sqlite3_bind_blob and friends.

* refactor(sample-app): abstract storage behind LedgerStore interface

Platform-specific createLedgerStore() returns a SqlLedgerStore backed
by SQLDelight on Android + iOS. Opens the door for a pure in-memory
web implementation that does not require a SQLite driver.

* feat(sample-app): add wasmJs target with in-memory LedgerStore

Wires a Compose Multiplatform browser canvas entry point. The web
implementation of LedgerStore is an in-memory model with localStorage
persistence, so it does not need a SQLite driver. Back navigation maps
the browser back button to the same BackHandler contract Android and
iOS use.

* chore(sample-app): settings + gitignore for wasmJs dev run

Registers the Node.js distributions repository and switches
repositoriesMode to PREFER_PROJECT so the Kotlin wasmJs plugin can
download its toolchain. Adds kotlin-js-store (lockfile) and ignores
runs/, web screenshots, playwright-mcp scratch output.

* fix(sample-app): singularize transaction count on Home

Shows "1 transaction" not "1 transactions" for accounts with a single
transaction; falls back to "$count transactions" otherwise.
This commit is contained in:
pj authored and GitHub committed 2026-04-19 15:56:33 +07:00
1 parent 69002b07b1
commit 5b5594ee05
61 files changed
+2247 -166

No files matched your search

+11 -1
View File
@@ -1 +1,11 @@
/runs
.gradle/
build/
.kotlin/
local.properties
iosApp/build/
iosApp/iosApp.xcodeproj/
iosApp/iosApp.xcodeproj/**
.DS_Store
runs/
.playwright-mcp/
web-*.png
+65 -18
View File
@@ -1,23 +1,56 @@
# uatu sample app
Tiny Android app wired to the uatu SDK, plus a TypeScript spec that drives it.
Use it as a reference for integrating uatu into your own app.
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.
## Stack
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
- kotlinx.serialization for file-backed persistence
- kotlinx.coroutines for state flows
- uatu `sdk-android` for harness integration on Android
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
- `uatu` CLI on `PATH` (see [getting started](https://priyanshujain.github.io/uatu/manual/getting-started.html))
- Android SDK installed (uatu auto-discovers `adb` and `emulator` under
`$ANDROID_HOME`, `~/Library/Android/sdk`, or the Homebrew cask; nothing to
export if you use a standard install)
- `just` task runner
- `just`
- JDK 17
- Android SDK (auto-discovered under `$ANDROID_HOME`, `~/Library/Android/sdk`,
or the Homebrew cask)
- Xcode 16+ and `xcodegen` (`brew install xcodegen`) for iOS
## Install the app
## Android
```sh
just install
just install # build + install on a booted emulator / device
just uninstall
just clean
```
## Run a test
## iOS
```sh
just ios # default device: iPhone 17 Pro
IOS_DEVICE="iPhone 15" just ios # pick a different simulator
```
`just ios` regenerates `iosApp/iosApp.xcodeproj` from `iosApp/project.yml`,
builds the KMP framework, links it into the SwiftUI host, installs, and
launches.
## Demo credentials
```
email: [email protected]
password: ledger123
```
## Run a uatu test (Android)
```sh
just test
@@ -39,12 +72,26 @@ DURATION=5m
Traces land in `./runs/<timestamp>/`.
## How the pieces connect
## Layout
- `android/build.gradle.kts` depends on `io.github.priyanshujain:sdk-android` from
Maven Central
- `android/src/main/kotlin/.../SampleApplication.kt` calls `Uatu.start(this)` and
registers snapshot extractors (`app_state`, `click_count`)
- `spec.ts` imports `@uatu/spec` (see `package.json`), reads those snapshots,
asserts properties on them, and weights the actions the fuzzer picks from
- `just test` invokes `uatu test` against the installed APK on the connected device (or the AVD named via `AVD=`)
```
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
- `composeApp/src/androidMain/.../SampleApplication.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
- `just test` invokes `uatu test` against the installed APK
@@ -1,38 +0,0 @@
plugins {
id("com.android.application") version "8.11.0"
kotlin("android") version "2.1.21"
}
val uatuVersion = findProperty("uatu.version") as String? ?: "0.0.0-dev"
android {
namespace = "dev.uatu.sample"
compileSdk = 35
defaultConfig {
applicationId = "dev.uatu.sample"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = uatuVersion
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
debug {
isDebuggable = true
}
}
}
dependencies {
implementation("io.github.priyanshujain:sdk-android:$uatuVersion")
}
@@ -1,85 +0,0 @@
package dev.uatu.sample
import android.app.Activity
import android.graphics.Color
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.Gravity
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
class MainActivity : Activity() {
companion object {
@Volatile var clickCount: Int = 0
@Volatile var username: String = ""
}
private lateinit var label: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(Color.parseColor("#FAFAFA"))
setPadding(64, 96, 64, 64)
}
label = TextView(this).apply {
text = "Clicks: 0"
textSize = 28f
setTextColor(Color.BLACK)
gravity = Gravity.CENTER
}
layout.addView(label)
val button = Button(this).apply {
text = "Click me"
textSize = 18f
setOnClickListener {
clickCount++
label.text = "Clicks: $clickCount"
}
}
layout.addView(button)
val resetButton = Button(this).apply {
text = "Reset"
textSize = 18f
setOnClickListener {
clickCount = 0
label.text = "Clicks: $clickCount"
}
}
layout.addView(resetButton)
val usernameLabel = TextView(this).apply {
text = "Username: "
textSize = 20f
setTextColor(Color.BLACK)
gravity = Gravity.CENTER
}
layout.addView(usernameLabel)
val usernameField = EditText(this).apply {
hint = "username"
contentDescription = "username_field"
textSize = 18f
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
username = s?.toString() ?: ""
usernameLabel.text = "Username: $username"
}
})
}
layout.addView(usernameField)
setContentView(layout)
}
}
@@ -1,13 +0,0 @@
package dev.uatu.sample
import android.app.Application
import dev.uatu.sdk.Uatu
class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
Uatu.start(this)
Uatu.extract("click_count") { MainActivity.clickCount }
Uatu.extract("username") { MainActivity.username }
}
}
+7
View File
@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
id("org.jetbrains.compose") version "1.10.3" apply false
id("app.cash.sqldelight") version "2.3.2" apply false
}
@@ -0,0 +1,130 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
kotlin("multiplatform")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
id("app.cash.sqldelight")
}
val uatuVersion = findProperty("uatu.version") as String? ?: "0.0.0-dev"
val sqldelightVersion = "2.3.2"
kotlin {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
optIn.add("kotlin.js.ExperimentalWasmJsInterop")
}
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64(),
).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
binaryOption("bundleId", "dev.uatu.sample")
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName.set("composeApp")
browser {
commonWebpackConfig {
outputFileName = "composeApp.js"
}
}
binaries.executable()
}
applyHierarchyTemplate {
common {
group("sql") {
withAndroidTarget()
group("apple") {
group("ios") {
withIosX64()
withIosArm64()
withIosSimulatorArm64()
}
}
}
group("wasmJs") {
withWasmJs()
}
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
implementation("app.cash.sqldelight:coroutines-extensions:$sqldelightVersion")
}
androidMain.dependencies {
implementation("androidx.activity:activity-compose:1.13.0")
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
implementation("io.github.priyanshujain:sdk-android:$uatuVersion")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:$sqldelightVersion")
}
}
}
android {
namespace = "dev.uatu.sample"
compileSdk = 36
defaultConfig {
applicationId = "dev.uatu.sample"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = uatuVersion
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
debug {
isDebuggable = true
}
}
sourceSets["main"].apply {
manifest.srcFile("src/androidMain/AndroidManifest.xml")
res.srcDirs("src/androidMain/res")
}
}
sqldelight {
databases {
create("LedgerDatabase") {
packageName.set("dev.uatu.sample.db")
}
}
}
@@ -3,10 +3,12 @@
<application
android:name=".SampleApplication"
android:label="Uatu Sample"
android:allowBackup="false">
android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -0,0 +1,11 @@
package dev.uatu.sample
import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import dev.uatu.sample.db.LedgerDatabase
actual class DriverFactory(private val context: Context) {
actual fun create(): SqlDriver =
AndroidSqliteDriver(LedgerDatabase.Schema, context, "ledger.db")
}
@@ -0,0 +1,9 @@
package dev.uatu.sample
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val DATE_FMT = SimpleDateFormat("MMM d, h:mm a", Locale.US)
actual fun formatDate(epochMillis: Long): String = DATE_FMT.format(Date(epochMillis))
@@ -0,0 +1,10 @@
package dev.uatu.sample
import android.content.Context
object AndroidLedgerContext {
lateinit var context: Context
}
actual fun createLedgerStore(): LedgerStore =
SqlLedgerStore(DriverFactory(AndroidLedgerContext.context))
@@ -0,0 +1,12 @@
package dev.uatu.sample
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
}
}
@@ -0,0 +1,8 @@
package dev.uatu.sample
import java.util.UUID
actual object Platform {
actual fun now(): Long = System.currentTimeMillis()
actual fun makeId(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,25 @@
package dev.uatu.sample
import android.app.Application
import dev.uatu.sdk.Uatu
class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
AndroidLedgerContext.context = applicationContext
Repository.init()
Uatu.start(this)
Uatu.extract("logged_in") { Repository.session.value != null }
Uatu.extract("account_count") { Repository.accounts.value.size }
Uatu.extract("total_balance") { balanceOf(Repository.transactions.value) }
Uatu.extract("route") {
when (Navigator.current.value) {
Route.Login -> "login"
Route.Home -> "home"
Route.AddAccount -> "add-account"
is Route.Ledger -> "ledger"
is Route.AddTransaction -> "add-transaction"
}
}
}
}
@@ -0,0 +1,7 @@
package dev.uatu.sample.ui
import androidx.compose.runtime.Composable
import androidx.activity.compose.BackHandler as AndroidBackHandler
@Composable
actual fun BackHandler(onBack: () -> Unit) = AndroidBackHandler(onBack = onBack)
@@ -0,0 +1,65 @@
package dev.uatu.sample
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import 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
@Composable
fun App() {
val session by Repository.session.collectAsState()
val route by Navigator.current.collectAsState()
LaunchedEffect(session, route) {
if (session == null && route !is Route.Login) {
Navigator.replace(Route.Login)
} else if (session != null && route is Route.Login) {
Navigator.replace(Route.Home)
}
}
LedgerTheme {
val t = Tokens()
CompositionLocalProvider(LocalTokens provides t) {
Box(
Modifier
.fillMaxSize()
.background(t.bg)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Column(Modifier.fillMaxSize()) {
when (val r = route) {
Route.Login -> LoginPage(onLoggedIn = { Navigator.replace(Route.Home) })
Route.Home -> HomePage(
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)
}
}
}
}
}
}
@@ -0,0 +1,8 @@
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
}
@@ -0,0 +1,71 @@
package dev.uatu.sample
import kotlin.math.absoluteValue
private const val CURRENCY = "$"
private val AMOUNT_REGEX = Regex("""^\d+(\.\d{1,2})?$""")
fun formatCents(cents: Long, signed: Boolean = false): String {
val abs = cents.absoluteValue
val dollars = abs / 100
val rem = (abs % 100).toInt()
val dollarsStr = withThousandsSeparator(dollars)
val remStr = rem.toString().padStart(2, '0')
val body = "$CURRENCY$dollarsStr.$remStr"
return when {
cents < 0 -> "-$body"
signed && cents > 0 -> "+$body"
else -> body
}
}
private fun withThousandsSeparator(value: Long): String {
val s = value.toString()
if (s.length <= 3) return s
val out = StringBuilder()
var count = 0
for (i in s.length - 1 downTo 0) {
out.append(s[i])
count++
if (count == 3 && i != 0) {
out.append(',')
count = 0
}
}
return out.reverse().toString()
}
fun parseCents(input: String): Long? {
val trimmed = input.trim().replace(",", "")
if (trimmed.isEmpty()) return null
if (!AMOUNT_REGEX.matches(trimmed)) return null
val dot = trimmed.indexOf('.')
val whole: String
val frac: String
if (dot < 0) {
whole = trimmed
frac = ""
} else {
whole = trimmed.substring(0, dot)
frac = trimmed.substring(dot + 1)
}
val fracPadded = (frac + "00").substring(0, 2)
val wholeLong = whole.toLongOrNull() ?: return null
val fracLong = fracPadded.toLongOrNull() ?: return null
val total = wholeLong * 100 + fracLong
if (total < 0) return null
return total
}
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
fun balanceOf(txns: List<Transaction>): Long = txns.sumOf { signedAmount(it) }
fun initialsOf(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (parts.isEmpty()) return "?"
if (parts.size == 1) return parts[0].take(2).uppercase()
return (parts.first().first().toString() + parts.last().first().toString()).uppercase()
}
expect fun formatDate(epochMillis: Long): String
@@ -0,0 +1,17 @@
package dev.uatu.sample
import kotlinx.coroutines.flow.StateFlow
interface LedgerStore {
val accounts: StateFlow<List<Account>>
val transactions: StateFlow<List<Transaction>>
val session: StateFlow<Session?>
fun accountExistsByName(name: String): Boolean
fun insertAccount(id: String, name: String, createdAt: Long)
fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long)
fun upsertSession(user: String, loggedInAt: Long)
fun clearSession()
}
expect fun createLedgerStore(): LedgerStore
@@ -0,0 +1,23 @@
package dev.uatu.sample
data class Account(
val id: String,
val name: String,
val createdAt: Long,
)
enum class TxnType { credit, debit }
data class Transaction(
val id: String,
val accountId: String,
val type: TxnType,
val amount: Long,
val note: String,
val createdAt: Long,
)
data class Session(
val user: String,
val loggedInAt: Long,
)
@@ -0,0 +1,39 @@
package dev.uatu.sample
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)
val current: StateFlow<Route> = _current.asStateFlow()
fun push(route: Route) {
stack.addLast(route)
_current.value = route
}
fun replace(route: Route) {
stack.clear()
stack.addLast(route)
_current.value = route
}
fun back(fallback: Route) {
if (stack.size > 1) {
stack.removeLast()
_current.value = stack.last()
} else {
replace(fallback)
}
}
}
@@ -0,0 +1,6 @@
package dev.uatu.sample
expect object Platform {
fun now(): Long
fun makeId(): String
}
@@ -0,0 +1,48 @@
package dev.uatu.sample
import kotlinx.coroutines.flow.StateFlow
object Repository {
private var _store: LedgerStore? = null
private val store: LedgerStore
get() = _store ?: error("Repository not initialized")
val accounts: StateFlow<List<Account>> get() = store.accounts
val transactions: StateFlow<List<Transaction>> get() = store.transactions
val session: StateFlow<Session?> get() = store.session
fun init() {
if (_store == null) _store = createLedgerStore()
}
fun createAccount(name: String): Account {
val trimmed = name.trim()
require(trimmed.isNotEmpty()) { "Name is required" }
require(trimmed.length <= 40) { "Name is too long (max 40)" }
require(!store.accountExistsByName(trimmed)) { "An account with that name already exists" }
val account = Account(id = Platform.makeId(), name = trimmed, createdAt = Platform.now())
store.insertAccount(account.id, account.name, account.createdAt)
return account
}
fun getAccount(id: String): Account? = accounts.value.firstOrNull { it.id == id }
fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
require(amount > 0) { "Amount must be greater than zero" }
requireNotNull(getAccount(accountId)) { "Account not found" }
val txn = Transaction(
id = Platform.makeId(),
accountId = accountId,
type = type,
amount = amount,
note = note.trim().take(80),
createdAt = Platform.now(),
)
store.insertTxn(txn.id, txn.accountId, txn.type, txn.amount, txn.note, txn.createdAt)
return txn
}
fun setSession(user: String) = store.upsertSession(user, Platform.now())
fun clearSession() = store.clearSession()
}
@@ -0,0 +1,78 @@
package dev.uatu.sample.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator
import dev.uatu.sample.Repository
import dev.uatu.sample.Route
@Composable
fun AddAccountPage() {
val t = LocalTokens.current
var name by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) }
BackHandler { Navigator.back(Route.Home) }
fun submit() {
val trimmed = name.trim()
if (trimmed.isEmpty()) {
err = "Account name is required"; return
}
if (trimmed.length > 40) {
err = "Name is too long (max 40 characters)"; return
}
try {
Repository.createAccount(trimmed)
Navigator.replace(Route.Home)
} catch (e: IllegalArgumentException) {
err = e.message ?: "Could not create account"
}
}
Screen(
header = {
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
},
footer = {
AppButton(
text = "Create account",
onClick = ::submit,
style = ButtonStyle.Primary,
enabled = name.trim().isNotEmpty(),
)
},
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Account name")
TextInput(
value = name,
onChange = { name = it; err = null },
placeholder = "e.g. Checking",
invalid = err != null,
label = "Account name",
)
}
ErrorText(err)
Text(
"Use a short, recognizable name. You can create as many accounts as you need.",
style = Type.caption,
color = t.textMuted,
)
}
}
}
@@ -0,0 +1,124 @@
package dev.uatu.sample.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.uatu.sample.Navigator
import dev.uatu.sample.Repository
import dev.uatu.sample.Route
import dev.uatu.sample.TxnType
import dev.uatu.sample.parseCents
private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
@Composable
fun AddTransactionPage(accountId: String) {
val accounts by Repository.accounts.collectAsState()
val account = accounts.firstOrNull { it.id == accountId }
BackHandler { Navigator.back(Route.Ledger(accountId)) }
if (account == null) {
Screen(
header = {
Header(title = "Add transaction", left = { BackButton { Navigator.back(Route.Home) } })
},
) {
EmptyState(title = "Account not found", subtitle = "")
AppButton(
text = "Back to accounts",
onClick = { Navigator.replace(Route.Home) },
style = ButtonStyle.Secondary,
)
}
return
}
var type by remember { mutableStateOf(TxnType.credit) }
var amount by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) }
fun submit() {
val cents = parseCents(amount)
if (cents == null) {
err = "Enter a valid amount (e.g. 12.34)"; return
}
if (cents <= 0) {
err = "Amount must be greater than zero"; return
}
try {
Repository.createTransaction(accountId, type, cents, note)
Navigator.back(Route.Home)
} catch (e: IllegalArgumentException) {
err = e.message ?: "Could not save transaction"
}
}
Screen(
header = {
Header(
title = "Add transaction",
subtitle = account.name,
left = { BackButton { Navigator.back(Route.Ledger(accountId)) } },
)
},
footer = {
AppButton(
text = if (type == TxnType.credit) "Add credit" else "Add debit",
onClick = ::submit,
style = ButtonStyle.Primary,
enabled = amount.isNotBlank(),
)
},
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Segmented(
selected = if (type == TxnType.credit) 0 else 1,
labels = listOf("Credit", "Debit"),
onSelect = { type = if (it == 0) TxnType.credit else TxnType.debit },
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Amount")
TextInput(
value = amount,
onChange = {
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
amount = it; err = null
}
},
placeholder = "0.00",
invalid = err != null,
keyboardType = KeyboardType.Decimal,
textAlign = TextAlign.Center,
textStyle = Type.amountInput,
label = "Amount",
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Note (optional)")
TextInput(
value = note,
onChange = { note = it.take(80) },
placeholder = "What's this for?",
label = "Note",
)
}
ErrorText(err)
}
}
}
@@ -0,0 +1,6 @@
package dev.uatu.sample.ui
import androidx.compose.runtime.Composable
@Composable
expect fun BackHandler(onBack: () -> Unit)
@@ -0,0 +1,162 @@
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)
}
}
@@ -0,0 +1,139 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import 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
@Composable
fun HomePage(user: String, onLogout: () -> Unit) {
val t = LocalTokens.current
val accounts by Repository.accounts.collectAsState()
val txns by Repository.transactions.collectAsState()
val total = balanceOf(txns)
Screen(
header = {
Header(
title = "Accounts",
subtitle = user,
right = {
IconButton(onClick = onLogout, description = "Sign out", icon = Icons.Logout)
},
)
},
footer = {
Row(verticalAlignment = Alignment.Bottom) {
Column {
Text("TOTAL BALANCE", style = Type.label, color = t.textMuted)
Text(formatCents(total), style = Type.balance, color = t.text)
}
Box(Modifier.weight(1f))
Text(
"${accounts.size} account${if (accounts.size == 1) "" else "s"}",
style = Type.caption,
color = t.textFaint,
)
}
AppButton(
text = "+ Add account",
onClick = { Navigator.push(Route.AddAccount) },
style = ButtonStyle.Primary,
)
},
) {
if (accounts.isEmpty()) {
EmptyState(
title = "No accounts yet",
subtitle = "Create your first account to start tracking transactions.",
icon = Icons.Bank,
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
accounts.forEach { a ->
val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
val count = txns.count { it.accountId == a.id }
AccountCard(
name = a.name,
initials = initialsOf(a.name),
count = count,
balance = bal,
onClick = { Navigator.push(Route.Ledger(a.id)) },
)
}
}
}
}
}
@Composable
private fun AccountCard(
name: String,
initials: String,
count: Int,
balance: Long,
onClick: () -> Unit,
) {
val t = LocalTokens.current
val txnLabel = if (count == 1) "1 transaction" else "$count transactions"
val a11y = "$name account, balance ${formatCents(balance)}, $txnLabel"
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RadiusLg))
.background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
.semantics(mergeDescendants = true) { contentDescription = a11y }
.clickable(role = Role.Button, onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(t.surface3),
contentAlignment = Alignment.Center,
) {
Text(initials, style = Type.bodyStrong, color = t.text)
}
Column(Modifier.weight(1f)) {
Text(
name,
style = Type.bodyStrong,
color = t.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(txnLabel, style = Type.caption, color = t.textMuted)
}
Text(formatCents(balance), style = Type.bodyStrong, color = t.text)
}
}
@@ -0,0 +1,44 @@
package dev.uatu.sample.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
object Icons {
val Back: ImageVector = strokeIcon("M15 18 L9 12 L15 6")
val Logout: ImageVector = strokeIcon(
"M9 21 H5 A2 2 0 0 1 3 19 V5 A2 2 0 0 1 5 3 H9 " +
"M16 17 L21 12 L16 7 " +
"M21 12 H9",
)
val Bank: ImageVector = strokeIcon(
"M3 6 H21 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 Z " +
"M3 10 H21",
)
val Lines: ImageVector = strokeIcon(
"M3 6 H21 " +
"M3 12 H21 " +
"M3 18 H15",
)
}
private fun strokeIcon(path: String): ImageVector =
ImageVector.Builder(
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).addPath(
pathData = addPathNodes(path),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
).build()
@@ -0,0 +1,156 @@
package dev.uatu.sample.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import 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
@Composable
fun LedgerPage(accountId: String) {
val t = LocalTokens.current
val accounts by Repository.accounts.collectAsState()
val allTxns by Repository.transactions.collectAsState()
BackHandler { Navigator.back(Route.Home) }
val account = accounts.firstOrNull { it.id == accountId }
if (account == null) {
Screen(
header = {
Header(title = "Account", left = { BackButton { Navigator.back(Route.Home) } })
},
) {
EmptyState(title = "Account not found", subtitle = "It may have been deleted.")
AppButton(
text = "Back to accounts",
onClick = { Navigator.replace(Route.Home) },
style = ButtonStyle.Secondary,
)
}
return
}
val txns = allTxns
.filter { it.accountId == accountId }
.sortedByDescending { it.createdAt }
val balance = balanceOf(txns)
Screen(
header = {
Header(
title = account.name,
subtitle = "Ledger",
left = { BackButton { Navigator.back(Route.Home) } },
)
},
footer = {
AppButton(
text = "+ Add transaction",
onClick = { Navigator.push(Route.AddTransaction(accountId)) },
style = ButtonStyle.Primary,
)
},
) {
Card {
Text("BALANCE", style = Type.label, color = t.textMuted)
Text(formatCents(balance), style = Type.balance, color = t.text)
}
Text(
"ACTIVITY",
style = Type.label,
color = t.textFaint,
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
)
if (txns.isEmpty()) {
EmptyState(
title = "No transactions yet",
subtitle = "Add your first credit or debit to see it here.",
icon = Icons.Lines,
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(RadiusLg))
.background(t.surface)
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
.padding(horizontal = 16.dp),
) {
txns.forEachIndexed { i, txn ->
TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
if (i != txns.lastIndex) {
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
}
}
}
}
}
}
@Composable
private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) {
val t = LocalTokens.current
val signed = if (type == TxnType.credit) amount else -amount
val label = "${if (type == TxnType.credit) "Credit" else "Debit"} ${formatCents(signed, signed = true)}" +
(if (note.isNotEmpty()) ", $note" else "") + ", $date"
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp)
.semantics(mergeDescendants = true) { contentDescription = label },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(t.surface2)
.border(1.dp, t.border, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Text(if (type == TxnType.credit) "+" else "-", style = Type.bodyStrong, color = t.text)
}
Column(Modifier.weight(1f)) {
Text(
if (note.isNotEmpty()) note else if (type == TxnType.credit) "Credit" else "Debit",
style = Type.body,
color = t.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(date, style = Type.caption, color = t.textFaint)
}
Text(
formatCents(signed, signed = true),
style = Type.bodyStrong,
color = t.text,
)
}
}
@@ -0,0 +1,85 @@
package dev.uatu.sample.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import dev.uatu.sample.DEMO_EMAIL
import dev.uatu.sample.DEMO_PASSWORD
import dev.uatu.sample.Repository
import dev.uatu.sample.checkCredentials
@Composable
fun LoginPage(onLoggedIn: (String) -> Unit) {
val t = LocalTokens.current
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var err by remember { mutableStateOf<String?>(null) }
fun submit() {
if (email.isBlank() || password.isEmpty()) {
err = "Enter email and password"; return
}
if (!checkCredentials(email, password)) {
err = "Invalid email or password"; return
}
val user = email.trim().lowercase()
Repository.setSession(user)
onLoggedIn(user)
}
Screen {
Spacer(Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Email")
TextInput(
value = email,
onChange = { email = it; err = null },
placeholder = DEMO_EMAIL,
invalid = err != null,
keyboardType = KeyboardType.Email,
label = "Email",
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
FieldLabel("Password")
TextInput(
value = password,
onChange = { password = it; err = null },
placeholder = "••••••••",
password = true,
invalid = err != null,
keyboardType = KeyboardType.Password,
label = "Password",
)
}
ErrorText(err)
AppButton(
text = "Sign in",
onClick = ::submit,
style = ButtonStyle.Primary,
)
Spacer(Modifier.height(4.dp))
Card(dashed = true) {
Text("Demo credentials", style = Type.caption, color = t.textMuted)
Text("email: $DEMO_EMAIL", style = Type.body, color = t.text)
Text("password: $DEMO_PASSWORD", style = Type.body, color = t.text)
}
}
}
}
@@ -0,0 +1,63 @@
package dev.uatu.sample.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.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),
val surface: Color = Color(0xFFFAFAFA),
val surface2: Color = Color(0xFFF4F4F4),
val surface3: Color = Color(0xFFEBEBEB),
val border: Color = Color(0xFFE7E7E7),
val borderStrong: Color = Color(0xFFD6D6D6),
val text: Color = Color(0xFF000000),
val textMuted: Color = Color(0xFF6B6B6B),
val textFaint: Color = Color(0xFF9A9A9A),
val onAccent: Color = Color(0xFFFFFFFF),
)
val LocalTokens = staticCompositionLocalOf { Tokens() }
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")
val dark = isSystemInDarkTheme()
val scheme = lightColorScheme(
background = Color(0xFFFFFFFF),
surface = Color(0xFFFAFAFA),
primary = Color(0xFF000000),
onPrimary = Color(0xFFFFFFFF),
)
MaterialTheme(colorScheme = scheme) { content() }
}
val FrameWidth = 420.dp
val FrameHeight = 900.dp
val ScreenPad = 20.dp
val RadiusSm = 8.dp
val RadiusMd = 12.dp
val RadiusLg = 16.dp
val RadiusPill = 999.dp
@@ -0,0 +1,185 @@
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
enum class ButtonStyle { Primary, Secondary, Ghost }
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
style: ButtonStyle = ButtonStyle.Secondary,
enabled: Boolean = true,
) {
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))
.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,
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 }
.semantics {
if (label != null) contentDescription = label
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) {
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
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(RadiusSm))
.background(if (active) t.surface3 else t.surface)
.semantics { this.selected = active }
.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,47 @@
CREATE TABLE Accounts (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
createdAt INTEGER NOT NULL
);
CREATE TABLE Txns (
id TEXT NOT NULL PRIMARY KEY,
accountId TEXT NOT NULL,
type TEXT NOT NULL,
amount INTEGER NOT NULL,
note TEXT NOT NULL,
createdAt INTEGER NOT NULL
);
CREATE INDEX txn_account ON Txns(accountId);
CREATE INDEX txn_created ON Txns(createdAt);
CREATE TABLE AppSession (
key INTEGER NOT NULL PRIMARY KEY CHECK (key = 1),
user TEXT NOT NULL,
loggedInAt INTEGER NOT NULL
);
selectAllAccounts:
SELECT * FROM Accounts ORDER BY createdAt ASC;
accountExistsByName:
SELECT EXISTS(SELECT 1 FROM Accounts WHERE name = :name COLLATE NOCASE);
insertAccount:
INSERT INTO Accounts(id, name, createdAt) VALUES (?, ?, ?);
selectAllTxns:
SELECT * FROM Txns;
insertTxn:
INSERT INTO Txns(id, accountId, type, amount, note, createdAt) VALUES (?, ?, ?, ?, ?, ?);
selectSession:
SELECT user, loggedInAt FROM AppSession WHERE key = 1;
upsertSession:
INSERT OR REPLACE INTO AppSession(key, user, loggedInAt) VALUES (1, ?, ?);
clearSession:
DELETE FROM AppSession WHERE key = 1;
@@ -0,0 +1,10 @@
package dev.uatu.sample
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import dev.uatu.sample.db.LedgerDatabase
actual class DriverFactory {
actual fun create(): SqlDriver =
NativeSqliteDriver(LedgerDatabase.Schema, "ledger.db")
}
@@ -0,0 +1,16 @@
package dev.uatu.sample
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSLocale
import platform.Foundation.dateWithTimeIntervalSince1970
private val DATE_FMT = NSDateFormatter().apply {
locale = NSLocale("en_US_POSIX")
dateFormat = "MMM d, h:mm a"
}
private fun date(epochMillis: Long): NSDate =
NSDate.dateWithTimeIntervalSince1970(epochMillis / 1000.0)
actual fun formatDate(epochMillis: Long): String = DATE_FMT.stringFromDate(date(epochMillis))
@@ -0,0 +1,3 @@
package dev.uatu.sample
actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory())
@@ -0,0 +1,39 @@
package dev.uatu.sample
import androidx.compose.ui.window.ComposeUIViewController
import dev.uatu.sample.ui.IosBackGesture
import kotlin.native.ObjCName
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCAction
import platform.Foundation.NSSelectorFromString
import platform.UIKit.UIGestureRecognizerStateEnded
import platform.UIKit.UIRectEdgeLeft
import platform.UIKit.UIScreenEdgePanGestureRecognizer
import platform.UIKit.UIViewController
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
class BackGestureTarget : NSObject() {
@ObjCAction
fun handleGesture(gesture: UIScreenEdgePanGestureRecognizer) {
if (gesture.state == UIGestureRecognizerStateEnded) {
IosBackGesture.dispatch()
}
}
}
private val backGestureTarget = BackGestureTarget()
@OptIn(ExperimentalForeignApi::class)
fun MainViewController(): UIViewController {
Repository.init()
val vc = ComposeUIViewController { App() }
val gesture = UIScreenEdgePanGestureRecognizer(
target = backGestureTarget,
action = NSSelectorFromString("handleGesture:"),
)
gesture.edges = UIRectEdgeLeft
vc.view.addGestureRecognizer(gesture)
return vc
}
@@ -0,0 +1,10 @@
package dev.uatu.sample
import platform.Foundation.NSDate
import platform.Foundation.NSUUID
import platform.Foundation.timeIntervalSince1970
actual object Platform {
actual fun now(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
actual fun makeId(): String = NSUUID().UUIDString
}
@@ -0,0 +1,15 @@
package dev.uatu.sample.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.getValue
@Composable
actual fun BackHandler(onBack: () -> Unit) {
val current by rememberUpdatedState(onBack)
DisposableEffect(Unit) {
val id = IosBackGesture.register { current() }
onDispose { IosBackGesture.unregister(id) }
}
}
@@ -0,0 +1,22 @@
package dev.uatu.sample.ui
object IosBackGesture {
private var nextId = 0
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
fun register(onBack: () -> Unit): Int {
val id = nextId++
callbacks.add(id to onBack)
return id
}
fun unregister(id: Int) {
callbacks.removeAll { it.first == id }
}
fun dispatch(): Boolean {
val top = callbacks.lastOrNull() ?: return false
top.second()
return true
}
}
@@ -0,0 +1,7 @@
package dev.uatu.sample
import app.cash.sqldelight.db.SqlDriver
expect class DriverFactory {
fun create(): SqlDriver
}
@@ -0,0 +1,69 @@
package dev.uatu.sample
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class SqlLedgerStore(factory: DriverFactory) : LedgerStore {
private val db = LedgerDatabase(factory.create())
private val q = db.ledgerQueries
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override val accounts: StateFlow<List<Account>> =
q.selectAllAccounts()
.asFlow()
.mapToList(Dispatchers.Default)
.map { rows -> rows.map { Account(it.id, it.name, it.createdAt) } }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val transactions: StateFlow<List<Transaction>> =
q.selectAllTxns()
.asFlow()
.mapToList(Dispatchers.Default)
.map { rows -> rows.map { it.toDomain() } }
.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val session: StateFlow<Session?> =
q.selectSession()
.asFlow()
.mapToOneOrNull(Dispatchers.Default)
.map { it?.let { row -> Session(row.user, row.loggedInAt) } }
.stateIn(scope, SharingStarted.Eagerly, null)
override fun accountExistsByName(name: String): Boolean =
q.accountExistsByName(name).executeAsOne()
override fun insertAccount(id: String, name: String, createdAt: Long) {
q.insertAccount(id, name, createdAt)
}
override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
q.insertTxn(id, accountId, type.name, amount, note, createdAt)
}
override fun upsertSession(user: String, loggedInAt: Long) {
q.upsertSession(user, loggedInAt)
}
override fun clearSession() {
q.clearSession()
}
}
private fun dev.uatu.sample.db.Txns.toDomain(): Transaction =
Transaction(
id = id,
accountId = accountId,
type = TxnType.valueOf(type),
amount = amount,
note = note,
createdAt = createdAt,
)
@@ -0,0 +1,27 @@
package dev.uatu.sample
private val MONTHS = arrayOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
actual fun formatDate(epochMillis: Long): String {
val month = jsDateMonth(epochMillis.toDouble())
val day = jsDateDay(epochMillis.toDouble())
val hour24 = jsDateHours(epochMillis.toDouble())
val minute = jsDateMinutes(epochMillis.toDouble())
val am = hour24 < 12
val hour12 = when {
hour24 == 0 -> 12
hour24 > 12 -> hour24 - 12
else -> hour24
}
val minuteStr = minute.toString().padStart(2, '0')
val suffix = if (am) "AM" else "PM"
return "${MONTHS[month]} $day, $hour12:$minuteStr $suffix"
}
private fun jsDateMonth(ms: Double): Int = js("new Date(ms).getMonth()")
private fun jsDateDay(ms: Double): Int = js("new Date(ms).getDate()")
private fun jsDateHours(ms: Double): Int = js("new Date(ms).getHours()")
private fun jsDateMinutes(ms: Double): Int = js("new Date(ms).getMinutes()")
@@ -0,0 +1,22 @@
package dev.uatu.sample
import kotlin.random.Random
actual object Platform {
actual fun now(): Long = jsNow().toLong()
actual fun makeId(): String = cryptoRandomUuid() ?: fallbackUuid()
}
private fun jsNow(): Double = js("Date.now()")
private fun cryptoRandomUuid(): String? = js(
"(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') ? crypto.randomUUID() : null"
)
private fun fallbackUuid(): String {
val bytes = ByteArray(16) { Random.nextInt(0, 256).toByte() }
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
val hex = bytes.joinToString("") { ((it.toInt() and 0xff) or 0x100).toString(16).substring(1) }
return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}"
}
@@ -0,0 +1,65 @@
package dev.uatu.sample
internal data class Snapshot(
val accounts: List<Account>,
val transactions: List<Transaction>,
val session: Session?,
) {
fun encode(): String = buildString {
for (a in accounts) {
append("A\t").append(esc(a.id)).append('\t').append(esc(a.name)).append('\t').append(a.createdAt).append('\n')
}
for (t in transactions) {
append("T\t").append(esc(t.id)).append('\t').append(esc(t.accountId)).append('\t')
.append(t.type.name).append('\t').append(t.amount).append('\t')
.append(esc(t.note)).append('\t').append(t.createdAt).append('\n')
}
session?.let { append("S\t").append(esc(it.user)).append('\t').append(it.loggedInAt).append('\n') }
}
companion object {
fun decode(raw: String): Snapshot? {
val accounts = mutableListOf<Account>()
val transactions = mutableListOf<Transaction>()
var session: Session? = null
for (line in raw.split('\n')) {
if (line.isEmpty()) continue
val parts = line.split('\t')
when (parts[0]) {
"A" -> if (parts.size == 4) accounts += Account(unesc(parts[1]), unesc(parts[2]), parts[3].toLong())
"T" -> if (parts.size == 7) transactions += Transaction(
id = unesc(parts[1]),
accountId = unesc(parts[2]),
type = TxnType.valueOf(parts[3]),
amount = parts[4].toLong(),
note = unesc(parts[5]),
createdAt = parts[6].toLong(),
)
"S" -> if (parts.size == 3) session = Session(unesc(parts[1]), parts[2].toLong())
}
}
return Snapshot(accounts, transactions, session)
}
private fun esc(s: String) = s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n")
private fun unesc(s: String): String {
val out = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '\\' && i + 1 < s.length) {
when (s[i + 1]) {
't' -> out.append('\t')
'n' -> out.append('\n')
'\\' -> out.append('\\')
else -> out.append(s[i + 1])
}
i += 2
} else {
out.append(c); i++
}
}
return out.toString()
}
}
}
@@ -0,0 +1,60 @@
package dev.uatu.sample
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.browser.localStorage
private const val STORAGE_KEY = "uatu.ledger.v1"
class WebLedgerStore : LedgerStore {
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
private val _transactions = MutableStateFlow<List<Transaction>>(emptyList())
private val _session = MutableStateFlow<Session?>(null)
override val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
override val transactions: StateFlow<List<Transaction>> = _transactions.asStateFlow()
override val session: StateFlow<Session?> = _session.asStateFlow()
init {
load()
}
override fun accountExistsByName(name: String): Boolean =
_accounts.value.any { it.name == name }
override fun insertAccount(id: String, name: String, createdAt: Long) {
_accounts.value = _accounts.value + Account(id, name, createdAt)
save()
}
override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
_transactions.value = _transactions.value + Transaction(id, accountId, type, amount, note, createdAt)
save()
}
override fun upsertSession(user: String, loggedInAt: Long) {
_session.value = Session(user, loggedInAt)
save()
}
override fun clearSession() {
_session.value = null
save()
}
private fun load() {
val raw = localStorage.getItem(STORAGE_KEY) ?: return
val parsed = Snapshot.decode(raw) ?: return
_accounts.value = parsed.accounts
_transactions.value = parsed.transactions
_session.value = parsed.session
}
private fun save() {
val snap = Snapshot(_accounts.value, _transactions.value, _session.value)
localStorage.setItem(STORAGE_KEY, snap.encode())
}
}
actual fun createLedgerStore(): LedgerStore = WebLedgerStore()
@@ -0,0 +1,21 @@
package dev.uatu.sample
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.uatu.sample.ui.WebBackGesture
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.events.Event
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
Repository.init()
window.history.pushState(null, "", window.location.href)
window.addEventListener("popstate", { _: Event ->
if (WebBackGesture.dispatch()) {
window.history.pushState(null, "", window.location.href)
}
})
val target = document.getElementById("app") ?: document.body!!
ComposeViewport(target) { App() }
}
@@ -0,0 +1,36 @@
package dev.uatu.sample.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
@Composable
actual fun BackHandler(onBack: () -> Unit) {
val current by rememberUpdatedState(onBack)
DisposableEffect(Unit) {
val id = WebBackGesture.register { current() }
onDispose { WebBackGesture.unregister(id) }
}
}
object WebBackGesture {
private var nextId = 0
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
fun register(onBack: () -> Unit): Int {
val id = nextId++
callbacks.add(id to onBack)
return id
}
fun unregister(id: Int) {
callbacks.removeAll { it.first == id }
}
fun dispatch(): Boolean {
val top = callbacks.lastOrNull() ?: return false
top.second()
return true
}
}
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Uatu Sample</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; }
body { display: flex; justify-content: center; }
#app { width: 100%; max-width: 480px; height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script src="composeApp.js"></script>
</body>
</html>
@@ -1,10 +1,10 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
# Pin the uatu SDK version consumed by this sample. Override with
# `-Puatu.version=<version>` or edit build.gradle.kts once a stable release is
# available on Maven Central.
# `-Puatu.version=<version>`.
uatu.version=0.0.0-dev
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Uatu Sample</string>
<key>CFBundleDisplayName</key>
<string>Uatu Sample</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
@@ -0,0 +1,20 @@
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ComposeView()
.ignoresSafeArea(.all)
.ignoresSafeArea(.keyboard)
}
}
}
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
+43
View File
@@ -0,0 +1,43 @@
name: iosApp
options:
bundleIdPrefix: dev.uatu.sample
deploymentTarget:
iOS: "15.0"
xcodeVersion: "16.0"
settings:
base:
ENABLE_USER_SCRIPT_SANDBOXING: NO
SWIFT_VERSION: "5.0"
ONLY_ACTIVE_ARCH: YES
EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64
targets:
iosApp:
type: application
platform: iOS
sources:
- iosApp
dependencies:
- framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework
embed: false
- sdk: libsqlite3.tbd
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.uatu.sample
PRODUCT_NAME: iosApp
INFOPLIST_FILE: iosApp/Info.plist
FRAMEWORK_SEARCH_PATHS:
- $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
OTHER_LDFLAGS:
- -ObjC
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
preBuildScripts:
- name: Build Kotlin framework
script: |
cd "$SRCROOT/.."
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
outputFiles:
- $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp
basedOnDependencyAnalysis: false
+30 -5
View File
@@ -6,7 +6,7 @@ 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")
android := justfile_directory() / "android"
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
default:
@just --list
@@ -40,21 +40,46 @@ install:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
cd {{android}} && ./gradlew installDebug
./gradlew :composeApp:installDebug
# Remove the sample APK from the connected device.
uninstall:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
cd {{android}} && ./gradlew uninstallDebug
./gradlew :composeApp:uninstallDebug
# Remove the gradle build directory.
# Remove gradle + iOS build directories.
clean:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
cd {{android}} && ./gradlew clean
./gradlew clean
rm -rf iosApp/build
# Regenerate iosApp.xcodeproj from project.yml.
ios-gen:
#!/usr/bin/env bash
set -euo pipefail
cd iosApp && xcodegen generate
# Build + install + launch on a booted iOS simulator (boots IOS_DEVICE if none).
ios:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
if ! xcrun simctl list devices booted | grep -q Booted; then
xcrun simctl boot "{{ios_device}}"
open -a Simulator
sleep 3
fi
just ios-gen
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
-destination 'platform=iOS Simulator,name={{ios_device}}' \
-derivedDataPath iosApp/build \
build | tail -5
xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app
xcrun simctl launch booted dev.uatu.sample
# Run 'uatu test' against the sample app. Uses a connected device if one is
# online, otherwise boots AVD=<name> if provided.
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/[email protected]":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
@@ -7,7 +7,7 @@ pluginManagement {
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
@@ -16,3 +16,5 @@ dependencyResolutionManagement {
}
rootProject.name = "uatu-sample"
include(":composeApp")