From 5121f777f289c55af9d2b4bc9c7d47818de6e410 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 26 Apr 2026 14:30:59 +0700 Subject: [PATCH] refactor(folio): introduce nested KotlinConf-style modules Split the monolithic :composeApp into :core, :app:shared, :app:ui-components, and :app:androidApp. The old module is still present and remains the source of truth until the next commits remove it; both compile in parallel to keep iOS/Android builds green during the cut-over. Highlights: - :core - SQLDelight schema + LedgerStore + Repository (now an injectable class, not a singleton object). Methods are suspend to match generateAsync = true. - :app:ui-components - design system primitives. IconButton/AppButton APIs revised: label = real contentDescription, testTag = stable selector. Drops the data-carrier description argument. - :app:shared - per-screen ViewModels colocated with screens; pure composables on (state, onEvent); LocalAppComponent CompositionLocal for hand-rolled DI; @Serializable Route. Hosts the iOS framework (baseName Shared). - :app:androidApp - thin Android entry that constructs the DriverFactory and hands it to App(). - gradle/libs.versions.toml centralises versions; settings.gradle.kts enables type-safe project accessors. Deferred to follow-up PRs (per the design discussion): - Metro DI: hand-rolled AppComponent for now; Metro graphs are mostly ceremony for an app this size and add KSP/version risk. - Navigation3: kept the existing Navigator-as-backstack class, injected rather than singleton; nav3 isn't shipping a stable multiplatform artifact for commonMain consumption yet. - :app:webApp + OPFS sqlite worker: web persistence is real new wiring (custom worker on @sqlite.org/sqlite-wasm). Master's WebLedgerStore + Snapshot is being removed by this PR; web stays buildable as a klib but no app-level wasm binary lands here. --- .../folio/app/androidApp/build.gradle.kts | 44 +++++ .../androidApp/src/main/AndroidManifest.xml | 17 ++ .../src/main/kotlin/app/folio/MainActivity.kt | 14 ++ examples/folio/app/shared/build.gradle.kts | 62 ++++++ .../app/folio/ui/BackHandler.android.kt | 7 + .../kotlin/app/folio/util/Format.android.kt | 9 + .../src/commonMain/kotlin/app/folio/App.kt | 92 +++++++++ .../kotlin/app/folio/AppComponent.kt | 14 ++ .../folio/feature/account/AddAccountScreen.kt | 79 ++++++++ .../feature/account/AddAccountViewModel.kt | 59 ++++++ .../app/folio/feature/auth/LoginScreen.kt | 86 +++++++++ .../app/folio/feature/auth/LoginViewModel.kt | 63 +++++++ .../app/folio/feature/home/HomeScreen.kt | 164 ++++++++++++++++ .../app/folio/feature/home/HomeViewModel.kt | 54 ++++++ .../feature/ledger/AddTransactionScreen.kt | 120 ++++++++++++ .../feature/ledger/AddTransactionViewModel.kt | 88 +++++++++ .../app/folio/feature/ledger/LedgerScreen.kt | 176 ++++++++++++++++++ .../folio/feature/ledger/LedgerViewModel.kt | 52 ++++++ .../kotlin/app/folio/navigation/Navigator.kt | 31 +++ .../kotlin/app/folio/navigation/Route.kt | 12 ++ .../kotlin/app/folio/ui/BackHandler.kt | 6 + .../kotlin/app/folio/util/Format.kt | 73 ++++++++ .../kotlin/app/folio/MainViewController.kt | 39 ++++ .../kotlin/app/folio/ui/BackHandler.ios.kt | 15 ++ .../kotlin/app/folio/ui/IosBackGesture.kt | 22 +++ .../kotlin/app/folio/util/Format.ios.kt | 16 ++ .../kotlin/app/folio/ui/BackHandler.wasmJs.kt | 36 ++++ .../kotlin/app/folio/util/Format.wasmJs.kt | 27 +++ .../folio/app/ui-components/build.gradle.kts | 50 +++++ .../app/folio/ui/component/AppButton.kt | 53 ++++++ .../kotlin/app/folio/ui/component/Card.kt | 33 ++++ .../app/folio/ui/component/EmptyState.kt | 56 ++++++ .../app/folio/ui/component/ErrorText.kt | 21 +++ .../app/folio/ui/component/FieldLabel.kt | 12 ++ .../kotlin/app/folio/ui/component/Header.kt | 48 +++++ .../app/folio/ui/component/IconButton.kt | 54 ++++++ .../kotlin/app/folio/ui/component/Screen.kt | 54 ++++++ .../app/folio/ui/component/Segmented.kt | 62 ++++++ .../app/folio/ui/component/TextInput.kt | 89 +++++++++ .../kotlin/app/folio/ui/icon/Icons.kt | 44 +++++ .../kotlin/app/folio/ui/theme/Theme.kt | 45 +++++ .../kotlin/app/folio/ui/theme/Type.kt | 20 ++ examples/folio/build.gradle.kts | 15 +- examples/folio/core/build.gradle.kts | 67 +++++++ .../folio/core/data/DriverFactory.android.kt | 12 ++ .../folio/core/platform/Platform.android.kt | 8 + .../kotlin/app/folio/core/data/Account.kt | 7 + .../app/folio/core/data/DriverFactory.kt | 7 + .../kotlin/app/folio/core/data/LedgerStore.kt | 15 ++ .../kotlin/app/folio/core/data/Repository.kt | 40 ++++ .../kotlin/app/folio/core/data/Session.kt | 6 + .../app/folio/core/data/SqlLedgerStore.kt | 68 +++++++ .../kotlin/app/folio/core/data/Transaction.kt | 12 ++ .../app/folio/core/platform/Platform.kt | 6 + .../sqldelight/app/folio/db/Ledger.sq | 47 +++++ .../app/folio/core/data/DriverFactory.ios.kt | 11 ++ .../app/folio/core/platform/Platform.ios.kt | 10 + .../folio/core/data/DriverFactory.wasmJs.kt | 19 ++ .../folio/core/platform/Platform.wasmJs.kt | 22 +++ examples/folio/gradle/libs.versions.toml | 1 + examples/folio/settings.gradle.kts | 7 +- 61 files changed, 2492 insertions(+), 6 deletions(-) create mode 100644 examples/folio/app/androidApp/build.gradle.kts create mode 100644 examples/folio/app/androidApp/src/main/AndroidManifest.xml create mode 100644 examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt create mode 100644 examples/folio/app/shared/build.gradle.kts create mode 100644 examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt create mode 100644 examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/AppComponent.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Route.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt create mode 100644 examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt create mode 100644 examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt create mode 100644 examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt create mode 100644 examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt create mode 100644 examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt create mode 100644 examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt create mode 100644 examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt create mode 100644 examples/folio/app/ui-components/build.gradle.kts create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt create mode 100644 examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt create mode 100644 examples/folio/core/build.gradle.kts create mode 100644 examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt create mode 100644 examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/LedgerStore.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt create mode 100644 examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt create mode 100644 examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq create mode 100644 examples/folio/core/src/iosMain/kotlin/app/folio/core/data/DriverFactory.ios.kt create mode 100644 examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt create mode 100644 examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt create mode 100644 examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt 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")