diff --git a/examples/folio/README.md b/examples/folio/README.md index 968899c..f6f7305 100644 --- a/examples/folio/README.md +++ b/examples/folio/README.md @@ -2,14 +2,15 @@ A minimal Kotlin Multiplatform personal-ledger app: login with demo credentials, create accounts, add credits and debits. Shared UI across -Android, iOS, and Web via Compose Multiplatform. Doubles as the example +Android and iOS via Compose Multiplatform. Doubles as the example sanderling runs its property-based specs against. ## Stack - Kotlin Multiplatform + Compose Multiplatform (shared UI) -- kotlinx.serialization for file-backed persistence +- SQLDelight for the data layer (unified across platforms) - kotlinx.coroutines for state flows +- kotlinx.serialization for `@Serializable` route types ## Prerequisites @@ -35,8 +36,8 @@ 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. +builds the KMP framework (`Shared.framework` from `:app:shared`), links it +into the SwiftUI host, installs, and launches. ## Demo credentials @@ -69,9 +70,13 @@ Traces land in `./sanderling/runs//`. ## How it connects to sanderling -- UI elements expose state via content descriptions (`account:$id:$balance`, - `ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`, - `focused_input:$field`) +- Each screen sets a stable Compose `testTag` (`HomeScreen`, `AccountCard`, + `LedgerRow`, `TxnAmount`, ...). The Sanderling SDK resolves `testTag` to + `resource-id` on Android and `accessibilityIdentifier` on iOS. +- Identity for list items is the visible text content (account name; txn + note + amount). No synthetic IDs encoded in semantics. +- `contentDescription` is reserved for real accessibility labels, never as a + data carrier. - `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`, - asserts properties, and weights the actions the fuzzer picks from -- `just test` invokes `sanderling test` against the installed APK + asserts properties, and weights the actions the fuzzer picks from. +- `just test` invokes `sanderling test` against the installed APK. diff --git a/examples/folio/composeApp/build.gradle.kts b/examples/folio/composeApp/build.gradle.kts deleted file mode 100644 index 286730d..0000000 --- a/examples/folio/composeApp/build.gradle.kts +++ /dev/null @@ -1,129 +0,0 @@ -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 sanderlingVersion = findProperty("sanderling.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", "app.folio") - } - } - - @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") - } - - iosMain.dependencies { - implementation("app.cash.sqldelight:native-driver:$sqldelightVersion") - } - } -} - -android { - namespace = "app.folio" - compileSdk = 36 - - defaultConfig { - applicationId = "app.folio" - minSdk = 24 - targetSdk = 36 - versionCode = 1 - versionName = sanderlingVersion - } - - 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("app.folio.db") - } - } -} diff --git a/examples/folio/composeApp/src/androidMain/AndroidManifest.xml b/examples/folio/composeApp/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 748409f..0000000 --- a/examples/folio/composeApp/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt deleted file mode 100644 index f5bf413..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.folio - -import android.app.Application -import app.folio.data.AndroidLedgerContext -import app.folio.data.Repository - -class FolioApplication : Application() { - override fun onCreate() { - super.onCreate() - AndroidLedgerContext.context = applicationContext - Repository.init() - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt deleted file mode 100644 index e7b0fb6..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.folio - -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/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt deleted file mode 100644 index ff19f55..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.folio.data - -import android.content.Context -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.android.AndroidSqliteDriver -import app.folio.db.LedgerDatabase - -actual class DriverFactory(private val context: Context) { - actual fun create(): SqlDriver = - AndroidSqliteDriver(LedgerDatabase.Schema, context, "ledger.db") -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt deleted file mode 100644 index 84c706f..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import android.content.Context - -object AndroidLedgerContext { - lateinit var context: Context -} - -actual fun createLedgerStore(): LedgerStore = - SqlLedgerStore(DriverFactory(AndroidLedgerContext.context)) diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt deleted file mode 100644 index ede6eed..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.folio.platform - -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/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt deleted file mode 100644 index 9adf3f6..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.folio.platform - -import java.util.UUID - -actual object Platform { - actual fun now(): Long = System.currentTimeMillis() - actual fun makeId(): String = UUID.randomUUID().toString() -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt deleted file mode 100644 index c2a3b2d..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.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/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt deleted file mode 100644 index 19b8270..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.folio - -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.size -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 androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.feature.account.AddAccountScreen -import app.folio.feature.auth.LoginScreen -import app.folio.feature.home.HomeScreen -import app.folio.feature.ledger.AddTransactionScreen -import app.folio.feature.ledger.LedgerScreen -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.FocusTracker -import app.folio.ui.theme.LedgerTheme -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Tokens - -@Composable -fun App() { - val session by Repository.session.collectAsState() - val route by Navigator.current.collectAsState() - val focusedInput by FocusTracker.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), - ) { - if (focusedInput != null) { - Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" }) - } - Column(Modifier.fillMaxSize()) { - when (val r = route) { - Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) }) - Route.Home -> HomeScreen( - user = session?.user ?: "", - onLogout = { - Repository.clearSession() - Navigator.replace(Route.Login) - }, - ) - Route.AddAccount -> AddAccountScreen() - is Route.Ledger -> LedgerScreen(accountId = r.accountId) - is Route.AddTransaction -> AddTransactionScreen(accountId = r.accountId) - } - } - } - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt deleted file mode 100644 index b0e464a..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.folio - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -// Assumes single-focus: only one input can be focused at a time, so -// enter() overwriting is safe. leave() is id-gated so a stale dispose -// from a previously-focused field cannot clobber the active focus. -object FocusTracker { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current.asStateFlow() - - fun enter(id: String) { - _current.value = id - } - - fun leave(id: String) { - if (_current.value == id) _current.value = null - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt deleted file mode 100644 index 5601094..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.data - -data class Account( - val id: String, - val name: String, - val createdAt: Long, -) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt deleted file mode 100644 index 29f4a0e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.folio.data - -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/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt deleted file mode 100644 index 38293c3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt +++ /dev/null @@ -1,49 +0,0 @@ -package app.folio.data - -import app.folio.platform.Platform -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/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt deleted file mode 100644 index 0e0929f..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.folio.data - -data class Session( - val user: String, - val loggedInAt: Long, -) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt deleted file mode 100644 index c9f1a54..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.folio.data - -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, -) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt deleted file mode 100644 index f4d017b..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt +++ /dev/null @@ -1,102 +0,0 @@ -package app.folio.feature.account - -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.DisposableEffect -import androidx.compose.runtime.collectAsState -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.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.ui.BackHandler -import app.folio.ui.component.AppButton -import app.folio.ui.component.BackButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.ErrorText -import app.folio.ui.component.FieldLabel -import app.folio.ui.component.Header -import app.folio.ui.component.Screen -import app.folio.ui.component.TextInput -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Type - -@Composable -fun AddAccountScreen() { - val t = LocalTokens.current - var name by remember { mutableStateOf("") } - val err by AddAccountUiState.addAccountError.collectAsState() - - BackHandler { Navigator.back(Route.Home) } - - DisposableEffect(Unit) { - onDispose { AddAccountUiState.addAccountError.value = "" } - } - - fun submit() { - val trimmed = name.trim() - if (trimmed.isEmpty()) { - AddAccountUiState.addAccountError.value = "Account name is required"; return - } - if (trimmed.length > 40) { - AddAccountUiState.addAccountError.value = "Name is too long (max 40 characters)"; return - } - try { - Repository.createAccount(trimmed) - AddAccountUiState.addAccountError.value = "" - Navigator.replace(Route.Home) - } catch (e: IllegalArgumentException) { - AddAccountUiState.addAccountError.value = e.message ?: "Could not create account" - } - } - - Screen( - modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" }, - 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(), - description = "add_account_submit", - ) - }, - ) { - 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; AddAccountUiState.addAccountError.value = "" }, - placeholder = "e.g. Checking", - invalid = err.isNotEmpty(), - label = "Account name", - description = "account_name_field", - ) - } - 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/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt deleted file mode 100644 index 3c78d4e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.account - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddAccountUiState { - val addAccountError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt deleted file mode 100644 index be1149c..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt +++ /dev/null @@ -1,111 +0,0 @@ -package app.folio.feature.auth - -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.DisposableEffect -import androidx.compose.runtime.collectAsState -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.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.ui.component.AppButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.Card -import app.folio.ui.component.ErrorText -import app.folio.ui.component.FieldLabel -import app.folio.ui.component.Screen -import app.folio.ui.component.TextInput -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Type - -const val DEMO_EMAIL = "demo@folio.app" -const val DEMO_PASSWORD = "ledger123" - -private fun checkCredentials(email: String, password: String): Boolean { - return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD -} - -@Composable -fun LoginScreen(onLoggedIn: (String) -> Unit) { - val t = LocalTokens.current - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val err by LoginUiState.loginError.collectAsState() - - DisposableEffect(Unit) { - onDispose { LoginUiState.loginError.value = "" } - } - - fun submit() { - if (email.isBlank() || password.isEmpty()) { - LoginUiState.loginError.value = "Enter email and password"; return - } - if (!checkCredentials(email, password)) { - LoginUiState.loginError.value = "Invalid email or password"; return - } - LoginUiState.loginError.value = "" - val user = email.trim().lowercase() - Repository.setSession(user) - onLoggedIn(user) - } - - Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) { - 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; LoginUiState.loginError.value = "" }, - placeholder = DEMO_EMAIL, - invalid = err.isNotEmpty(), - keyboardType = KeyboardType.Email, - label = "Email", - description = "login_email", - ) - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Password") - TextInput( - value = password, - onChange = { password = it; LoginUiState.loginError.value = "" }, - placeholder = "••••••••", - password = true, - invalid = err.isNotEmpty(), - keyboardType = KeyboardType.Password, - label = "Password", - description = "login_password", - ) - } - ErrorText(err) - AppButton( - text = "Sign in", - onClick = ::submit, - style = ButtonStyle.Primary, - description = "login_submit", - ) - 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/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt deleted file mode 100644 index e2c40e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.auth - -import kotlinx.coroutines.flow.MutableStateFlow - -object LoginUiState { - val loginError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt deleted file mode 100644 index ce18006..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt +++ /dev/null @@ -1,158 +0,0 @@ -package app.folio.feature.home - -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.platform.testTag -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 app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.initialsOf -import app.folio.platform.signedAmount -import app.folio.ui.component.AppButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.EmptyState -import app.folio.ui.component.Header -import app.folio.ui.component.IconButton -import app.folio.ui.component.Screen -import app.folio.ui.icon.Icons -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusLg -import app.folio.ui.theme.Type - -@Composable -fun HomeScreen(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( - modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" }, - header = { - Header( - title = "Accounts", - subtitle = user, - right = { - IconButton(onClick = onLogout, description = "logout_button", 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, - description = "add_account_button", - ) - }, - ) { - 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( - id = a.id, - name = a.name, - initials = initialsOf(a.name), - count = count, - balance = bal, - onClick = { Navigator.push(Route.Ledger(a.id)) }, - ) - } - } - } - } -} - -@Composable -private fun AccountCard( - id: String, - name: String, - initials: String, - count: Int, - balance: Long, - onClick: () -> Unit, -) { - val t = LocalTokens.current - val txnLabel = if (count == 1) "1 transaction" else "$count transactions" - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(RadiusLg)) - .background(t.surface) - .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) - .semantics { contentDescription = "account_card:$id" } - .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, - modifier = Modifier.semantics { contentDescription = "account_balance" }, - ) - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt deleted file mode 100644 index 714abfb..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt +++ /dev/null @@ -1,162 +0,0 @@ -package app.folio.feature.ledger - -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.DisposableEffect -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.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.data.TxnType -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.parseCents -import app.folio.ui.BackHandler -import app.folio.ui.component.AppButton -import app.folio.ui.component.BackButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.EmptyState -import app.folio.ui.component.ErrorText -import app.folio.ui.component.FieldLabel -import app.folio.ui.component.Header -import app.folio.ui.component.Screen -import app.folio.ui.component.Segmented -import app.folio.ui.component.TextInput -import app.folio.ui.theme.Type - -private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") - -@Composable -fun AddTransactionScreen(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("") } - val err by AddTransactionUiState.txnError.collectAsState() - - DisposableEffect(type) { - AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit" - onDispose { AddTransactionUiState.txnFormType.value = null } - } - - DisposableEffect(Unit) { - onDispose { AddTransactionUiState.txnError.value = "" } - } - - fun submit() { - val cents = parseCents(amount) - if (cents == null) { - AddTransactionUiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return - } - if (cents <= 0) { - AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return - } - try { - Repository.createTransaction(accountId, type, cents, note) - AddTransactionUiState.txnError.value = "" - Navigator.back(Route.Home) - } catch (e: IllegalArgumentException) { - AddTransactionUiState.txnError.value = e.message ?: "Could not save transaction" - } - } - - Screen( - modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" }, - 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(), - description = "txn_submit", - ) - }, - ) { - 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 = { - AddTransactionUiState.txnError.value = "" - type = if (it == 0) TxnType.credit else TxnType.debit - }, - descriptions = listOf("txn_credit", "txn_debit"), - ) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Amount") - TextInput( - value = amount, - onChange = { - AddTransactionUiState.txnError.value = "" - if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { - amount = it - } - }, - placeholder = "0.00", - invalid = err.isNotEmpty(), - keyboardType = KeyboardType.Decimal, - textAlign = TextAlign.Center, - textStyle = Type.amountInput, - label = "Amount", - description = "txn_amount", - ) - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Note (optional)") - TextInput( - value = note, - onChange = { - AddTransactionUiState.txnError.value = "" - note = it.take(80) - }, - placeholder = "What's this for?", - label = "Note", - description = "txn_note", - ) - } - ErrorText(err) - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt deleted file mode 100644 index 1c6289f..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.folio.feature.ledger - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddTransactionUiState { - val txnError = MutableStateFlow("") - val txnFormType = MutableStateFlow(null) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt deleted file mode 100644 index f7f60df..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt +++ /dev/null @@ -1,175 +0,0 @@ -package app.folio.feature.ledger - -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.platform.testTag -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 app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.data.TxnType -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.formatDate -import app.folio.platform.signedAmount -import app.folio.ui.BackHandler -import app.folio.ui.component.AppButton -import app.folio.ui.component.BackButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.Card -import app.folio.ui.component.EmptyState -import app.folio.ui.component.Header -import app.folio.ui.component.Screen -import app.folio.ui.icon.Icons -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusLg -import app.folio.ui.theme.Type - -@Composable -fun LedgerScreen(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( - modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" }, - 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, - description = "add_txn_button", - ) - }, - ) { - Card { - Text("BALANCE", style = Type.label, color = t.textMuted) - Text( - formatCents(balance), - style = Type.balance, - color = t.text, - modifier = Modifier.semantics { contentDescription = "ledger_balance_display" }, - ) - } - 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.id, 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(id: String, type: TxnType, amount: Long, note: String, date: String) { - val t = LocalTokens.current - val signed = if (type == TxnType.credit) amount else -amount - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 14.dp) - .semantics { contentDescription = "ledger_row:$id" }, - 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, - modifier = Modifier.semantics { contentDescription = "txn_amount" }, - ) - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt deleted file mode 100644 index 841dc00..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.folio.navigation - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -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/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt deleted file mode 100644 index cb8a6e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.folio.navigation - -sealed interface Route { - data object Login : Route - data object Home : Route - data object AddAccount : Route - data class Ledger(val accountId: String) : Route - data class AddTransaction(val accountId: String) : Route -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt deleted file mode 100644 index efbb4d1..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt +++ /dev/null @@ -1,73 +0,0 @@ -package app.folio.platform - -import app.folio.data.Transaction -import app.folio.data.TxnType -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/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt deleted file mode 100644 index dc4e3ce..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.folio.platform - -expect object Platform { - fun now(): Long - fun makeId(): String -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt deleted file mode 100644 index 47044cf..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt +++ /dev/null @@ -1,6 +0,0 @@ -package app.folio.ui - -import androidx.compose.runtime.Composable - -@Composable -expect fun BackHandler(onBack: () -> Unit) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt deleted file mode 100644 index d6012e4..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt +++ /dev/null @@ -1,57 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusMd -import app.folio.ui.theme.Type - -enum class ButtonStyle { Primary, Secondary, Ghost } - -@Composable -fun AppButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - style: ButtonStyle = ButtonStyle.Secondary, - enabled: Boolean = true, - description: String? = null, -) { - val t = LocalTokens.current - val (bg, fg, border) = when (style) { - ButtonStyle.Primary -> Triple(t.text, t.onAccent, t.text) - ButtonStyle.Secondary -> Triple(t.surface2, t.text, t.borderStrong) - ButtonStyle.Ghost -> Triple(t.bg, t.textMuted, t.bg) - } - Box( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(RadiusMd)) - .background(if (enabled) bg else t.surface3) - .border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd)) - .then( - if (description != null) Modifier.semantics { contentDescription = description } - else Modifier - ) - .clickable(enabled = enabled, role = Role.Button, onClick = onClick) - .padding(vertical = 14.dp, horizontal = 16.dp), - contentAlignment = Alignment.Center, - ) { - Text(text, style = Type.button, color = if (enabled) fg else t.textFaint) - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt deleted file mode 100644 index 236633b..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt +++ /dev/null @@ -1,33 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusLg - -@Composable -fun Card( - modifier: Modifier = Modifier, - dashed: Boolean = false, - content: @Composable () -> Unit, -) { - val t = LocalTokens.current - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(RadiusLg)) - .background(if (dashed) t.surface2 else t.surface) - .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { content() } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt deleted file mode 100644 index 69d52c8..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt +++ /dev/null @@ -1,56 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Type - -@Composable -fun EmptyState(title: String, subtitle: String, icon: ImageVector? = null) { - val t = LocalTokens.current - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp, horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (icon != null) { - Box( - modifier = Modifier - .size(56.dp) - .background(t.surface2, CircleShape) - .border(1.dp, t.border, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = rememberVectorPainter(icon), - contentDescription = null, - tint = t.textMuted, - modifier = Modifier.size(22.dp), - ) - } - Spacer(Modifier.height(2.dp)) - } - Text(title, style = Type.bodyStrong, color = t.text, textAlign = TextAlign.Center) - Text(subtitle, style = Type.caption, color = t.textMuted, textAlign = TextAlign.Center) - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt deleted file mode 100644 index c9322be..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Type - -@Composable -fun ErrorText(err: String?) { - val t = LocalTokens.current - Text( - err ?: "", - style = Type.caption, - color = t.text, - modifier = Modifier.fillMaxWidth().height(18.dp), - ) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt deleted file mode 100644 index ce6ea55..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Type - -@Composable -fun FieldLabel(text: String) { - val t = LocalTokens.current - Text(text.uppercase(), style = Type.label, color = t.textMuted) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt deleted file mode 100644 index 58af281..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.ScreenPad -import app.folio.ui.theme.Type - -@Composable -fun Header( - title: String, - subtitle: String? = null, - left: @Composable (() -> Unit)? = null, - right: @Composable (() -> Unit)? = null, -) { - val t = LocalTokens.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = ScreenPad, end = ScreenPad, top = 14.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (left != null) left() - Column(Modifier.weight(1f)) { - Text( - title, - style = Type.title, - color = t.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (subtitle != null) { - Text(subtitle, style = Type.caption, color = t.textMuted) - } - } - if (right != null) right() - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt deleted file mode 100644 index 81624e7..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt +++ /dev/null @@ -1,51 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.ui.icon.Icons -import app.folio.ui.theme.LocalTokens - -@Composable -fun IconButton( - onClick: () -> Unit, - description: String, - icon: ImageVector, -) { - val t = LocalTokens.current - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .background(t.surface2) - .border(1.dp, t.border, RoundedCornerShape(10.dp)) - .clickable(onClick = onClick) - .semantics { contentDescription = description }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = rememberVectorPainter(icon), - contentDescription = null, - tint = t.text, - modifier = Modifier.size(16.dp), - ) - } -} - -@Composable -fun BackButton(onClick: () -> Unit) { - IconButton(onClick = onClick, description = "Back", icon = Icons.Back) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt deleted file mode 100644 index c5b18a6..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt +++ /dev/null @@ -1,54 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.ScreenPad - -@Composable -fun Screen( - modifier: Modifier = Modifier, - header: @Composable (() -> Unit)? = null, - footer: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - val t = LocalTokens.current - Column(Modifier.fillMaxSize().then(modifier)) { - 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() - } - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt deleted file mode 100644 index 89a1ea3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt +++ /dev/null @@ -1,64 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusMd -import app.folio.ui.theme.RadiusSm -import app.folio.ui.theme.Type - -@Composable -fun Segmented( - selected: Int, - labels: List, - onSelect: (Int) -> Unit, - descriptions: List? = null, -) { - val t = LocalTokens.current - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(RadiusMd)) - .background(t.surface) - .border(1.dp, t.border, RoundedCornerShape(RadiusMd)) - .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - labels.forEachIndexed { i, label -> - val active = i == selected - val desc = descriptions?.getOrNull(i) - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(RadiusSm)) - .background(if (active) t.surface3 else t.surface) - .semantics { - this.selected = active - if (desc != null) contentDescription = desc - } - .clickable(role = Role.Tab) { onSelect(i) } - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text(label, style = Type.button, color = if (active) t.text else t.textMuted) - } - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt deleted file mode 100644 index a2ecdd3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt +++ /dev/null @@ -1,95 +0,0 @@ -package app.folio.ui.component - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.folio.FocusTracker -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.RadiusMd -import app.folio.ui.theme.Type - -@Composable -fun TextInput( - value: String, - onChange: (String) -> Unit, - placeholder: String = "", - invalid: Boolean = false, - password: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text, - textAlign: TextAlign = TextAlign.Start, - textStyle: TextStyle = Type.body, - label: String? = null, - description: String? = null, - modifier: Modifier = Modifier, -) { - val t = LocalTokens.current - var focused by remember { mutableStateOf(false) } - val borderColor = if (focused || invalid) t.text else t.border - val bg = if (focused) t.surface2 else t.surface - Box( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(RadiusMd)) - .background(bg) - .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(RadiusMd)) - .padding(horizontal = 14.dp, vertical = 14.dp), - contentAlignment = if (textAlign == TextAlign.Center) Alignment.Center else Alignment.CenterStart, - ) { - if (value.isEmpty() && placeholder.isNotEmpty()) { - Text( - placeholder, - style = textStyle.copy(color = t.textFaint, textAlign = textAlign), - modifier = Modifier.fillMaxWidth(), - ) - } - BasicTextField( - value = value, - onValueChange = onChange, - textStyle = textStyle.copy(color = t.text, textAlign = textAlign), - singleLine = true, - visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType), - cursorBrush = SolidColor(t.text), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - focused = it.isFocused - if (description != null) { - if (it.isFocused) FocusTracker.enter(description) - else FocusTracker.leave(description) - } - } - .semantics { - val desc = description ?: label - if (desc != null) contentDescription = desc - if (invalid) stateDescription = "Invalid" - }, - ) - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt deleted file mode 100644 index 2f1b183..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt +++ /dev/null @@ -1,44 +0,0 @@ -package app.folio.ui.icon - -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/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt deleted file mode 100644 index 0cc1ef7..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt +++ /dev/null @@ -1,45 +0,0 @@ -package app.folio.ui.theme - -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.unit.dp - -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() } - -@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/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt deleted file mode 100644 index 44ccbd8..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt +++ /dev/null @@ -1,20 +0,0 @@ -package app.folio.ui.theme - -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val Mono: FontFamily = FontFamily.Monospace - -object Type { - val body = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Normal) - val bodyStrong = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - val title = TextStyle(fontFamily = Mono, fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp) - val caption = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal) - val label = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.7.sp) - val balance = TextStyle(fontFamily = Mono, fontSize = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp) - val amountInput = TextStyle(fontFamily = Mono, fontSize = 36.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp) - val button = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - val brand = TextStyle(fontFamily = Mono, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp) -} diff --git a/examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq b/examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq deleted file mode 100644 index e49fe2f..0000000 --- a/examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq +++ /dev/null @@ -1,47 +0,0 @@ -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/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt deleted file mode 100644 index 04a86e2..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ /dev/null @@ -1,45 +0,0 @@ -package app.folio - -import androidx.compose.ui.window.ComposeUIViewController -import app.folio.data.Repository -import app.folio.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() - app.folio.sanderling.AuthSnapshots - app.folio.sanderling.AccountSnapshots - app.folio.sanderling.LedgerSnapshots - app.folio.sanderling.NavigationSnapshots - app.folio.sanderling.SanderlingIos.start() - 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/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt deleted file mode 100644 index 598ccfb..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver -import app.folio.db.LedgerDatabase - -actual class DriverFactory { - actual fun create(): SqlDriver = - NativeSqliteDriver(LedgerDatabase.Schema, "ledger.db") -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt deleted file mode 100644 index 594de08..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.folio.data - -actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory()) diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt deleted file mode 100644 index c78fc7b..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.folio.platform - -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/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt deleted file mode 100644 index 6276e14..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.platform - -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/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt deleted file mode 100644 index bb3dcab..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.folio.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/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt deleted file mode 100644 index de1bcc5..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt +++ /dev/null @@ -1,22 +0,0 @@ -package app.folio.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/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt b/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt deleted file mode 100644 index 50b6df4..0000000 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.data - -import app.cash.sqldelight.db.SqlDriver - -expect class DriverFactory { - fun create(): SqlDriver -} diff --git a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt b/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt deleted file mode 100644 index 7357c76..0000000 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt +++ /dev/null @@ -1,69 +0,0 @@ -package app.folio.data - -import app.cash.sqldelight.coroutines.asFlow -import app.cash.sqldelight.coroutines.mapToList -import app.cash.sqldelight.coroutines.mapToOneOrNull -import app.folio.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 app.folio.db.Txns.toDomain(): Transaction = - Transaction( - id = id, - accountId = accountId, - type = TxnType.valueOf(type), - amount = amount, - note = note, - createdAt = createdAt, - ) diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt deleted file mode 100644 index 3bbd0da..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt +++ /dev/null @@ -1,65 +0,0 @@ -package app.folio.data - -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/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt deleted file mode 100644 index 0523721..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt +++ /dev/null @@ -1,60 +0,0 @@ -package app.folio.data - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.browser.localStorage - -private const val STORAGE_KEY = "sanderling.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.equals(name, ignoreCase = true) } - - 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/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt deleted file mode 100644 index baaeb83..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt +++ /dev/null @@ -1,22 +0,0 @@ -package app.folio - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.window.ComposeViewport -import app.folio.data.Repository -import app.folio.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/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt deleted file mode 100644 index bc99afc..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.folio.platform - -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/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt deleted file mode 100644 index 467b5d8..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt +++ /dev/null @@ -1,22 +0,0 @@ -package app.folio.platform - -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/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt deleted file mode 100644 index 747c6b0..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt +++ /dev/null @@ -1,36 +0,0 @@ -package app.folio.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/folio/composeApp/src/wasmJsMain/resources/index.html b/examples/folio/composeApp/src/wasmJsMain/resources/index.html deleted file mode 100644 index dfc1982..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/resources/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Folio - - - -
- - - diff --git a/examples/folio/iosApp/iosApp/iOSApp.swift b/examples/folio/iosApp/iosApp/iOSApp.swift index 09b4572..c4821c1 100644 --- a/examples/folio/iosApp/iosApp/iOSApp.swift +++ b/examples/folio/iosApp/iosApp/iOSApp.swift @@ -1,5 +1,5 @@ import SwiftUI -import ComposeApp +import Shared @main struct iOSApp: App { diff --git a/examples/folio/iosApp/project.yml b/examples/folio/iosApp/project.yml index 5fcf8cd..a01b85f 100644 --- a/examples/folio/iosApp/project.yml +++ b/examples/folio/iosApp/project.yml @@ -19,7 +19,7 @@ targets: sources: - iosApp dependencies: - - framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework + - framework: ../app/shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework embed: false - sdk: libsqlite3.tbd settings: @@ -28,7 +28,7 @@ targets: PRODUCT_NAME: iosApp INFOPLIST_FILE: iosApp/Info.plist FRAMEWORK_SEARCH_PATHS: - - $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework + - $(SRCROOT)/../app/shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework OTHER_LDFLAGS: - -ObjC KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64 @@ -37,7 +37,7 @@ targets: script: | cd "$SRCROOT/.." ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \ - ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 + ./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64 outputFiles: - - $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp + - $(SRCROOT)/../app/shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared basedOnDependencyAnalysis: false diff --git a/examples/folio/justfile b/examples/folio/justfile index 263e8e0..a8f7294 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -40,14 +40,14 @@ install: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:installDebug + ./gradlew :app:androidApp:installDebug # Remove the folio APK from the connected device. uninstall: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:uninstallDebug + ./gradlew :app:androidApp:uninstallDebug # Remove gradle + iOS build directories. clean: