diff --git a/examples/sample-app/.gitignore b/examples/sample-app/.gitignore index f491bf0..9d44317 100644 --- a/examples/sample-app/.gitignore +++ b/examples/sample-app/.gitignore @@ -1 +1,11 @@ -/runs \ No newline at end of file +.gradle/ +build/ +.kotlin/ +local.properties +iosApp/build/ +iosApp/iosApp.xcodeproj/ +iosApp/iosApp.xcodeproj/** +.DS_Store +runs/ +.playwright-mcp/ +web-*.png diff --git a/examples/sample-app/README.md b/examples/sample-app/README.md index 9f58a77..789445f 100644 --- a/examples/sample-app/README.md +++ b/examples/sample-app/README.md @@ -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: demo@ledger.app +password: ledger123 +``` + +## Run a uatu test (Android) ```sh just test @@ -39,12 +72,26 @@ DURATION=5m Traces land in `./runs//`. -## 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 diff --git a/examples/sample-app/android/build.gradle.kts b/examples/sample-app/android/build.gradle.kts deleted file mode 100644 index f7c9ad2..0000000 --- a/examples/sample-app/android/build.gradle.kts +++ /dev/null @@ -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") -} diff --git a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt deleted file mode 100644 index 414cddb..0000000 --- a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt +++ /dev/null @@ -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) - } -} diff --git a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt deleted file mode 100644 index 784df4d..0000000 --- a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt +++ /dev/null @@ -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 } - } -} diff --git a/examples/sample-app/build.gradle.kts b/examples/sample-app/build.gradle.kts new file mode 100644 index 0000000..d3137ca --- /dev/null +++ b/examples/sample-app/build.gradle.kts @@ -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 +} diff --git a/examples/sample-app/composeApp/build.gradle.kts b/examples/sample-app/composeApp/build.gradle.kts new file mode 100644 index 0000000..678b0fa --- /dev/null +++ b/examples/sample-app/composeApp/build.gradle.kts @@ -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") + } + } +} diff --git a/examples/sample-app/android/src/main/AndroidManifest.xml b/examples/sample-app/composeApp/src/androidMain/AndroidManifest.xml similarity index 67% rename from examples/sample-app/android/src/main/AndroidManifest.xml rename to examples/sample-app/composeApp/src/androidMain/AndroidManifest.xml index a4328c4..bbcfc8e 100644 --- a/examples/sample-app/android/src/main/AndroidManifest.xml +++ b/examples/sample-app/composeApp/src/androidMain/AndroidManifest.xml @@ -3,10 +3,12 @@ + android:allowBackup="false" + android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:exported="true" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale"> diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/DriverFactory.android.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/DriverFactory.android.kt new file mode 100644 index 0000000..6f2af53 --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/DriverFactory.android.kt @@ -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") +} diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Format.android.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Format.android.kt new file mode 100644 index 0000000..312ba0e --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Format.android.kt @@ -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)) diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/LedgerStore.android.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/LedgerStore.android.kt new file mode 100644 index 0000000..f3a63f0 --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/LedgerStore.android.kt @@ -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)) diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/MainActivity.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/MainActivity.kt new file mode 100644 index 0000000..1042d2b --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/MainActivity.kt @@ -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() } + } +} diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Platform.android.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Platform.android.kt new file mode 100644 index 0000000..48c8584 --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/Platform.android.kt @@ -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() +} diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt new file mode 100644 index 0000000..b0db6ec --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt @@ -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" + } + } + } +} diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/ui/BackHandler.android.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/ui/BackHandler.android.kt new file mode 100644 index 0000000..bdb78d7 --- /dev/null +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/ui/BackHandler.android.kt @@ -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) diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/App.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/App.kt new file mode 100644 index 0000000..05caa86 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/App.kt @@ -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) + } + } + } + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Auth.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Auth.kt new file mode 100644 index 0000000..c449fc0 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Auth.kt @@ -0,0 +1,8 @@ +package dev.uatu.sample + +const val DEMO_EMAIL = "demo@ledger.app" +const val DEMO_PASSWORD = "ledger123" + +fun checkCredentials(email: String, password: String): Boolean { + return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Format.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Format.kt new file mode 100644 index 0000000..9e691b2 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Format.kt @@ -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): 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 diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/LedgerStore.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/LedgerStore.kt new file mode 100644 index 0000000..75fe0c0 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/LedgerStore.kt @@ -0,0 +1,17 @@ +package dev.uatu.sample + +import kotlinx.coroutines.flow.StateFlow + +interface LedgerStore { + val accounts: StateFlow> + val transactions: StateFlow> + val session: StateFlow + + 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 diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Models.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Models.kt new file mode 100644 index 0000000..2661f55 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Models.kt @@ -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, +) diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Navigation.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Navigation.kt new file mode 100644 index 0000000..12db5d3 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Navigation.kt @@ -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().apply { addLast(Route.Home) } + private val _current = MutableStateFlow(Route.Home) + val current: StateFlow = _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) + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Platform.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Platform.kt new file mode 100644 index 0000000..608e13a --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Platform.kt @@ -0,0 +1,6 @@ +package dev.uatu.sample + +expect object Platform { + fun now(): Long + fun makeId(): String +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Repository.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Repository.kt new file mode 100644 index 0000000..c79b8fc --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/Repository.kt @@ -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> get() = store.accounts + val transactions: StateFlow> get() = store.transactions + val session: StateFlow 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() +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt new file mode 100644 index 0000000..52b34d3 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt @@ -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(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, + ) + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt new file mode 100644 index 0000000..4d3b8b8 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt @@ -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(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) + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/BackHandler.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/BackHandler.kt new file mode 100644 index 0000000..830118e --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/BackHandler.kt @@ -0,0 +1,6 @@ +package dev.uatu.sample.ui + +import androidx.compose.runtime.Composable + +@Composable +expect fun BackHandler(onBack: () -> Unit) diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Components.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Components.kt new file mode 100644 index 0000000..28e6880 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Components.kt @@ -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) + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt new file mode 100644 index 0000000..df0fa2e --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt @@ -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) + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Icons.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Icons.kt new file mode 100644 index 0000000..de35f36 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Icons.kt @@ -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() diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt new file mode 100644 index 0000000..d72cd34 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt @@ -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, + ) + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt new file mode 100644 index 0000000..6aa469a --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt @@ -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(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) + } + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Theme.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Theme.kt new file mode 100644 index 0000000..3a12b22 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Theme.kt @@ -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 diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt new file mode 100644 index 0000000..ab3b28b --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt @@ -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, 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) + } + } + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/sqldelight/dev/uatu/sample/db/Ledger.sq b/examples/sample-app/composeApp/src/commonMain/sqldelight/dev/uatu/sample/db/Ledger.sq new file mode 100644 index 0000000..e49fe2f --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/sqldelight/dev/uatu/sample/db/Ledger.sq @@ -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; diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/DriverFactory.ios.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/DriverFactory.ios.kt new file mode 100644 index 0000000..1d366c1 --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/DriverFactory.ios.kt @@ -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") +} diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Format.ios.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Format.ios.kt new file mode 100644 index 0000000..4877dc2 --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Format.ios.kt @@ -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)) diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/LedgerStore.ios.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/LedgerStore.ios.kt new file mode 100644 index 0000000..514f59a --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/LedgerStore.ios.kt @@ -0,0 +1,3 @@ +package dev.uatu.sample + +actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory()) diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/MainViewController.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/MainViewController.kt new file mode 100644 index 0000000..dd2e8df --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/MainViewController.kt @@ -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 +} diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Platform.ios.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Platform.ios.kt new file mode 100644 index 0000000..166f210 --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/Platform.ios.kt @@ -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 +} diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/BackHandler.ios.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/BackHandler.ios.kt new file mode 100644 index 0000000..4c02ba8 --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/BackHandler.ios.kt @@ -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) } + } +} diff --git a/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/IosBackGesture.kt b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/IosBackGesture.kt new file mode 100644 index 0000000..a04bd46 --- /dev/null +++ b/examples/sample-app/composeApp/src/iosMain/kotlin/dev/uatu/sample/ui/IosBackGesture.kt @@ -0,0 +1,22 @@ +package dev.uatu.sample.ui + +object IosBackGesture { + private var nextId = 0 + private val callbacks = mutableListOf 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 + } +} diff --git a/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/DriverFactory.kt b/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/DriverFactory.kt new file mode 100644 index 0000000..5c4854c --- /dev/null +++ b/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/DriverFactory.kt @@ -0,0 +1,7 @@ +package dev.uatu.sample + +import app.cash.sqldelight.db.SqlDriver + +expect class DriverFactory { + fun create(): SqlDriver +} diff --git a/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/SqlLedgerStore.kt b/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/SqlLedgerStore.kt new file mode 100644 index 0000000..6110535 --- /dev/null +++ b/examples/sample-app/composeApp/src/sqlMain/kotlin/dev/uatu/sample/SqlLedgerStore.kt @@ -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> = + 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> = + q.selectAllTxns() + .asFlow() + .mapToList(Dispatchers.Default) + .map { rows -> rows.map { it.toDomain() } } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override val session: StateFlow = + 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, + ) diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Format.wasmJs.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Format.wasmJs.kt new file mode 100644 index 0000000..84c2e1f --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Format.wasmJs.kt @@ -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()") diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Platform.wasmJs.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Platform.wasmJs.kt new file mode 100644 index 0000000..a569ac2 --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Platform.wasmJs.kt @@ -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)}" +} diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Snapshot.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Snapshot.kt new file mode 100644 index 0000000..2c1a33b --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/Snapshot.kt @@ -0,0 +1,65 @@ +package dev.uatu.sample + +internal data class Snapshot( + val accounts: List, + val transactions: List, + 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() + val transactions = mutableListOf() + 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() + } + } +} diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt new file mode 100644 index 0000000..4ec49c3 --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt @@ -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>(emptyList()) + private val _transactions = MutableStateFlow>(emptyList()) + private val _session = MutableStateFlow(null) + + override val accounts: StateFlow> = _accounts.asStateFlow() + override val transactions: StateFlow> = _transactions.asStateFlow() + override val session: StateFlow = _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() diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/main.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/main.kt new file mode 100644 index 0000000..cbd65fd --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/main.kt @@ -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() } +} diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/ui/BackHandler.wasmJs.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/ui/BackHandler.wasmJs.kt new file mode 100644 index 0000000..243fa31 --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/ui/BackHandler.wasmJs.kt @@ -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 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 + } +} diff --git a/examples/sample-app/composeApp/src/wasmJsMain/resources/index.html b/examples/sample-app/composeApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..7a40391 --- /dev/null +++ b/examples/sample-app/composeApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,17 @@ + + + + + + Uatu Sample + + + +
+ + + diff --git a/examples/sample-app/android/gradle.properties b/examples/sample-app/gradle.properties similarity index 54% rename from examples/sample-app/android/gradle.properties rename to examples/sample-app/gradle.properties index 29c722d..2c3e8ff 100644 --- a/examples/sample-app/android/gradle.properties +++ b/examples/sample-app/gradle.properties @@ -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=` or edit build.gradle.kts once a stable release is -# available on Maven Central. +# `-Puatu.version=`. uatu.version=0.0.0-dev diff --git a/examples/sample-app/android/gradle/wrapper/gradle-wrapper.jar b/examples/sample-app/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from examples/sample-app/android/gradle/wrapper/gradle-wrapper.jar rename to examples/sample-app/gradle/wrapper/gradle-wrapper.jar diff --git a/examples/sample-app/android/gradle/wrapper/gradle-wrapper.properties b/examples/sample-app/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from examples/sample-app/android/gradle/wrapper/gradle-wrapper.properties rename to examples/sample-app/gradle/wrapper/gradle-wrapper.properties diff --git a/examples/sample-app/android/gradlew b/examples/sample-app/gradlew similarity index 100% rename from examples/sample-app/android/gradlew rename to examples/sample-app/gradlew diff --git a/examples/sample-app/android/gradlew.bat b/examples/sample-app/gradlew.bat similarity index 100% rename from examples/sample-app/android/gradlew.bat rename to examples/sample-app/gradlew.bat diff --git a/examples/sample-app/iosApp/iosApp/Info.plist b/examples/sample-app/iosApp/iosApp/Info.plist new file mode 100644 index 0000000..89d60fd --- /dev/null +++ b/examples/sample-app/iosApp/iosApp/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Uatu Sample + CFBundleDisplayName + Uatu Sample + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + UIApplicationSupportsIndirectInputEvents + + CADisableMinimumFrameDurationOnPhone + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/examples/sample-app/iosApp/iosApp/iOSApp.swift b/examples/sample-app/iosApp/iosApp/iOSApp.swift new file mode 100644 index 0000000..09b4572 --- /dev/null +++ b/examples/sample-app/iosApp/iosApp/iOSApp.swift @@ -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) {} +} diff --git a/examples/sample-app/iosApp/project.yml b/examples/sample-app/iosApp/project.yml new file mode 100644 index 0000000..fb66b7e --- /dev/null +++ b/examples/sample-app/iosApp/project.yml @@ -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 diff --git a/examples/sample-app/justfile b/examples/sample-app/justfile index a561f27..720f203 100644 --- a/examples/sample-app/justfile +++ b/examples/sample-app/justfile @@ -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= if provided. diff --git a/examples/sample-app/kotlin-js-store/wasm/yarn.lock b/examples/sample-app/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..5f4567d --- /dev/null +++ b/examples/sample-app/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@js-joda/core@3.2.0": + 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== diff --git a/examples/sample-app/android/settings.gradle.kts b/examples/sample-app/settings.gradle.kts similarity index 77% rename from examples/sample-app/android/settings.gradle.kts rename to examples/sample-app/settings.gradle.kts index 7b8693f..a60d2ad 100644 --- a/examples/sample-app/android/settings.gradle.kts +++ b/examples/sample-app/settings.gradle.kts @@ -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")