diff --git a/examples/folio/app/androidApp/build.gradle.kts b/examples/folio/app/androidApp/build.gradle.kts
new file mode 100644
index 0000000..276897d
--- /dev/null
+++ b/examples/folio/app/androidApp/build.gradle.kts
@@ -0,0 +1,44 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.plugin.compose)
+ alias(libs.plugins.compose.multiplatform)
+}
+
+val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev"
+
+kotlin {
+ compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
+}
+
+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/main/AndroidManifest.xml")
+ res.srcDirs("src/main/res")
+ }
+}
+
+dependencies {
+ implementation(projects.app.shared)
+ implementation(libs.androidx.activity.compose)
+}
diff --git a/examples/folio/app/androidApp/src/main/AndroidManifest.xml b/examples/folio/app/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c94068c
--- /dev/null
+++ b/examples/folio/app/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt b/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt
new file mode 100644
index 0000000..6d4d206
--- /dev/null
+++ b/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt
@@ -0,0 +1,14 @@
+package app.folio
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import app.folio.core.data.DriverFactory
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val driverFactory = DriverFactory(applicationContext)
+ setContent { App(driverFactory) }
+ }
+}
diff --git a/examples/folio/app/shared/build.gradle.kts b/examples/folio/app/shared/build.gradle.kts
new file mode 100644
index 0000000..4c1acdf
--- /dev/null
+++ b/examples/folio/app/shared/build.gradle.kts
@@ -0,0 +1,62 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.kotlin.plugin.compose)
+ alias(libs.plugins.kotlin.plugin.serialization)
+}
+
+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 = "Shared"
+ isStatic = true
+ binaryOption("bundleId", "app.folio.shared")
+ export(projects.core)
+ }
+ }
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs { browser() }
+
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ commonMain.dependencies {
+ api(projects.core)
+ api(projects.app.uiComponents)
+ implementation(compose.runtime)
+ implementation(compose.foundation)
+ implementation(compose.material3)
+ implementation(compose.ui)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.lifecycle.viewmodel.compose)
+ }
+ }
+}
+
+android {
+ namespace = "app.folio.shared"
+ compileSdk = 36
+ defaultConfig { minSdk = 24 }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt
new file mode 100644
index 0000000..c2a3b2d
--- /dev/null
+++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt
@@ -0,0 +1,7 @@
+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/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt
new file mode 100644
index 0000000..340f8bf
--- /dev/null
+++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt
@@ -0,0 +1,9 @@
+package app.folio.util
+
+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/app/shared/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt
new file mode 100644
index 0000000..8065951
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt
@@ -0,0 +1,92 @@
+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.windowInsetsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+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.Alignment
+import androidx.compose.ui.Modifier
+import app.folio.core.data.DriverFactory
+import app.folio.core.data.Repository
+import app.folio.core.data.SqlLedgerStore
+import app.folio.db.LedgerDatabase
+import app.folio.feature.account.AddAccountRoute
+import app.folio.feature.auth.LoginRoute
+import app.folio.feature.home.HomeRoute
+import app.folio.feature.ledger.AddTransactionRoute
+import app.folio.feature.ledger.LedgerRoute
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import app.folio.ui.theme.LedgerTheme
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.Tokens
+
+@Composable
+fun App(driverFactory: DriverFactory) {
+ var component by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ val driver = driverFactory.create()
+ val db = LedgerDatabase(driver)
+ val store = SqlLedgerStore(db)
+ component = AppComponent(repository = Repository(store), navigator = Navigator(initial = Route.Home))
+ }
+
+ LedgerTheme {
+ val t = Tokens()
+ CompositionLocalProvider(LocalTokens provides t) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(t.bg)
+ .windowInsetsPadding(WindowInsets.safeDrawing),
+ contentAlignment = Alignment.Center,
+ ) {
+ val c = component
+ if (c == null) {
+ // Loading: keep blank to avoid surprising the spec runner.
+ } else {
+ CompositionLocalProvider(LocalAppComponent provides c) {
+ AppContent()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AppContent() {
+ val component = LocalAppComponent.current
+ val session by component.repository.session.collectAsState()
+ val route by component.navigator.current.collectAsState()
+
+ LaunchedEffect(session, route) {
+ if (session == null && route !is Route.Login) {
+ component.navigator.replace(Route.Login)
+ } else if (session != null && route is Route.Login) {
+ component.navigator.replace(Route.Home)
+ }
+ }
+
+ Column(Modifier.fillMaxSize()) {
+ when (val r = route) {
+ Route.Login -> LoginRoute()
+ Route.Home -> HomeRoute()
+ Route.AddAccount -> AddAccountRoute()
+ is Route.Ledger -> LedgerRoute(accountId = r.accountId)
+ is Route.AddTransaction -> AddTransactionRoute(accountId = r.accountId)
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/AppComponent.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/AppComponent.kt
new file mode 100644
index 0000000..a07d8b9
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/AppComponent.kt
@@ -0,0 +1,14 @@
+package app.folio
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import app.folio.core.data.Repository
+import app.folio.navigation.Navigator
+
+class AppComponent(
+ val repository: Repository,
+ val navigator: Navigator,
+)
+
+val LocalAppComponent = staticCompositionLocalOf {
+ error("AppComponent not provided. Wrap content in CompositionLocalProvider(LocalAppComponent provides ...).")
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt
new file mode 100644
index 0000000..e41b421
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt
@@ -0,0 +1,79 @@
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.folio.LocalAppComponent
+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 AddAccountRoute() {
+ val component = LocalAppComponent.current
+ val vm: AddAccountViewModel = viewModel { AddAccountViewModel(component.repository, component.navigator) }
+ val state by vm.state.collectAsState()
+ AddAccountScreen(state = state, onEvent = vm::onEvent)
+}
+
+@Composable
+fun AddAccountScreen(state: AddAccountUiState, onEvent: (AddAccountEvent) -> Unit) {
+ val t = LocalTokens.current
+ BackHandler { onEvent(AddAccountEvent.Back) }
+
+ Screen(
+ modifier = Modifier.testTag("AddAccountScreen"),
+ header = {
+ Header(title = "New account", left = { BackButton(onClick = { onEvent(AddAccountEvent.Back) }) })
+ },
+ footer = {
+ AppButton(
+ text = "Create account",
+ onClick = { onEvent(AddAccountEvent.Submit) },
+ style = ButtonStyle.Primary,
+ enabled = state.name.trim().isNotEmpty(),
+ testTag = "AddAccountSubmit",
+ )
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ FieldLabel("Account name")
+ TextInput(
+ value = state.name,
+ onChange = { onEvent(AddAccountEvent.NameChange(it)) },
+ placeholder = "e.g. Checking",
+ invalid = state.error.isNotEmpty(),
+ label = "Account name",
+ testTag = "AccountNameField",
+ )
+ }
+ ErrorText(state.error)
+ 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/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt
new file mode 100644
index 0000000..5929284
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt
@@ -0,0 +1,59 @@
+package app.folio.feature.account
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.folio.core.data.Repository
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class AddAccountUiState(
+ val name: String = "",
+ val error: String = "",
+)
+
+sealed interface AddAccountEvent {
+ data class NameChange(val value: String) : AddAccountEvent
+ data object Submit : AddAccountEvent
+ data object Back : AddAccountEvent
+}
+
+class AddAccountViewModel(
+ private val repository: Repository,
+ private val navigator: Navigator,
+) : ViewModel() {
+ private val _state = MutableStateFlow(AddAccountUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun onEvent(event: AddAccountEvent) {
+ when (event) {
+ is AddAccountEvent.NameChange -> _state.update { it.copy(name = event.value, error = "") }
+ AddAccountEvent.Back -> navigator.back(Route.Home)
+ AddAccountEvent.Submit -> submit()
+ }
+ }
+
+ private fun submit() {
+ val trimmed = _state.value.name.trim()
+ if (trimmed.isEmpty()) {
+ _state.update { it.copy(error = "Account name is required") }
+ return
+ }
+ if (trimmed.length > 40) {
+ _state.update { it.copy(error = "Name is too long (max 40 characters)") }
+ return
+ }
+ viewModelScope.launch {
+ try {
+ repository.createAccount(trimmed)
+ navigator.replace(Route.Home)
+ } catch (e: IllegalArgumentException) {
+ _state.update { it.copy(error = e.message ?: "Could not create account") }
+ }
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt
new file mode 100644
index 0000000..e53ae1b
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt
@@ -0,0 +1,86 @@
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.folio.LocalAppComponent
+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
+
+@Composable
+fun LoginRoute() {
+ val component = LocalAppComponent.current
+ val vm: LoginViewModel = viewModel { LoginViewModel(component.repository, component.navigator) }
+ val state by vm.state.collectAsState()
+ LoginScreen(state = state, onEvent = vm::onEvent)
+}
+
+@Composable
+fun LoginScreen(state: LoginUiState, onEvent: (LoginEvent) -> Unit) {
+ val t = LocalTokens.current
+ Screen(modifier = Modifier.testTag("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 = state.email,
+ onChange = { onEvent(LoginEvent.EmailChange(it)) },
+ placeholder = DEMO_EMAIL,
+ invalid = state.error.isNotEmpty(),
+ keyboardType = KeyboardType.Email,
+ label = "Email",
+ testTag = "LoginEmail",
+ )
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ FieldLabel("Password")
+ TextInput(
+ value = state.password,
+ onChange = { onEvent(LoginEvent.PasswordChange(it)) },
+ placeholder = "••••••••",
+ password = true,
+ invalid = state.error.isNotEmpty(),
+ keyboardType = KeyboardType.Password,
+ label = "Password",
+ testTag = "LoginPassword",
+ )
+ }
+ ErrorText(state.error)
+ AppButton(
+ text = "Sign in",
+ onClick = { onEvent(LoginEvent.Submit) },
+ style = ButtonStyle.Primary,
+ testTag = "LoginSubmit",
+ )
+ 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/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt
new file mode 100644
index 0000000..72b9dcd
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt
@@ -0,0 +1,63 @@
+package app.folio.feature.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.folio.core.data.Repository
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+const val DEMO_EMAIL = "demo@folio.app"
+const val DEMO_PASSWORD = "ledger123"
+
+data class LoginUiState(
+ val email: String = "",
+ val password: String = "",
+ val error: String = "",
+)
+
+sealed interface LoginEvent {
+ data class EmailChange(val value: String) : LoginEvent
+ data class PasswordChange(val value: String) : LoginEvent
+ data object Submit : LoginEvent
+}
+
+class LoginViewModel(
+ private val repository: Repository,
+ private val navigator: Navigator,
+) : ViewModel() {
+ private val _state = MutableStateFlow(LoginUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ fun onEvent(event: LoginEvent) {
+ when (event) {
+ is LoginEvent.EmailChange -> _state.update { it.copy(email = event.value, error = "") }
+ is LoginEvent.PasswordChange -> _state.update { it.copy(password = event.value, error = "") }
+ LoginEvent.Submit -> submit()
+ }
+ }
+
+ private fun submit() {
+ val s = _state.value
+ if (s.email.isBlank() || s.password.isEmpty()) {
+ _state.update { it.copy(error = "Enter email and password") }
+ return
+ }
+ if (!checkCredentials(s.email, s.password)) {
+ _state.update { it.copy(error = "Invalid email or password") }
+ return
+ }
+ val user = s.email.trim().lowercase()
+ viewModelScope.launch {
+ repository.setSession(user)
+ navigator.replace(Route.Home)
+ }
+ }
+
+ private fun checkCredentials(email: String, password: String): Boolean =
+ email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt
new file mode 100644
index 0000000..678698b
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt
@@ -0,0 +1,164 @@
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.folio.LocalAppComponent
+import app.folio.util.balanceOf
+import app.folio.util.formatCents
+import app.folio.util.initialsOf
+import app.folio.util.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 HomeRoute() {
+ val component = LocalAppComponent.current
+ val vm: HomeViewModel = viewModel { HomeViewModel(component.repository, component.navigator) }
+ val state by vm.state.collectAsState()
+ HomeScreen(state = state, onEvent = vm::onEvent)
+}
+
+@Composable
+fun HomeScreen(state: HomeUiState, onEvent: (HomeEvent) -> Unit) {
+ val t = LocalTokens.current
+ val total = balanceOf(state.transactions)
+
+ Screen(
+ modifier = Modifier.testTag("HomeScreen"),
+ header = {
+ Header(
+ title = "Accounts",
+ subtitle = state.user,
+ right = {
+ IconButton(
+ onClick = { onEvent(HomeEvent.Logout) },
+ label = "Log out",
+ icon = Icons.Logout,
+ testTag = "LogoutButton",
+ )
+ },
+ )
+ },
+ 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(
+ "${state.accounts.size} account${if (state.accounts.size == 1) "" else "s"}",
+ style = Type.caption,
+ color = t.textFaint,
+ )
+ }
+ AppButton(
+ text = "+ Add account",
+ onClick = { onEvent(HomeEvent.AddAccount) },
+ style = ButtonStyle.Primary,
+ testTag = "AddAccountButton",
+ )
+ },
+ ) {
+ if (state.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)) {
+ state.accounts.forEach { a ->
+ val bal = state.transactions.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
+ val count = state.transactions.count { it.accountId == a.id }
+ AccountCard(
+ name = a.name,
+ initials = initialsOf(a.name),
+ count = count,
+ balance = bal,
+ onClick = { onEvent(HomeEvent.OpenAccount(a.id)) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AccountCard(
+ name: String,
+ initials: String,
+ count: Int,
+ balance: Long,
+ onClick: () -> Unit,
+) {
+ val t = LocalTokens.current
+ val txnLabel = if (count == 1) "1 transaction" else "$count transactions"
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(RadiusLg))
+ .background(t.surface)
+ .border(1.dp, t.border, RoundedCornerShape(RadiusLg))
+ .testTag("AccountCard")
+ .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.testTag("AccountBalance"),
+ )
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt
new file mode 100644
index 0000000..75bf68d
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt
@@ -0,0 +1,54 @@
+package app.folio.feature.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.folio.core.data.Account
+import app.folio.core.data.Repository
+import app.folio.core.data.Transaction
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+data class HomeUiState(
+ val user: String = "",
+ val accounts: List = emptyList(),
+ val transactions: List = emptyList(),
+)
+
+sealed interface HomeEvent {
+ data object Logout : HomeEvent
+ data object AddAccount : HomeEvent
+ data class OpenAccount(val accountId: String) : HomeEvent
+}
+
+class HomeViewModel(
+ private val repository: Repository,
+ private val navigator: Navigator,
+) : ViewModel() {
+ val state: StateFlow = combine(
+ repository.session,
+ repository.accounts,
+ repository.transactions,
+ ) { session, accounts, transactions ->
+ HomeUiState(
+ user = session?.user ?: "",
+ accounts = accounts,
+ transactions = transactions,
+ )
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUiState())
+
+ fun onEvent(event: HomeEvent) {
+ when (event) {
+ HomeEvent.Logout -> viewModelScope.launch {
+ repository.clearSession()
+ navigator.replace(Route.Login)
+ }
+ HomeEvent.AddAccount -> navigator.push(Route.AddAccount)
+ is HomeEvent.OpenAccount -> navigator.push(Route.Ledger(event.accountId))
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt
new file mode 100644
index 0000000..c03983e
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt
@@ -0,0 +1,120 @@
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.folio.LocalAppComponent
+import app.folio.core.data.TxnType
+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
+
+@Composable
+fun AddTransactionRoute(accountId: String) {
+ val component = LocalAppComponent.current
+ val vm: AddTransactionViewModel = viewModel(key = "addTxn:$accountId") {
+ AddTransactionViewModel(component.repository, component.navigator, accountId)
+ }
+ val state by vm.state.collectAsState()
+ AddTransactionScreen(state = state, onEvent = vm::onEvent)
+}
+
+@Composable
+fun AddTransactionScreen(state: AddTransactionUiState, onEvent: (AddTransactionEvent) -> Unit) {
+ BackHandler { onEvent(AddTransactionEvent.Back) }
+
+ val account = state.account
+ if (account == null) {
+ Screen(
+ header = {
+ Header(title = "Add transaction", left = { BackButton { onEvent(AddTransactionEvent.BackToHome) } })
+ },
+ ) {
+ EmptyState(title = "Account not found", subtitle = "")
+ AppButton(
+ text = "Back to accounts",
+ onClick = { onEvent(AddTransactionEvent.BackToHome) },
+ style = ButtonStyle.Secondary,
+ )
+ }
+ return
+ }
+
+ Screen(
+ modifier = Modifier.testTag("AddTransactionScreen"),
+ header = {
+ Header(
+ title = "Add transaction",
+ subtitle = account.name,
+ left = { BackButton { onEvent(AddTransactionEvent.Back) } },
+ )
+ },
+ footer = {
+ AppButton(
+ text = if (state.type == TxnType.credit) "Add credit" else "Add debit",
+ onClick = { onEvent(AddTransactionEvent.Submit) },
+ style = ButtonStyle.Primary,
+ enabled = state.amount.isNotBlank(),
+ testTag = "TxnSubmit",
+ )
+ },
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Segmented(
+ selected = if (state.type == TxnType.credit) 0 else 1,
+ labels = listOf("Credit", "Debit"),
+ onSelect = {
+ onEvent(AddTransactionEvent.TypeChange(if (it == 0) TxnType.credit else TxnType.debit))
+ },
+ testTags = listOf("TxnTypeCredit", "TxnTypeDebit"),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ FieldLabel("Amount")
+ TextInput(
+ value = state.amount,
+ onChange = { onEvent(AddTransactionEvent.AmountChange(it)) },
+ placeholder = "0.00",
+ invalid = state.error.isNotEmpty(),
+ keyboardType = KeyboardType.Decimal,
+ textAlign = TextAlign.Center,
+ textStyle = Type.amountInput,
+ label = "Amount",
+ testTag = "TxnAmountField",
+ )
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ FieldLabel("Note (optional)")
+ TextInput(
+ value = state.note,
+ onChange = { onEvent(AddTransactionEvent.NoteChange(it)) },
+ placeholder = "What's this for?",
+ label = "Note",
+ testTag = "TxnNoteField",
+ )
+ }
+ ErrorText(state.error)
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt
new file mode 100644
index 0000000..315719a
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt
@@ -0,0 +1,88 @@
+package app.folio.feature.ledger
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.folio.core.data.Account
+import app.folio.core.data.Repository
+import app.folio.core.data.TxnType
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import app.folio.util.parseCents
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
+
+data class AddTransactionUiState(
+ val account: Account? = null,
+ val type: TxnType = TxnType.credit,
+ val amount: String = "",
+ val note: String = "",
+ val error: String = "",
+)
+
+sealed interface AddTransactionEvent {
+ data class TypeChange(val type: TxnType) : AddTransactionEvent
+ data class AmountChange(val value: String) : AddTransactionEvent
+ data class NoteChange(val value: String) : AddTransactionEvent
+ data object Submit : AddTransactionEvent
+ data object Back : AddTransactionEvent
+ data object BackToHome : AddTransactionEvent
+}
+
+class AddTransactionViewModel(
+ private val repository: Repository,
+ private val navigator: Navigator,
+ private val accountId: String,
+) : ViewModel() {
+ private val form = MutableStateFlow(AddTransactionUiState())
+
+ val state: StateFlow = combine(
+ form,
+ repository.accounts.map { accounts -> accounts.firstOrNull { it.id == accountId } },
+ ) { f, account -> f.copy(account = account) }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, AddTransactionUiState())
+
+ fun onEvent(event: AddTransactionEvent) {
+ when (event) {
+ is AddTransactionEvent.TypeChange -> form.update { it.copy(type = event.type, error = "") }
+ is AddTransactionEvent.AmountChange -> {
+ val v = event.value
+ if (v.isEmpty() || AMOUNT_REGEX.matches(v)) {
+ form.update { it.copy(amount = v, error = "") }
+ }
+ }
+ is AddTransactionEvent.NoteChange -> form.update { it.copy(note = event.value.take(80), error = "") }
+ AddTransactionEvent.Back -> navigator.back(Route.Ledger(accountId))
+ AddTransactionEvent.BackToHome -> navigator.replace(Route.Home)
+ AddTransactionEvent.Submit -> submit()
+ }
+ }
+
+ private fun submit() {
+ val s = form.value
+ val cents = parseCents(s.amount)
+ if (cents == null) {
+ form.update { it.copy(error = "Enter a valid amount (e.g. 12.34)") }
+ return
+ }
+ if (cents <= 0) {
+ form.update { it.copy(error = "Amount must be greater than zero") }
+ return
+ }
+ viewModelScope.launch {
+ try {
+ repository.createTransaction(accountId, s.type, cents, s.note)
+ navigator.back(Route.Home)
+ } catch (e: IllegalArgumentException) {
+ form.update { it.copy(error = e.message ?: "Could not save transaction") }
+ }
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt
new file mode 100644
index 0000000..1a0c585
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt
@@ -0,0 +1,176 @@
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import app.folio.LocalAppComponent
+import app.folio.core.data.TxnType
+import app.folio.util.balanceOf
+import app.folio.util.formatCents
+import app.folio.util.formatDate
+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 LedgerRoute(accountId: String) {
+ val component = LocalAppComponent.current
+ val vm: LedgerViewModel = viewModel(key = "ledger:$accountId") {
+ LedgerViewModel(component.repository, component.navigator, accountId)
+ }
+ val state by vm.state.collectAsState()
+ LedgerScreen(state = state, onEvent = vm::onEvent)
+}
+
+@Composable
+fun LedgerScreen(state: LedgerUiState, onEvent: (LedgerEvent) -> Unit) {
+ val t = LocalTokens.current
+
+ BackHandler { onEvent(LedgerEvent.Back) }
+
+ val account = state.account
+ if (account == null) {
+ Screen(
+ header = {
+ Header(title = "Account", left = { BackButton { onEvent(LedgerEvent.Back) } })
+ },
+ ) {
+ EmptyState(title = "Account not found", subtitle = "It may have been deleted.")
+ AppButton(
+ text = "Back to accounts",
+ onClick = { onEvent(LedgerEvent.BackToHome) },
+ style = ButtonStyle.Secondary,
+ )
+ }
+ return
+ }
+
+ val balance = balanceOf(state.transactions)
+
+ Screen(
+ modifier = Modifier.testTag("LedgerScreen"),
+ header = {
+ Header(
+ title = account.name,
+ subtitle = "Ledger",
+ left = { BackButton { onEvent(LedgerEvent.Back) } },
+ )
+ },
+ footer = {
+ AppButton(
+ text = "+ Add transaction",
+ onClick = { onEvent(LedgerEvent.AddTransaction) },
+ style = ButtonStyle.Primary,
+ testTag = "AddTransactionButton",
+ )
+ },
+ ) {
+ Card {
+ Text("BALANCE", style = Type.label, color = t.textMuted)
+ Text(
+ formatCents(balance),
+ style = Type.balance,
+ color = t.text,
+ modifier = Modifier.testTag("LedgerBalance"),
+ )
+ }
+ Text(
+ "ACTIVITY",
+ style = Type.label,
+ color = t.textFaint,
+ modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
+ )
+ if (state.transactions.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),
+ ) {
+ state.transactions.forEachIndexed { i, txn ->
+ TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
+ if (i != state.transactions.lastIndex) {
+ Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) {
+ val t = LocalTokens.current
+ val signed = if (type == TxnType.credit) amount else -amount
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 14.dp)
+ .testTag("LedgerRow"),
+ 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.testTag("TxnAmount"),
+ )
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt
new file mode 100644
index 0000000..3aaa0d1
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt
@@ -0,0 +1,52 @@
+package app.folio.feature.ledger
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.folio.core.data.Account
+import app.folio.core.data.Repository
+import app.folio.core.data.Transaction
+import app.folio.navigation.Navigator
+import app.folio.navigation.Route
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+
+data class LedgerUiState(
+ val account: Account? = null,
+ val transactions: List = emptyList(),
+ val accountId: String = "",
+)
+
+sealed interface LedgerEvent {
+ data object Back : LedgerEvent
+ data object AddTransaction : LedgerEvent
+ data object BackToHome : LedgerEvent
+}
+
+class LedgerViewModel(
+ private val repository: Repository,
+ private val navigator: Navigator,
+ private val accountId: String,
+) : ViewModel() {
+ val state: StateFlow = combine(
+ repository.accounts,
+ repository.transactions,
+ ) { accounts, txns ->
+ LedgerUiState(
+ account = accounts.firstOrNull { it.id == accountId },
+ transactions = txns
+ .filter { it.accountId == accountId }
+ .sortedByDescending { it.createdAt },
+ accountId = accountId,
+ )
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, LedgerUiState(accountId = accountId))
+
+ fun onEvent(event: LedgerEvent) {
+ when (event) {
+ LedgerEvent.Back -> navigator.back(Route.Home)
+ LedgerEvent.AddTransaction -> navigator.push(Route.AddTransaction(accountId))
+ LedgerEvent.BackToHome -> navigator.replace(Route.Home)
+ }
+ }
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt
new file mode 100644
index 0000000..823e389
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt
@@ -0,0 +1,31 @@
+package app.folio.navigation
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class Navigator(initial: Route = Route.Home) {
+ private val stack = ArrayDeque().apply { addLast(initial) }
+ private val _current = MutableStateFlow(initial)
+ 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/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt
new file mode 100644
index 0000000..73ce55e
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt
@@ -0,0 +1,12 @@
+package app.folio.navigation
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface Route {
+ @Serializable data object Login : Route
+ @Serializable data object Home : Route
+ @Serializable data object AddAccount : Route
+ @Serializable data class Ledger(val accountId: String) : Route
+ @Serializable data class AddTransaction(val accountId: String) : Route
+}
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt
new file mode 100644
index 0000000..47044cf
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt
@@ -0,0 +1,6 @@
+package app.folio.ui
+
+import androidx.compose.runtime.Composable
+
+@Composable
+expect fun BackHandler(onBack: () -> Unit)
diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt
new file mode 100644
index 0000000..d40c9d4
--- /dev/null
+++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt
@@ -0,0 +1,73 @@
+package app.folio.util
+
+import app.folio.core.data.Transaction
+import app.folio.core.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/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt
new file mode 100644
index 0000000..ef2a4aa
--- /dev/null
+++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt
@@ -0,0 +1,39 @@
+package app.folio
+
+import androidx.compose.ui.window.ComposeUIViewController
+import app.folio.core.data.DriverFactory
+import app.folio.ui.IosBackGesture
+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 {
+ val driverFactory = DriverFactory()
+ val vc = ComposeUIViewController { App(driverFactory) }
+ val gesture = UIScreenEdgePanGestureRecognizer(
+ target = backGestureTarget,
+ action = NSSelectorFromString("handleGesture:"),
+ )
+ gesture.edges = UIRectEdgeLeft
+ vc.view.addGestureRecognizer(gesture)
+ return vc
+}
diff --git a/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt
new file mode 100644
index 0000000..aae594d
--- /dev/null
+++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt
@@ -0,0 +1,15 @@
+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 = IosBackGesture.register { current() }
+ onDispose { IosBackGesture.unregister(id) }
+ }
+}
diff --git a/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt
new file mode 100644
index 0000000..de1bcc5
--- /dev/null
+++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt
@@ -0,0 +1,22 @@
+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/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt
new file mode 100644
index 0000000..fe6edcd
--- /dev/null
+++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt
@@ -0,0 +1,16 @@
+package app.folio.util
+
+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/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt
new file mode 100644
index 0000000..747c6b0
--- /dev/null
+++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt
@@ -0,0 +1,36 @@
+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/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt
new file mode 100644
index 0000000..100fa81
--- /dev/null
+++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt
@@ -0,0 +1,27 @@
+package app.folio.util
+
+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/app/ui-components/build.gradle.kts b/examples/folio/app/ui-components/build.gradle.kts
new file mode 100644
index 0000000..79e5c0c
--- /dev/null
+++ b/examples/folio/app/ui-components/build.gradle.kts
@@ -0,0 +1,50 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.kotlin.plugin.compose)
+}
+
+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) }
+ }
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs { browser() }
+
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ commonMain.dependencies {
+ api(compose.runtime)
+ api(compose.foundation)
+ api(compose.material3)
+ api(compose.ui)
+ }
+ }
+}
+
+android {
+ namespace = "app.folio.ui"
+ compileSdk = 36
+ defaultConfig { minSdk = 24 }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt
new file mode 100644
index 0000000..0469542
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt
@@ -0,0 +1,53 @@
+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.platform.testTag
+import androidx.compose.ui.semantics.Role
+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,
+ testTag: 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 (testTag != null) Modifier.testTag(testTag) 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/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt
new file mode 100644
index 0000000..236633b
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt
@@ -0,0 +1,33 @@
+package app.folio.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.RadiusLg
+
+@Composable
+fun Card(
+ modifier: Modifier = Modifier,
+ dashed: Boolean = false,
+ content: @Composable () -> Unit,
+) {
+ val t = LocalTokens.current
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(RadiusLg))
+ .background(if (dashed) t.surface2 else t.surface)
+ .border(1.dp, t.border, RoundedCornerShape(RadiusLg))
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) { content() }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt
new file mode 100644
index 0000000..69d52c8
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt
@@ -0,0 +1,56 @@
+package app.folio.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.Type
+
+@Composable
+fun EmptyState(title: String, subtitle: String, icon: ImageVector? = null) {
+ val t = LocalTokens.current
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 48.dp, horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (icon != null) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(t.surface2, CircleShape)
+ .border(1.dp, t.border, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = rememberVectorPainter(icon),
+ contentDescription = null,
+ tint = t.textMuted,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(Modifier.height(2.dp))
+ }
+ Text(title, style = Type.bodyStrong, color = t.text, textAlign = TextAlign.Center)
+ Text(subtitle, style = Type.caption, color = t.textMuted, textAlign = TextAlign.Center)
+ }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt
new file mode 100644
index 0000000..c9322be
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt
@@ -0,0 +1,21 @@
+package app.folio.ui.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.Type
+
+@Composable
+fun ErrorText(err: String?) {
+ val t = LocalTokens.current
+ Text(
+ err ?: "",
+ style = Type.caption,
+ color = t.text,
+ modifier = Modifier.fillMaxWidth().height(18.dp),
+ )
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt
new file mode 100644
index 0000000..ce6ea55
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt
@@ -0,0 +1,12 @@
+package app.folio.ui.component
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.Type
+
+@Composable
+fun FieldLabel(text: String) {
+ val t = LocalTokens.current
+ Text(text.uppercase(), style = Type.label, color = t.textMuted)
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt
new file mode 100644
index 0000000..58af281
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt
@@ -0,0 +1,48 @@
+package app.folio.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import app.folio.ui.theme.LocalTokens
+import app.folio.ui.theme.ScreenPad
+import app.folio.ui.theme.Type
+
+@Composable
+fun Header(
+ title: String,
+ subtitle: String? = null,
+ left: @Composable (() -> Unit)? = null,
+ right: @Composable (() -> Unit)? = null,
+) {
+ val t = LocalTokens.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = ScreenPad, end = ScreenPad, top = 14.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (left != null) left()
+ Column(Modifier.weight(1f)) {
+ Text(
+ title,
+ style = Type.title,
+ color = t.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (subtitle != null) {
+ Text(subtitle, style = Type.caption, color = t.textMuted)
+ }
+ }
+ if (right != null) right()
+ }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt
new file mode 100644
index 0000000..dfc9c79
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt
@@ -0,0 +1,54 @@
+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.platform.testTag
+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,
+ label: String,
+ icon: ImageVector,
+ testTag: String? = null,
+) {
+ 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)
+ .then(if (testTag != null) Modifier.testTag(testTag) else Modifier)
+ .semantics { contentDescription = label },
+ 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, label = "Back", icon = Icons.Back, testTag = "BackButton")
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt
new file mode 100644
index 0000000..c5b18a6
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt
@@ -0,0 +1,54 @@
+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/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt
new file mode 100644
index 0000000..7625279
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt
@@ -0,0 +1,62 @@
+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.platform.testTag
+import androidx.compose.ui.semantics.Role
+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,
+ testTags: 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 tag = testTags?.getOrNull(i)
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(RadiusSm))
+ .background(if (active) t.surface3 else t.surface)
+ .then(if (tag != null) Modifier.testTag(tag) else Modifier)
+ .semantics { this.selected = active }
+ .clickable(role = Role.Tab) { onSelect(i) }
+ .padding(vertical = 10.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, style = Type.button, color = if (active) t.text else t.textMuted)
+ }
+ }
+ }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt
new file mode 100644
index 0000000..eb595eb
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt
@@ -0,0 +1,89 @@
+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.platform.testTag
+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.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,
+ testTag: 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 }
+ .then(if (testTag != null) Modifier.testTag(testTag) else Modifier)
+ .semantics {
+ contentDescription = label
+ if (invalid) stateDescription = "Invalid"
+ },
+ )
+ }
+}
diff --git a/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt
new file mode 100644
index 0000000..2f1b183
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt
@@ -0,0 +1,44 @@
+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/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt
new file mode 100644
index 0000000..0cc1ef7
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt
@@ -0,0 +1,45 @@
+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/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt
new file mode 100644
index 0000000..44ccbd8
--- /dev/null
+++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt
@@ -0,0 +1,20 @@
+package app.folio.ui.theme
+
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Mono: FontFamily = FontFamily.Monospace
+
+object Type {
+ val body = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Normal)
+ val bodyStrong = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ val title = TextStyle(fontFamily = Mono, fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
+ val caption = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal)
+ val label = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.7.sp)
+ val balance = TextStyle(fontFamily = Mono, fontSize = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
+ val amountInput = TextStyle(fontFamily = Mono, fontSize = 36.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
+ val button = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ val brand = TextStyle(fontFamily = Mono, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
+}
diff --git a/examples/folio/build.gradle.kts b/examples/folio/build.gradle.kts
index d3137ca..fc357d1 100644
--- a/examples/folio/build.gradle.kts
+++ b/examples/folio/build.gradle.kts
@@ -1,7 +1,12 @@
plugins {
- id("com.android.application") version "8.13.2" apply false
- id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false
- id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
- id("org.jetbrains.compose") version "1.10.3" apply false
- id("app.cash.sqldelight") version "2.3.2" apply false
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.multiplatform) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.plugin.compose) apply false
+ alias(libs.plugins.kotlin.plugin.serialization) apply false
+ alias(libs.plugins.compose.multiplatform) apply false
+ alias(libs.plugins.sqldelight) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.metro) apply false
}
diff --git a/examples/folio/core/build.gradle.kts b/examples/folio/core/build.gradle.kts
new file mode 100644
index 0000000..6bb72e1
--- /dev/null
+++ b/examples/folio/core/build.gradle.kts
@@ -0,0 +1,67 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.sqldelight)
+}
+
+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) }
+ }
+ iosX64()
+ iosArm64()
+ iosSimulatorArm64()
+
+ @OptIn(ExperimentalWasmDsl::class)
+ wasmJs { browser() }
+
+ applyDefaultHierarchyTemplate()
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ api(libs.sqldelight.runtime)
+ api(libs.sqldelight.coroutines.extensions)
+ api(libs.sqldelight.async.extensions)
+ }
+ androidMain.dependencies {
+ implementation(libs.sqldelight.android.driver)
+ }
+ iosMain.dependencies {
+ implementation(libs.sqldelight.native.driver)
+ }
+ wasmJsMain.dependencies {
+ implementation(libs.sqldelight.web.worker.driver)
+ }
+ }
+}
+
+android {
+ namespace = "app.folio.core"
+ compileSdk = 36
+ defaultConfig { minSdk = 24 }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+sqldelight {
+ databases {
+ create("LedgerDatabase") {
+ packageName.set("app.folio.db")
+ generateAsync.set(true)
+ }
+ }
+}
diff --git a/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt
new file mode 100644
index 0000000..cd3f674
--- /dev/null
+++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt
@@ -0,0 +1,12 @@
+package app.folio.core.data
+
+import android.content.Context
+import app.cash.sqldelight.async.coroutines.synchronous
+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 suspend fun create(): SqlDriver =
+ AndroidSqliteDriver(LedgerDatabase.Schema.synchronous(), context, "ledger.db")
+}
diff --git a/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt
new file mode 100644
index 0000000..adefad1
--- /dev/null
+++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt
@@ -0,0 +1,8 @@
+package app.folio.core.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/core/src/commonMain/kotlin/app/folio/core/data/Account.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt
new file mode 100644
index 0000000..ff29a77
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt
@@ -0,0 +1,7 @@
+package app.folio.core.data
+
+data class Account(
+ val id: String,
+ val name: String,
+ val createdAt: Long,
+)
diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt
new file mode 100644
index 0000000..07ee724
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt
@@ -0,0 +1,7 @@
+package app.folio.core.data
+
+import app.cash.sqldelight.db.SqlDriver
+
+expect class DriverFactory {
+ suspend fun create(): SqlDriver
+}
diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt
new file mode 100644
index 0000000..4fa54fa
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt
@@ -0,0 +1,15 @@
+package app.folio.core.data
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface LedgerStore {
+ val accounts: StateFlow>
+ val transactions: StateFlow>
+ val session: StateFlow
+
+ suspend fun accountExistsByName(name: String): Boolean
+ suspend fun insertAccount(id: String, name: String, createdAt: Long)
+ suspend fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long)
+ suspend fun upsertSession(user: String, loggedInAt: Long)
+ suspend fun clearSession()
+}
diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt
new file mode 100644
index 0000000..4a0fb1a
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt
@@ -0,0 +1,40 @@
+package app.folio.core.data
+
+import app.folio.core.platform.Platform
+import kotlinx.coroutines.flow.StateFlow
+
+class Repository(private val store: LedgerStore) {
+ val accounts: StateFlow> = store.accounts
+ val transactions: StateFlow> = store.transactions
+ val session: StateFlow = store.session
+
+ suspend 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 }
+
+ suspend 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
+ }
+
+ suspend fun setSession(user: String) = store.upsertSession(user, Platform.now())
+ suspend fun clearSession() = store.clearSession()
+}
diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt
new file mode 100644
index 0000000..d3bae1c
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt
@@ -0,0 +1,6 @@
+package app.folio.core.data
+
+data class Session(
+ val user: String,
+ val loggedInAt: Long,
+)
diff --git a/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt
new file mode 100644
index 0000000..d508494
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt
@@ -0,0 +1,68 @@
+package app.folio.core.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(db: LedgerDatabase) : LedgerStore {
+ private val queries = db.ledgerQueries
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ override val accounts: StateFlow> =
+ queries.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> =
+ queries.selectAllTxns()
+ .asFlow()
+ .mapToList(Dispatchers.Default)
+ .map { rows -> rows.map { it.toDomain() } }
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ override val session: StateFlow =
+ queries.selectSession()
+ .asFlow()
+ .mapToOneOrNull(Dispatchers.Default)
+ .map { row -> row?.let { Session(it.user, it.loggedInAt) } }
+ .stateIn(scope, SharingStarted.Eagerly, null)
+
+ override suspend fun accountExistsByName(name: String): Boolean =
+ queries.accountExistsByName(name).awaitAsOne()
+
+ override suspend fun insertAccount(id: String, name: String, createdAt: Long) {
+ queries.insertAccount(id, name, createdAt)
+ }
+
+ override suspend fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
+ queries.insertTxn(id, accountId, type.name, amount, note, createdAt)
+ }
+
+ override suspend fun upsertSession(user: String, loggedInAt: Long) {
+ queries.upsertSession(user, loggedInAt)
+ }
+
+ override suspend fun clearSession() {
+ queries.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/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt
new file mode 100644
index 0000000..33162ad
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt
@@ -0,0 +1,12 @@
+package app.folio.core.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/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt
new file mode 100644
index 0000000..5d0650e
--- /dev/null
+++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt
@@ -0,0 +1,6 @@
+package app.folio.core.platform
+
+expect object Platform {
+ fun now(): Long
+ fun makeId(): String
+}
diff --git a/examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq b/examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq
new file mode 100644
index 0000000..e49fe2f
--- /dev/null
+++ b/examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq
@@ -0,0 +1,47 @@
+CREATE TABLE Accounts (
+ id TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ createdAt INTEGER NOT NULL
+);
+
+CREATE TABLE Txns (
+ id TEXT NOT NULL PRIMARY KEY,
+ accountId TEXT NOT NULL,
+ type TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ note TEXT NOT NULL,
+ createdAt INTEGER NOT NULL
+);
+
+CREATE INDEX txn_account ON Txns(accountId);
+CREATE INDEX txn_created ON Txns(createdAt);
+
+CREATE TABLE AppSession (
+ key INTEGER NOT NULL PRIMARY KEY CHECK (key = 1),
+ user TEXT NOT NULL,
+ loggedInAt INTEGER NOT NULL
+);
+
+selectAllAccounts:
+SELECT * FROM Accounts ORDER BY createdAt ASC;
+
+accountExistsByName:
+SELECT EXISTS(SELECT 1 FROM Accounts WHERE name = :name COLLATE NOCASE);
+
+insertAccount:
+INSERT INTO Accounts(id, name, createdAt) VALUES (?, ?, ?);
+
+selectAllTxns:
+SELECT * FROM Txns;
+
+insertTxn:
+INSERT INTO Txns(id, accountId, type, amount, note, createdAt) VALUES (?, ?, ?, ?, ?, ?);
+
+selectSession:
+SELECT user, loggedInAt FROM AppSession WHERE key = 1;
+
+upsertSession:
+INSERT OR REPLACE INTO AppSession(key, user, loggedInAt) VALUES (1, ?, ?);
+
+clearSession:
+DELETE FROM AppSession WHERE key = 1;
diff --git a/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt b/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt
new file mode 100644
index 0000000..5633774
--- /dev/null
+++ b/examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt
@@ -0,0 +1,11 @@
+package app.folio.core.data
+
+import app.cash.sqldelight.async.coroutines.synchronous
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.native.NativeSqliteDriver
+import app.folio.db.LedgerDatabase
+
+actual class DriverFactory {
+ actual suspend fun create(): SqlDriver =
+ NativeSqliteDriver(LedgerDatabase.Schema.synchronous(), "ledger.db")
+}
diff --git a/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt
new file mode 100644
index 0000000..f4a0f37
--- /dev/null
+++ b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt
@@ -0,0 +1,10 @@
+package app.folio.core.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/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt
new file mode 100644
index 0000000..5c3d21b
--- /dev/null
+++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt
@@ -0,0 +1,19 @@
+package app.folio.core.data
+
+import app.cash.sqldelight.async.coroutines.awaitCreate
+import app.cash.sqldelight.db.SqlDriver
+import app.cash.sqldelight.driver.worker.WebWorkerDriver
+import app.folio.db.LedgerDatabase
+import org.w3c.dom.Worker
+
+actual class DriverFactory {
+ actual suspend fun create(): SqlDriver {
+ val worker = Worker(workerScriptUrl())
+ val driver = WebWorkerDriver(worker)
+ LedgerDatabase.Schema.awaitCreate(driver)
+ return driver
+ }
+}
+
+private fun workerScriptUrl(): String =
+ js("""new URL("./sqlite.worker.js", import.meta.url).toString()""")
diff --git a/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt
new file mode 100644
index 0000000..76d3685
--- /dev/null
+++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt
@@ -0,0 +1,22 @@
+package app.folio.core.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/gradle/libs.versions.toml b/examples/folio/gradle/libs.versions.toml
index 53f06c5..74e0290 100644
--- a/examples/folio/gradle/libs.versions.toml
+++ b/examples/folio/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
+sqldelight-async-extensions = { module = "app.cash.sqldelight:async-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" }
diff --git a/examples/folio/settings.gradle.kts b/examples/folio/settings.gradle.kts
index 2f2b992..09485ac 100644
--- a/examples/folio/settings.gradle.kts
+++ b/examples/folio/settings.gradle.kts
@@ -15,6 +15,11 @@ dependencyResolutionManagement {
}
}
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
rootProject.name = "folio"
-include(":composeApp")
+include(":core")
+include(":app:shared")
+include(":app:ui-components")
+include(":app:androidApp")