diff --git a/examples/folio/.gitignore b/examples/folio/.gitignore index 827413a..9ac256b 100644 --- a/examples/folio/.gitignore +++ b/examples/folio/.gitignore @@ -2,9 +2,9 @@ build/ .kotlin/ local.properties -iosApp/build/ -iosApp/iosApp.xcodeproj/ -iosApp/iosApp.xcodeproj/** +app/iosApp/build/ +app/iosApp/iosApp.xcodeproj/ +app/iosApp/iosApp.xcodeproj/** .DS_Store sanderling/node_modules/ sanderling/runs/ diff --git a/examples/folio/README.md b/examples/folio/README.md index 968899c..c82204b 100644 --- a/examples/folio/README.md +++ b/examples/folio/README.md @@ -2,14 +2,15 @@ A minimal Kotlin Multiplatform personal-ledger app: login with demo credentials, create accounts, add credits and debits. Shared UI across -Android, iOS, and Web via Compose Multiplatform. Doubles as the example +Android and iOS via Compose Multiplatform. Doubles as the example sanderling runs its property-based specs against. ## Stack - Kotlin Multiplatform + Compose Multiplatform (shared UI) -- kotlinx.serialization for file-backed persistence +- SQLDelight for the data layer (unified across platforms) - kotlinx.coroutines for state flows +- kotlinx.serialization for `@Serializable` route types ## Prerequisites @@ -34,9 +35,9 @@ just ios # default device: iPhone 17 Pro IOS_DEVICE="iPhone 15" just ios # pick a different simulator ``` -`just ios` regenerates `iosApp/iosApp.xcodeproj` from `iosApp/project.yml`, -builds the KMP framework, links it into the SwiftUI host, installs, and -launches. +`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`, +builds the KMP framework (`Shared.framework` from `:app:shared`), links it +into the SwiftUI host, installs, and launches. ## Demo credentials @@ -69,9 +70,13 @@ Traces land in `./sanderling/runs//`. ## How it connects to sanderling -- UI elements expose state via content descriptions (`account:$id:$balance`, - `ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`, - `focused_input:$field`) +- Each screen sets a stable Compose `testTag` (`HomeScreen`, `AccountCard`, + `LedgerRow`, `TxnAmount`, ...). The Sanderling SDK resolves `testTag` to + `resource-id` on Android and `accessibilityIdentifier` on iOS. +- Identity for list items is the visible text content (account name; txn + note + amount). No synthetic IDs encoded in semantics. +- `contentDescription` is reserved for real accessibility labels, never as a + data carrier. - `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`, - asserts properties, and weights the actions the fuzzer picks from -- `just test` invokes `sanderling test` against the installed APK + asserts properties, and weights the actions the fuzzer picks from. +- `just test` invokes `sanderling test` against the installed APK. diff --git a/examples/folio/app/androidApp/build.gradle.kts b/examples/folio/app/androidApp/build.gradle.kts new file mode 100644 index 0000000..b854bb4 --- /dev/null +++ b/examples/folio/app/androidApp/build.gradle.kts @@ -0,0 +1,45 @@ +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) + alias(libs.plugins.metro) +} + +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/composeApp/src/androidMain/AndroidManifest.xml b/examples/folio/app/androidApp/src/main/AndroidManifest.xml similarity index 94% rename from examples/folio/composeApp/src/androidMain/AndroidManifest.xml rename to examples/folio/app/androidApp/src/main/AndroidManifest.xml index 748409f..c94068c 100644 --- a/examples/folio/composeApp/src/androidMain/AndroidManifest.xml +++ b/examples/folio/app/androidApp/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ 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..c5409ea --- /dev/null +++ b/examples/folio/app/androidApp/src/main/kotlin/app/folio/MainActivity.kt @@ -0,0 +1,20 @@ +package app.folio + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph +import dev.zacsweers.metro.createGraphFactory + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val driverFactory = DriverFactory(applicationContext) + val graphFactory = createGraphFactory() + setContent { + App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) }) + } + } +} diff --git a/examples/folio/iosApp/iosApp/Info.plist b/examples/folio/app/iosApp/iosApp/Info.plist similarity index 100% rename from examples/folio/iosApp/iosApp/Info.plist rename to examples/folio/app/iosApp/iosApp/Info.plist diff --git a/examples/folio/iosApp/iosApp/iOSApp.swift b/examples/folio/app/iosApp/iosApp/iOSApp.swift similarity index 96% rename from examples/folio/iosApp/iosApp/iOSApp.swift rename to examples/folio/app/iosApp/iosApp/iOSApp.swift index 09b4572..c4821c1 100644 --- a/examples/folio/iosApp/iosApp/iOSApp.swift +++ b/examples/folio/app/iosApp/iosApp/iOSApp.swift @@ -1,5 +1,5 @@ import SwiftUI -import ComposeApp +import Shared @main struct iOSApp: App { diff --git a/examples/folio/iosApp/project.yml b/examples/folio/app/iosApp/project.yml similarity index 69% rename from examples/folio/iosApp/project.yml rename to examples/folio/app/iosApp/project.yml index 5fcf8cd..10a632d 100644 --- a/examples/folio/iosApp/project.yml +++ b/examples/folio/app/iosApp/project.yml @@ -19,7 +19,7 @@ targets: sources: - iosApp dependencies: - - framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework + - framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework embed: false - sdk: libsqlite3.tbd settings: @@ -28,16 +28,16 @@ targets: PRODUCT_NAME: iosApp INFOPLIST_FILE: iosApp/Info.plist FRAMEWORK_SEARCH_PATHS: - - $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework + - $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework OTHER_LDFLAGS: - -ObjC KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64 preBuildScripts: - name: Build Kotlin framework script: | - cd "$SRCROOT/.." + cd "$SRCROOT/../.." ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \ - ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 + ./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64 outputFiles: - - $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp + - $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared basedOnDependencyAnalysis: false diff --git a/examples/folio/app/shared/build.gradle.kts b/examples/folio/app/shared/build.gradle.kts new file mode 100644 index 0000000..9dd07b2 --- /dev/null +++ b/examples/folio/app/shared/build.gradle.kts @@ -0,0 +1,66 @@ +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) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + 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) + implementation(libs.navigation.compose) + } + androidMain.dependencies { + implementation(libs.androidx.activity.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/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt similarity index 100% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt rename to examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/BackHandler.android.kt diff --git a/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt new file mode 100644 index 0000000..5896d35 --- /dev/null +++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/ui/TestTagBridge.android.kt @@ -0,0 +1,10 @@ +package app.folio.ui + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId + +@OptIn(ExperimentalComposeUiApi::class) +actual fun Modifier.testTagsAsResourceId(): Modifier = + semantics { testTagsAsResourceId = true } diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt similarity index 89% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt rename to examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt index ede6eed..340f8bf 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Format.android.kt +++ b/examples/folio/app/shared/src/androidMain/kotlin/app/folio/util/Format.android.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util import java.text.SimpleDateFormat import java.util.Date 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..6ab54a9 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/App.kt @@ -0,0 +1,103 @@ +package app.folio + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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 androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.folio.di.AppGraph +import app.folio.di.LocalAppGraph +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.Route +import app.folio.ui.testTagsAsResourceId +import app.folio.ui.theme.LedgerTheme +import app.folio.ui.theme.LocalTokens +import app.folio.ui.theme.Tokens + +@Composable +fun App(graphBuilder: suspend () -> AppGraph) { + var graph by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { graph = graphBuilder() } + + LedgerTheme { + val t = Tokens() + CompositionLocalProvider(LocalTokens provides t) { + Box( + Modifier + .fillMaxSize() + .background(t.bg) + .windowInsetsPadding(WindowInsets.safeDrawing) + .testTagsAsResourceId(), + contentAlignment = Alignment.Center, + ) { + val g = graph + if (g != null) { + CompositionLocalProvider(LocalAppGraph provides g) { + AppContent() + } + } + } + } + } +} + +@Composable +private fun AppContent() { + val graph = LocalAppGraph.current + val navController = rememberNavController() + + LaunchedEffect(navController) { graph.navigator.attach(navController) } + + val initialSession = remember { graph.repository.session.value } + val startDestination: Route = if (initialSession != null) Route.Home else Route.Login + + val session by graph.repository.session.collectAsState() + val currentEntry by navController.currentBackStackEntryAsState() + val onLogin = currentEntry?.destination?.hasRoute(Route.Login::class) == true + + LaunchedEffect(session, onLogin) { + if (session == null && !onLogin) { + graph.navigator.replace(Route.Login) + } else if (session != null && onLogin) { + graph.navigator.replace(Route.Home) + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + ) { + composable { LoginRoute() } + composable { HomeRoute() } + composable { AddAccountRoute() } + composable { entry -> + LedgerRoute(accountId = entry.toRoute().accountId) + } + composable { entry -> + AddTransactionRoute(accountId = entry.toRoute().accountId) + } + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt new file mode 100644 index 0000000..8de68f2 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/AppGraph.kt @@ -0,0 +1,41 @@ +package app.folio.di + +import app.folio.core.data.LedgerStore +import app.folio.core.data.Repository +import app.folio.core.data.SqlLedgerStore +import app.folio.db.LedgerDatabase +import app.folio.feature.account.AddAccountViewModel +import app.folio.feature.auth.LoginViewModel +import app.folio.feature.home.HomeViewModel +import app.folio.feature.ledger.AddTransactionViewModel +import app.folio.feature.ledger.LedgerViewModel +import app.folio.navigation.Navigator +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn + +@SingleIn(AppScope::class) +@DependencyGraph(AppScope::class) +interface AppGraph { + val repository: Repository + val navigator: Navigator + + val loginViewModel: LoginViewModel + val homeViewModel: HomeViewModel + val addAccountViewModel: AddAccountViewModel + val ledgerViewModelFactory: LedgerViewModel.Factory + val addTransactionViewModelFactory: AddTransactionViewModel.Factory + + @Binds val SqlLedgerStore.bindLedgerStore: LedgerStore + + @SingleIn(AppScope::class) + @Provides + fun provideNavigator(): Navigator = Navigator() + + @DependencyGraph.Factory + fun interface Factory { + fun create(@Provides database: LedgerDatabase): AppGraph + } +} diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt new file mode 100644 index 0000000..a40236e --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/di/LocalAppGraph.kt @@ -0,0 +1,7 @@ +package app.folio.di + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalAppGraph = staticCompositionLocalOf { + error("AppGraph not provided. Wrap content in CompositionLocalProvider(LocalAppGraph provides ...).") +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt similarity index 50% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt index f4d017b..5fe7ddb 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt @@ -6,20 +6,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph import app.folio.ui.BackHandler import app.folio.ui.component.AppButton import app.folio.ui.component.BackButton @@ -33,46 +26,30 @@ import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.Type @Composable -fun AddAccountScreen() { +fun AddAccountRoute() { + val graph = LocalAppGraph.current + val vm: AddAccountViewModel = viewModel { graph.addAccountViewModel } + val state by vm.state.collectAsState() + AddAccountScreen(state = state, onEvent = vm::onEvent) +} + +@Composable +fun AddAccountScreen(state: AddAccountUiState, onEvent: (AddAccountEvent) -> Unit) { val t = LocalTokens.current - var name by remember { mutableStateOf("") } - val err by AddAccountUiState.addAccountError.collectAsState() - - BackHandler { Navigator.back(Route.Home) } - - DisposableEffect(Unit) { - onDispose { AddAccountUiState.addAccountError.value = "" } - } - - fun submit() { - val trimmed = name.trim() - if (trimmed.isEmpty()) { - AddAccountUiState.addAccountError.value = "Account name is required"; return - } - if (trimmed.length > 40) { - AddAccountUiState.addAccountError.value = "Name is too long (max 40 characters)"; return - } - try { - Repository.createAccount(trimmed) - AddAccountUiState.addAccountError.value = "" - Navigator.replace(Route.Home) - } catch (e: IllegalArgumentException) { - AddAccountUiState.addAccountError.value = e.message ?: "Could not create account" - } - } + BackHandler { onEvent(AddAccountEvent.Back) } Screen( - modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" }, + modifier = Modifier.testTag("AddAccountScreen"), header = { - Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) }) + Header(title = "New account", left = { BackButton(onClick = { onEvent(AddAccountEvent.Back) }) }) }, footer = { AppButton( text = "Create account", - onClick = ::submit, + onClick = { onEvent(AddAccountEvent.Submit) }, style = ButtonStyle.Primary, - enabled = name.trim().isNotEmpty(), - description = "add_account_submit", + enabled = state.name.trim().isNotEmpty(), + testTag = "AddAccountSubmit", ) }, ) { @@ -83,15 +60,15 @@ fun AddAccountScreen() { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Account name") TextInput( - value = name, - onChange = { name = it; AddAccountUiState.addAccountError.value = "" }, + value = state.name, + onChange = { onEvent(AddAccountEvent.NameChange(it)) }, placeholder = "e.g. Checking", - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), label = "Account name", - description = "account_name_field", + testTag = "AccountNameField", ) } - ErrorText(err) + ErrorText(state.error) Text( "Use a short, recognizable name. You can create as many accounts as you need.", style = Type.caption, 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..0262050 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/account/AddAccountViewModel.kt @@ -0,0 +1,61 @@ +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 dev.zacsweers.metro.Inject +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 +} + +@Inject +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/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt similarity index 55% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt index be1149c..e22fc98 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt @@ -8,19 +8,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import app.folio.data.Repository +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph import app.folio.ui.component.AppButton import app.folio.ui.component.ButtonStyle import app.folio.ui.component.Card @@ -31,38 +26,18 @@ import app.folio.ui.component.TextInput import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.Type -const val DEMO_EMAIL = "demo@folio.app" -const val DEMO_PASSWORD = "ledger123" - -private fun checkCredentials(email: String, password: String): Boolean { - return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD +@Composable +fun LoginRoute() { + val graph = LocalAppGraph.current + val vm: LoginViewModel = viewModel { graph.loginViewModel } + val state by vm.state.collectAsState() + LoginScreen(state = state, onEvent = vm::onEvent) } @Composable -fun LoginScreen(onLoggedIn: (String) -> Unit) { +fun LoginScreen(state: LoginUiState, onEvent: (LoginEvent) -> Unit) { val t = LocalTokens.current - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - val err by LoginUiState.loginError.collectAsState() - - DisposableEffect(Unit) { - onDispose { LoginUiState.loginError.value = "" } - } - - fun submit() { - if (email.isBlank() || password.isEmpty()) { - LoginUiState.loginError.value = "Enter email and password"; return - } - if (!checkCredentials(email, password)) { - LoginUiState.loginError.value = "Invalid email or password"; return - } - LoginUiState.loginError.value = "" - val user = email.trim().lowercase() - Repository.setSession(user) - onLoggedIn(user) - } - - Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) { + Screen(modifier = Modifier.testTag("LoginScreen")) { Spacer(Modifier.height(16.dp)) Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), @@ -71,34 +46,34 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Email") TextInput( - value = email, - onChange = { email = it; LoginUiState.loginError.value = "" }, + value = state.email, + onChange = { onEvent(LoginEvent.EmailChange(it)) }, placeholder = DEMO_EMAIL, - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), keyboardType = KeyboardType.Email, label = "Email", - description = "login_email", + testTag = "LoginEmail", ) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Password") TextInput( - value = password, - onChange = { password = it; LoginUiState.loginError.value = "" }, + value = state.password, + onChange = { onEvent(LoginEvent.PasswordChange(it)) }, placeholder = "••••••••", password = true, - invalid = err.isNotEmpty(), + invalid = state.error.isNotEmpty(), keyboardType = KeyboardType.Password, label = "Password", - description = "login_password", + testTag = "LoginPassword", ) } - ErrorText(err) + ErrorText(state.error) AppButton( text = "Sign in", - onClick = ::submit, + onClick = { onEvent(LoginEvent.Submit) }, style = ButtonStyle.Primary, - description = "login_submit", + testTag = "LoginSubmit", ) Spacer(Modifier.height(4.dp)) Card(dashed = true) { 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..bcfd389 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/auth/LoginViewModel.kt @@ -0,0 +1,65 @@ +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 dev.zacsweers.metro.Inject +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 +} + +@Inject +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/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt similarity index 72% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt index ce18006..29bae99 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt @@ -13,24 +13,21 @@ 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.platform.testTag import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.runtime.collectAsState -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.initialsOf -import app.folio.platform.signedAmount +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.di.LocalAppGraph +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 @@ -43,20 +40,31 @@ import app.folio.ui.theme.RadiusLg import app.folio.ui.theme.Type @Composable -fun HomeScreen(user: String, onLogout: () -> Unit) { +fun HomeRoute() { + val graph = LocalAppGraph.current + val vm: HomeViewModel = viewModel { graph.homeViewModel } + 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 accounts by Repository.accounts.collectAsState() - val txns by Repository.transactions.collectAsState() - val total = balanceOf(txns) + val total = balanceOf(state.transactions) Screen( - modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" }, + modifier = Modifier.testTag("HomeScreen"), header = { Header( title = "Accounts", - subtitle = user, + subtitle = state.user, right = { - IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout) + IconButton( + onClick = { onEvent(HomeEvent.Logout) }, + label = "Log out", + icon = Icons.Logout, + testTag = "LogoutButton", + ) }, ) }, @@ -68,20 +76,20 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { } Box(Modifier.weight(1f)) Text( - "${accounts.size} account${if (accounts.size == 1) "" else "s"}", + "${state.accounts.size} account${if (state.accounts.size == 1) "" else "s"}", style = Type.caption, color = t.textFaint, ) } AppButton( text = "+ Add account", - onClick = { Navigator.push(Route.AddAccount) }, + onClick = { onEvent(HomeEvent.AddAccount) }, style = ButtonStyle.Primary, - description = "add_account_button", + testTag = "AddAccountButton", ) }, ) { - if (accounts.isEmpty()) { + if (state.accounts.isEmpty()) { EmptyState( title = "No accounts yet", subtitle = "Create your first account to start tracking transactions.", @@ -89,16 +97,15 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { ) } else { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - accounts.forEach { a -> - val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) } - val count = txns.count { it.accountId == a.id } + 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( - id = a.id, name = a.name, initials = initialsOf(a.name), count = count, balance = bal, - onClick = { Navigator.push(Route.Ledger(a.id)) }, + onClick = { onEvent(HomeEvent.OpenAccount(a.id)) }, ) } } @@ -108,7 +115,6 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { @Composable private fun AccountCard( - id: String, name: String, initials: String, count: Int, @@ -123,7 +129,7 @@ private fun AccountCard( .clip(RoundedCornerShape(RadiusLg)) .background(t.surface) .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) - .semantics { contentDescription = "account_card:$id" } + .testTag("AccountCard") .clickable(role = Role.Button, onClick = onClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -145,6 +151,7 @@ private fun AccountCard( color = t.text, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("AccountName"), ) Text(txnLabel, style = Type.caption, color = t.textMuted) } @@ -152,7 +159,7 @@ private fun AccountCard( formatCents(balance), style = Type.bodyStrong, color = t.text, - modifier = Modifier.semantics { contentDescription = "account_balance" }, + 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..c41d0a8 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/home/HomeViewModel.kt @@ -0,0 +1,56 @@ +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 dev.zacsweers.metro.Inject +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 +} + +@Inject +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..e319cec --- /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.core.data.TxnType +import app.folio.di.LocalAppGraph +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 graph = LocalAppGraph.current + val vm: AddTransactionViewModel = viewModel(key = "addTxn:$accountId") { + graph.addTransactionViewModelFactory.create(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..60bc0aa --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionViewModel.kt @@ -0,0 +1,97 @@ +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +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 +} + +@AssistedInject +class AddTransactionViewModel( + private val repository: Repository, + private val navigator: Navigator, + @Assisted 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") } + } + } + } + + @AssistedFactory + fun interface Factory { + fun create(accountId: String): AddTransactionViewModel + } +} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt similarity index 72% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt index f7f60df..1897d6e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt @@ -13,24 +13,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.data.TxnType -import app.folio.platform.balanceOf -import app.folio.platform.formatCents -import app.folio.platform.formatDate -import app.folio.platform.signedAmount +import androidx.lifecycle.viewmodel.compose.viewModel +import app.folio.core.data.TxnType +import app.folio.di.LocalAppGraph +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 @@ -45,50 +41,55 @@ import app.folio.ui.theme.RadiusLg import app.folio.ui.theme.Type @Composable -fun LedgerScreen(accountId: String) { +fun LedgerRoute(accountId: String) { + val graph = LocalAppGraph.current + val vm: LedgerViewModel = viewModel(key = "ledger:$accountId") { + graph.ledgerViewModelFactory.create(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 - val accounts by Repository.accounts.collectAsState() - val allTxns by Repository.transactions.collectAsState() - BackHandler { Navigator.back(Route.Home) } + BackHandler { onEvent(LedgerEvent.Back) } - val account = accounts.firstOrNull { it.id == accountId } + val account = state.account if (account == null) { Screen( header = { - Header(title = "Account", left = { BackButton { Navigator.back(Route.Home) } }) + 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 = { Navigator.replace(Route.Home) }, + onClick = { onEvent(LedgerEvent.BackToHome) }, style = ButtonStyle.Secondary, ) } return } - val txns = allTxns - .filter { it.accountId == accountId } - .sortedByDescending { it.createdAt } - val balance = balanceOf(txns) + val balance = balanceOf(state.transactions) Screen( - modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" }, + modifier = Modifier.testTag("LedgerScreen"), header = { Header( title = account.name, subtitle = "Ledger", - left = { BackButton { Navigator.back(Route.Home) } }, + left = { BackButton { onEvent(LedgerEvent.Back) } }, ) }, footer = { AppButton( text = "+ Add transaction", - onClick = { Navigator.push(Route.AddTransaction(accountId)) }, + onClick = { onEvent(LedgerEvent.AddTransaction) }, style = ButtonStyle.Primary, - description = "add_txn_button", + testTag = "AddTransactionButton", ) }, ) { @@ -98,7 +99,7 @@ fun LedgerScreen(accountId: String) { formatCents(balance), style = Type.balance, color = t.text, - modifier = Modifier.semantics { contentDescription = "ledger_balance_display" }, + modifier = Modifier.testTag("LedgerBalance"), ) } Text( @@ -107,7 +108,7 @@ fun LedgerScreen(accountId: String) { color = t.textFaint, modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp), ) - if (txns.isEmpty()) { + if (state.transactions.isEmpty()) { EmptyState( title = "No transactions yet", subtitle = "Add your first credit or debit to see it here.", @@ -122,9 +123,9 @@ fun LedgerScreen(accountId: String) { .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) .padding(horizontal = 16.dp), ) { - txns.forEachIndexed { i, txn -> - TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) - if (i != txns.lastIndex) { + 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)) } } @@ -134,14 +135,14 @@ fun LedgerScreen(accountId: String) { } @Composable -private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: String) { +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) - .semantics { contentDescription = "ledger_row:$id" }, + .testTag("LedgerRow"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -162,14 +163,15 @@ private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: color = t.text, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("TxnNote"), ) - Text(date, style = Type.caption, color = t.textFaint) + Text(date, style = Type.caption, color = t.textFaint, modifier = Modifier.testTag("TxnDate")) } Text( formatCents(signed, signed = true), style = Type.bodyStrong, color = t.text, - modifier = Modifier.semantics { contentDescription = "txn_amount" }, + 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..e5c9bae --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/feature/ledger/LedgerViewModel.kt @@ -0,0 +1,61 @@ +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +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 +} + +@AssistedInject +class LedgerViewModel( + private val repository: Repository, + private val navigator: Navigator, + @Assisted 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) + } + } + + @AssistedFactory + fun interface Factory { + fun create(accountId: String): LedgerViewModel + } +} 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..9d218da --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/navigation/Navigator.kt @@ -0,0 +1,32 @@ +package app.folio.navigation + +import androidx.navigation.NavHostController + +class Navigator { + private var controller: NavHostController? = null + + fun attach(controller: NavHostController) { + this.controller = controller + } + + fun push(route: Route) { + controller?.navigate(route) { + launchSingleTop = true + } + } + + fun replace(route: Route) { + val nav = controller ?: return + nav.navigate(route) { + popUpTo(nav.graph.id) { inclusive = true } + launchSingleTop = true + } + } + + fun back(fallback: Route) { + val nav = controller ?: return + if (!nav.popBackStack()) { + 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/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/BackHandler.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/BackHandler.kt diff --git a/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt new file mode 100644 index 0000000..1da4fa5 --- /dev/null +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/ui/TestTagBridge.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +expect fun Modifier.testTagsAsResourceId(): Modifier diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt similarity index 95% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt rename to examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt index efbb4d1..d40c9d4 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Format.kt +++ b/examples/folio/app/shared/src/commonMain/kotlin/app/folio/util/Format.kt @@ -1,7 +1,7 @@ -package app.folio.platform +package app.folio.util -import app.folio.data.Transaction -import app.folio.data.TxnType +import app.folio.core.data.Transaction +import app.folio.core.data.TxnType import kotlin.math.absoluteValue private const val CURRENCY = "$" diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt similarity index 75% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt index 04a86e2..0f3cbeb 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/MainViewController.kt @@ -1,9 +1,11 @@ package app.folio import androidx.compose.ui.window.ComposeUIViewController -import app.folio.data.Repository +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph import app.folio.ui.IosBackGesture -import kotlin.native.ObjCName +import dev.zacsweers.metro.createGraphFactory import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ObjCAction @@ -28,13 +30,11 @@ private val backGestureTarget = BackGestureTarget() @OptIn(ExperimentalForeignApi::class) fun MainViewController(): UIViewController { - Repository.init() - app.folio.sanderling.AuthSnapshots - app.folio.sanderling.AccountSnapshots - app.folio.sanderling.LedgerSnapshots - app.folio.sanderling.NavigationSnapshots - app.folio.sanderling.SanderlingIos.start() - val vc = ComposeUIViewController { App() } + val driverFactory = DriverFactory() + val graphFactory = createGraphFactory() + val vc = ComposeUIViewController { + App(graphBuilder = { graphFactory.create(LedgerDatabase(driverFactory.create())) }) + } val gesture = UIScreenEdgePanGestureRecognizer( target = backGestureTarget, action = NSSelectorFromString("handleGesture:"), diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt similarity index 100% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt index bb3dcab..aae594d 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/BackHandler.ios.kt @@ -2,8 +2,8 @@ package app.folio.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState @Composable actual fun BackHandler(onBack: () -> Unit) { diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt similarity index 100% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/IosBackGesture.kt diff --git a/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt new file mode 100644 index 0000000..b0f567e --- /dev/null +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/ui/TestTagBridge.ios.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.testTagsAsResourceId(): Modifier = this diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt similarity index 94% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt rename to examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt index c78fc7b..fe6edcd 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Format.ios.kt +++ b/examples/folio/app/shared/src/iosMain/kotlin/app/folio/util/Format.ios.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt similarity index 100% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt rename to examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/BackHandler.wasmJs.kt diff --git a/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt new file mode 100644 index 0000000..b0f567e --- /dev/null +++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/ui/TestTagBridge.wasmJs.kt @@ -0,0 +1,5 @@ +package app.folio.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.testTagsAsResourceId(): Modifier = this diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt similarity index 97% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt rename to examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt index bc99afc..100fa81 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Format.wasmJs.kt +++ b/examples/folio/app/shared/src/wasmJsMain/kotlin/app/folio/util/Format.wasmJs.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.util private val MONTHS = arrayOf( "Jan", "Feb", "Mar", "Apr", "May", "Jun", 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..558224e --- /dev/null +++ b/examples/folio/app/ui-components/build.gradle.kts @@ -0,0 +1,49 @@ +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") + } + + 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/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt index d6012e4..0469542 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/AppButton.kt @@ -13,9 +13,8 @@ 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.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.RadiusMd @@ -30,7 +29,7 @@ fun AppButton( modifier: Modifier = Modifier, style: ButtonStyle = ButtonStyle.Secondary, enabled: Boolean = true, - description: String? = null, + testTag: String? = null, ) { val t = LocalTokens.current val (bg, fg, border) = when (style) { @@ -44,10 +43,7 @@ fun AppButton( .clip(RoundedCornerShape(RadiusMd)) .background(if (enabled) bg else t.surface3) .border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd)) - .then( - if (description != null) Modifier.semantics { contentDescription = description } - else Modifier - ) + .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, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Card.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Card.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/EmptyState.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/ErrorText.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/FieldLabel.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Header.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Header.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt similarity index 82% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt index 81624e7..dfc9c79 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/IconButton.kt @@ -13,6 +13,7 @@ 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 @@ -22,8 +23,9 @@ import app.folio.ui.theme.LocalTokens @Composable fun IconButton( onClick: () -> Unit, - description: String, + label: String, icon: ImageVector, + testTag: String? = null, ) { val t = LocalTokens.current Box( @@ -33,7 +35,8 @@ fun IconButton( .background(t.surface2) .border(1.dp, t.border, RoundedCornerShape(10.dp)) .clickable(onClick = onClick) - .semantics { contentDescription = description }, + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) + .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { Icon( @@ -47,5 +50,5 @@ fun IconButton( @Composable fun BackButton(onClick: () -> Unit) { - IconButton(onClick = onClick, description = "Back", icon = Icons.Back) + IconButton(onClick = onClick, label = "Back", icon = Icons.Back, testTag = "BackButton") } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Screen.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt index 89a1ea3..7625279 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/Segmented.kt @@ -14,8 +14,8 @@ 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.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -29,7 +29,7 @@ fun Segmented( selected: Int, labels: List, onSelect: (Int) -> Unit, - descriptions: List? = null, + testTags: List? = null, ) { val t = LocalTokens.current Row( @@ -43,16 +43,14 @@ fun Segmented( ) { labels.forEachIndexed { i, label -> val active = i == selected - val desc = descriptions?.getOrNull(i) + val tag = testTags?.getOrNull(i) Box( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(RadiusSm)) .background(if (active) t.surface3 else t.surface) - .semantics { - this.selected = active - if (desc != null) contentDescription = desc - } + .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, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt similarity index 86% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt index a2ecdd3..eb595eb 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt +++ b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/component/TextInput.kt @@ -20,6 +20,7 @@ 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 @@ -29,7 +30,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import app.folio.FocusTracker import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.RadiusMd import app.folio.ui.theme.Type @@ -44,8 +44,8 @@ fun TextInput( keyboardType: KeyboardType = KeyboardType.Text, textAlign: TextAlign = TextAlign.Start, textStyle: TextStyle = Type.body, - label: String? = null, - description: String? = null, + label: String, + testTag: String? = null, modifier: Modifier = Modifier, ) { val t = LocalTokens.current @@ -78,16 +78,10 @@ fun TextInput( cursorBrush = SolidColor(t.text), modifier = Modifier .fillMaxWidth() - .onFocusChanged { - focused = it.isFocused - if (description != null) { - if (it.isFocused) FocusTracker.enter(description) - else FocusTracker.leave(description) - } - } + .onFocusChanged { focused = it.isFocused } + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier) .semantics { - val desc = description ?: label - if (desc != null) contentDescription = desc + contentDescription = label if (invalid) stateDescription = "Invalid" }, ) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/icon/Icons.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Theme.kt diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt b/examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt similarity index 100% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/theme/Type.kt rename to examples/folio/app/ui-components/src/commonMain/kotlin/app/folio/ui/theme/Type.kt diff --git a/examples/folio/app/webApp/build.gradle.kts b/examples/folio/app/webApp/build.gradle.kts new file mode 100644 index 0000000..edef4a6 --- /dev/null +++ b/examples/folio/app/webApp/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.plugin.compose) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + outputModuleName.set("composeApp") + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + } + } + binaries.executable() + } + + sourceSets { + wasmJsMain.dependencies { + implementation(projects.app.shared) + implementation(projects.core) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.kotlinx.browser) + implementation(npm("@sqlite.org/sqlite-wasm", libs.versions.sqlite.wasm.get())) + } + } +} diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt b/examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt similarity index 53% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt rename to examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt index baaeb83..54cdadc 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/main.kt +++ b/examples/folio/app/webApp/src/wasmJsMain/kotlin/app/folio/main.kt @@ -1,22 +1,33 @@ +@file:OptIn(ExperimentalComposeUiApi::class, kotlin.js.ExperimentalWasmJsInterop::class) + package app.folio import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -import app.folio.data.Repository +import app.folio.core.data.DriverFactory +import app.folio.db.LedgerDatabase +import app.folio.di.AppGraph import app.folio.ui.WebBackGesture +import dev.zacsweers.metro.createGraphFactory import kotlinx.browser.document import kotlinx.browser.window import org.w3c.dom.events.Event -@OptIn(ExperimentalComposeUiApi::class) fun main() { - Repository.init() + val driverFactory = DriverFactory() + val graphFactory = createGraphFactory() + window.history.pushState(null, "", window.location.href) window.addEventListener("popstate", { _: Event -> if (WebBackGesture.dispatch()) { window.history.pushState(null, "", window.location.href) } }) + val target = document.getElementById("app") ?: document.body!! - ComposeViewport(target) { App() } + ComposeViewport(target) { + App(graphBuilder = { + graphFactory.create(LedgerDatabase(driverFactory.create())) + }) + } } diff --git a/examples/folio/app/webApp/src/wasmJsMain/resources/index.html b/examples/folio/app/webApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..6927ef9 --- /dev/null +++ b/examples/folio/app/webApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,16 @@ + + + + + + Folio + + + +
+ + + diff --git a/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js b/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js new file mode 100644 index 0000000..0fc0c69 --- /dev/null +++ b/examples/folio/app/webApp/src/wasmJsMain/resources/sqlite.worker.js @@ -0,0 +1,62 @@ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let db = null; + +async function init() { + const sqlite3 = await sqlite3InitModule({ + print: (msg) => console.log('[sqlite]', msg), + printErr: (msg) => console.error('[sqlite]', msg), + }); + + const pool = await sqlite3.installOpfsSAHPoolVfs({ + directory: '/folio-sqlite-pool', + clearOnInit: false, + initialCapacity: 6, + }); + db = new pool.OpfsSAHPoolDb('/folio.sqlite3'); + console.log('[sqlite] using OPFS SAH pool'); +} + +function execQuery(sql, params) { + const rows = db.exec({ + sql, + bind: params, + returnValue: 'resultRows', + }); + return { values: rows }; +} + +function dispatch(data) { + switch (data && data.action) { + case 'exec': + if (!data.sql) throw new Error('exec: missing sql'); + return execQuery(data.sql, data.params); + case 'begin_transaction': + db.exec('BEGIN TRANSACTION;'); + return { values: [] }; + case 'end_transaction': + db.exec('COMMIT;'); + return { values: [] }; + case 'rollback_transaction': + db.exec('ROLLBACK;'); + return { values: [] }; + default: + throw new Error('Unsupported action: ' + (data && data.action)); + } +} + +const ready = init(); + +self.onmessage = (event) => { + const data = event.data; + ready + .then(() => { + const results = dispatch(data); + self.postMessage({ id: data.id, results }); + }) + .catch((err) => { + const message = err && err.message ? String(err.message) : String(err); + console.error('[sqlite worker] error', message, err); + self.postMessage({ id: data && data.id, error: { message, name: err && err.name } }); + }); +}; diff --git a/examples/folio/app/webApp/webpack.config.d/coopcoep.js b/examples/folio/app/webApp/webpack.config.d/coopcoep.js new file mode 100644 index 0000000..759cf0b --- /dev/null +++ b/examples/folio/app/webApp/webpack.config.d/coopcoep.js @@ -0,0 +1,10 @@ +config.devServer = { + ...(config.devServer || {}), + port: process.env.WEBAPP_PORT ? Number(process.env.WEBAPP_PORT) : 8088, + headers: { + ...((config.devServer && config.devServer.headers) || {}), + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'cross-origin', + }, +}; diff --git a/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js b/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js new file mode 100644 index 0000000..412eaac --- /dev/null +++ b/examples/folio/app/webApp/webpack.config.d/sqlite-wasm.js @@ -0,0 +1,9 @@ +// Webpack picks up `new Worker(new URL("./sqlite.worker.js", import.meta.url), +// { type: "module" })` from the compiled Kotlin/wasm output and bundles the +// worker. The bundled worker imports `@sqlite.org/sqlite-wasm`, which in turn +// loads sqlite3.wasm via `new URL("sqlite3.wasm", import.meta.url)`. Enabling +// asyncWebAssembly lets webpack process that .wasm reference correctly. +config.experiments = { + ...(config.experiments || {}), + asyncWebAssembly: true, +}; diff --git a/examples/folio/build.gradle.kts b/examples/folio/build.gradle.kts index d3137ca..6f1b1cf 100644 --- a/examples/folio/build.gradle.kts +++ b/examples/folio/build.gradle.kts @@ -1,7 +1,11 @@ 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.metro) apply false } diff --git a/examples/folio/composeApp/build.gradle.kts b/examples/folio/composeApp/build.gradle.kts deleted file mode 100644 index 286730d..0000000 --- a/examples/folio/composeApp/build.gradle.kts +++ /dev/null @@ -1,129 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - id("com.android.application") - kotlin("multiplatform") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.compose") - id("app.cash.sqldelight") -} - -val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev" -val sqldelightVersion = "2.3.2" - -kotlin { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - optIn.add("kotlin.js.ExperimentalWasmJsInterop") - } - - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { target -> - target.binaries.framework { - baseName = "ComposeApp" - isStatic = true - binaryOption("bundleId", "app.folio") - } - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - outputModuleName.set("composeApp") - browser { - commonWebpackConfig { - outputFileName = "composeApp.js" - } - } - binaries.executable() - } - - applyHierarchyTemplate { - common { - group("sql") { - withAndroidTarget() - group("apple") { - group("ios") { - withIosX64() - withIosArm64() - withIosSimulatorArm64() - } - } - } - group("wasmJs") { - withWasmJs() - } - } - } - - sourceSets { - commonMain.dependencies { - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("app.cash.sqldelight:runtime:$sqldelightVersion") - implementation("app.cash.sqldelight:coroutines-extensions:$sqldelightVersion") - } - - androidMain.dependencies { - implementation("androidx.activity:activity-compose:1.13.0") - implementation("app.cash.sqldelight:android-driver:$sqldelightVersion") - } - - iosMain.dependencies { - implementation("app.cash.sqldelight:native-driver:$sqldelightVersion") - } - } -} - -android { - namespace = "app.folio" - compileSdk = 36 - - defaultConfig { - applicationId = "app.folio" - minSdk = 24 - targetSdk = 36 - versionCode = 1 - versionName = sanderlingVersion - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - buildTypes { - debug { - isDebuggable = true - } - } - - sourceSets["main"].apply { - manifest.srcFile("src/androidMain/AndroidManifest.xml") - res.srcDirs("src/androidMain/res") - } -} - -sqldelight { - databases { - create("LedgerDatabase") { - packageName.set("app.folio.db") - } - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt deleted file mode 100644 index f5bf413..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/FolioApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.folio - -import android.app.Application -import app.folio.data.AndroidLedgerContext -import app.folio.data.Repository - -class FolioApplication : Application() { - override fun onCreate() { - super.onCreate() - AndroidLedgerContext.context = applicationContext - Repository.init() - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt deleted file mode 100644 index e7b0fb6..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/MainActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package app.folio - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { App() } - } -} diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt b/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt deleted file mode 100644 index 84c706f..0000000 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/LedgerStore.android.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import android.content.Context - -object AndroidLedgerContext { - lateinit var context: Context -} - -actual fun createLedgerStore(): LedgerStore = - SqlLedgerStore(DriverFactory(AndroidLedgerContext.context)) diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt deleted file mode 100644 index 19b8270..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/App.kt +++ /dev/null @@ -1,77 +0,0 @@ -package app.folio - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.feature.account.AddAccountScreen -import app.folio.feature.auth.LoginScreen -import app.folio.feature.home.HomeScreen -import app.folio.feature.ledger.AddTransactionScreen -import app.folio.feature.ledger.LedgerScreen -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.FocusTracker -import app.folio.ui.theme.LedgerTheme -import app.folio.ui.theme.LocalTokens -import app.folio.ui.theme.Tokens - -@Composable -fun App() { - val session by Repository.session.collectAsState() - val route by Navigator.current.collectAsState() - val focusedInput by FocusTracker.current.collectAsState() - - LaunchedEffect(session, route) { - if (session == null && route !is Route.Login) { - Navigator.replace(Route.Login) - } else if (session != null && route is Route.Login) { - Navigator.replace(Route.Home) - } - } - - LedgerTheme { - val t = Tokens() - CompositionLocalProvider(LocalTokens provides t) { - Box( - Modifier - .fillMaxSize() - .background(t.bg) - .windowInsetsPadding(WindowInsets.safeDrawing), - ) { - if (focusedInput != null) { - Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" }) - } - Column(Modifier.fillMaxSize()) { - when (val r = route) { - Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) }) - Route.Home -> HomeScreen( - user = session?.user ?: "", - onLogout = { - Repository.clearSession() - Navigator.replace(Route.Login) - }, - ) - Route.AddAccount -> AddAccountScreen() - is Route.Ledger -> LedgerScreen(accountId = r.accountId) - is Route.AddTransaction -> AddTransactionScreen(accountId = r.accountId) - } - } - } - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt deleted file mode 100644 index b0e464a..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/FocusTracker.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.folio - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -// Assumes single-focus: only one input can be focused at a time, so -// enter() overwriting is safe. leave() is id-gated so a stale dispose -// from a previously-focused field cannot clobber the active focus. -object FocusTracker { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current.asStateFlow() - - fun enter(id: String) { - _current.value = id - } - - fun leave(id: String) { - if (_current.value == id) _current.value = null - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt deleted file mode 100644 index 29f4a0e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/LedgerStore.kt +++ /dev/null @@ -1,17 +0,0 @@ -package app.folio.data - -import kotlinx.coroutines.flow.StateFlow - -interface LedgerStore { - val accounts: StateFlow> - val transactions: StateFlow> - val session: StateFlow - - fun accountExistsByName(name: String): Boolean - fun insertAccount(id: String, name: String, createdAt: Long) - fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) - fun upsertSession(user: String, loggedInAt: Long) - fun clearSession() -} - -expect fun createLedgerStore(): LedgerStore diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt deleted file mode 100644 index 3c78d4e..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.account - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddAccountUiState { - val addAccountError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt deleted file mode 100644 index e2c40e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.folio.feature.auth - -import kotlinx.coroutines.flow.MutableStateFlow - -object LoginUiState { - val loginError = MutableStateFlow("") -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt deleted file mode 100644 index 714abfb..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt +++ /dev/null @@ -1,162 +0,0 @@ -package app.folio.feature.ledger - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.folio.data.Repository -import app.folio.data.TxnType -import app.folio.navigation.Navigator -import app.folio.navigation.Route -import app.folio.platform.parseCents -import app.folio.ui.BackHandler -import app.folio.ui.component.AppButton -import app.folio.ui.component.BackButton -import app.folio.ui.component.ButtonStyle -import app.folio.ui.component.EmptyState -import app.folio.ui.component.ErrorText -import app.folio.ui.component.FieldLabel -import app.folio.ui.component.Header -import app.folio.ui.component.Screen -import app.folio.ui.component.Segmented -import app.folio.ui.component.TextInput -import app.folio.ui.theme.Type - -private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") - -@Composable -fun AddTransactionScreen(accountId: String) { - val accounts by Repository.accounts.collectAsState() - val account = accounts.firstOrNull { it.id == accountId } - - BackHandler { Navigator.back(Route.Ledger(accountId)) } - - if (account == null) { - Screen( - header = { - Header(title = "Add transaction", left = { BackButton { Navigator.back(Route.Home) } }) - }, - ) { - EmptyState(title = "Account not found", subtitle = "") - AppButton( - text = "Back to accounts", - onClick = { Navigator.replace(Route.Home) }, - style = ButtonStyle.Secondary, - ) - } - return - } - - var type by remember { mutableStateOf(TxnType.credit) } - var amount by remember { mutableStateOf("") } - var note by remember { mutableStateOf("") } - val err by AddTransactionUiState.txnError.collectAsState() - - DisposableEffect(type) { - AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit" - onDispose { AddTransactionUiState.txnFormType.value = null } - } - - DisposableEffect(Unit) { - onDispose { AddTransactionUiState.txnError.value = "" } - } - - fun submit() { - val cents = parseCents(amount) - if (cents == null) { - AddTransactionUiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return - } - if (cents <= 0) { - AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return - } - try { - Repository.createTransaction(accountId, type, cents, note) - AddTransactionUiState.txnError.value = "" - Navigator.back(Route.Home) - } catch (e: IllegalArgumentException) { - AddTransactionUiState.txnError.value = e.message ?: "Could not save transaction" - } - } - - Screen( - modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" }, - header = { - Header( - title = "Add transaction", - subtitle = account.name, - left = { BackButton { Navigator.back(Route.Ledger(accountId)) } }, - ) - }, - footer = { - AppButton( - text = if (type == TxnType.credit) "Add credit" else "Add debit", - onClick = ::submit, - style = ButtonStyle.Primary, - enabled = amount.isNotBlank(), - description = "txn_submit", - ) - }, - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Segmented( - selected = if (type == TxnType.credit) 0 else 1, - labels = listOf("Credit", "Debit"), - onSelect = { - AddTransactionUiState.txnError.value = "" - type = if (it == 0) TxnType.credit else TxnType.debit - }, - descriptions = listOf("txn_credit", "txn_debit"), - ) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Amount") - TextInput( - value = amount, - onChange = { - AddTransactionUiState.txnError.value = "" - if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { - amount = it - } - }, - placeholder = "0.00", - invalid = err.isNotEmpty(), - keyboardType = KeyboardType.Decimal, - textAlign = TextAlign.Center, - textStyle = Type.amountInput, - label = "Amount", - description = "txn_amount", - ) - } - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - FieldLabel("Note (optional)") - TextInput( - value = note, - onChange = { - AddTransactionUiState.txnError.value = "" - note = it.take(80) - }, - placeholder = "What's this for?", - label = "Note", - description = "txn_note", - ) - } - ErrorText(err) - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt deleted file mode 100644 index 1c6289f..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionUiState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.folio.feature.ledger - -import kotlinx.coroutines.flow.MutableStateFlow - -object AddTransactionUiState { - val txnError = MutableStateFlow("") - val txnFormType = MutableStateFlow(null) -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt deleted file mode 100644 index 841dc00..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Navigator.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.folio.navigation - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -object Navigator { - private val stack = ArrayDeque().apply { addLast(Route.Home) } - private val _current = MutableStateFlow(Route.Home) - val current: StateFlow = _current.asStateFlow() - - fun push(route: Route) { - stack.addLast(route) - _current.value = route - } - - fun replace(route: Route) { - stack.clear() - stack.addLast(route) - _current.value = route - } - - fun back(fallback: Route) { - if (stack.size > 1) { - stack.removeLast() - _current.value = stack.last() - } else { - replace(fallback) - } - } -} diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt deleted file mode 100644 index cb8a6e3..0000000 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/navigation/Route.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.folio.navigation - -sealed interface Route { - data object Login : Route - data object Home : Route - data object AddAccount : Route - data class Ledger(val accountId: String) : Route - data class AddTransaction(val accountId: String) : Route -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt deleted file mode 100644 index 598ccfb..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/DriverFactory.ios.kt +++ /dev/null @@ -1,10 +0,0 @@ -package app.folio.data - -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.native.NativeSqliteDriver -import app.folio.db.LedgerDatabase - -actual class DriverFactory { - actual fun create(): SqlDriver = - NativeSqliteDriver(LedgerDatabase.Schema, "ledger.db") -} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt deleted file mode 100644 index 594de08..0000000 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/data/LedgerStore.ios.kt +++ /dev/null @@ -1,3 +0,0 @@ -package app.folio.data - -actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory()) diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt deleted file mode 100644 index 3bbd0da..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/Snapshot.kt +++ /dev/null @@ -1,65 +0,0 @@ -package app.folio.data - -internal data class Snapshot( - val accounts: List, - val transactions: List, - val session: Session?, -) { - fun encode(): String = buildString { - for (a in accounts) { - append("A\t").append(esc(a.id)).append('\t').append(esc(a.name)).append('\t').append(a.createdAt).append('\n') - } - for (t in transactions) { - append("T\t").append(esc(t.id)).append('\t').append(esc(t.accountId)).append('\t') - .append(t.type.name).append('\t').append(t.amount).append('\t') - .append(esc(t.note)).append('\t').append(t.createdAt).append('\n') - } - session?.let { append("S\t").append(esc(it.user)).append('\t').append(it.loggedInAt).append('\n') } - } - - companion object { - fun decode(raw: String): Snapshot? { - val accounts = mutableListOf() - val transactions = mutableListOf() - var session: Session? = null - for (line in raw.split('\n')) { - if (line.isEmpty()) continue - val parts = line.split('\t') - when (parts[0]) { - "A" -> if (parts.size == 4) accounts += Account(unesc(parts[1]), unesc(parts[2]), parts[3].toLong()) - "T" -> if (parts.size == 7) transactions += Transaction( - id = unesc(parts[1]), - accountId = unesc(parts[2]), - type = TxnType.valueOf(parts[3]), - amount = parts[4].toLong(), - note = unesc(parts[5]), - createdAt = parts[6].toLong(), - ) - "S" -> if (parts.size == 3) session = Session(unesc(parts[1]), parts[2].toLong()) - } - } - return Snapshot(accounts, transactions, session) - } - - private fun esc(s: String) = s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n") - private fun unesc(s: String): String { - val out = StringBuilder(s.length) - var i = 0 - while (i < s.length) { - val c = s[i] - if (c == '\\' && i + 1 < s.length) { - when (s[i + 1]) { - 't' -> out.append('\t') - 'n' -> out.append('\n') - '\\' -> out.append('\\') - else -> out.append(s[i + 1]) - } - i += 2 - } else { - out.append(c); i++ - } - } - return out.toString() - } - } -} diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt b/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt deleted file mode 100644 index 0523721..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/data/WebLedgerStore.kt +++ /dev/null @@ -1,60 +0,0 @@ -package app.folio.data - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.browser.localStorage - -private const val STORAGE_KEY = "sanderling.ledger.v1" - -class WebLedgerStore : LedgerStore { - private val _accounts = MutableStateFlow>(emptyList()) - private val _transactions = MutableStateFlow>(emptyList()) - private val _session = MutableStateFlow(null) - - override val accounts: StateFlow> = _accounts.asStateFlow() - override val transactions: StateFlow> = _transactions.asStateFlow() - override val session: StateFlow = _session.asStateFlow() - - init { - load() - } - - override fun accountExistsByName(name: String): Boolean = - _accounts.value.any { it.name.equals(name, ignoreCase = true) } - - override fun insertAccount(id: String, name: String, createdAt: Long) { - _accounts.value = _accounts.value + Account(id, name, createdAt) - save() - } - - override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) { - _transactions.value = _transactions.value + Transaction(id, accountId, type, amount, note, createdAt) - save() - } - - override fun upsertSession(user: String, loggedInAt: Long) { - _session.value = Session(user, loggedInAt) - save() - } - - override fun clearSession() { - _session.value = null - save() - } - - private fun load() { - val raw = localStorage.getItem(STORAGE_KEY) ?: return - val parsed = Snapshot.decode(raw) ?: return - _accounts.value = parsed.accounts - _transactions.value = parsed.transactions - _session.value = parsed.session - } - - private fun save() { - val snap = Snapshot(_accounts.value, _transactions.value, _session.value) - localStorage.setItem(STORAGE_KEY, snap.encode()) - } -} - -actual fun createLedgerStore(): LedgerStore = WebLedgerStore() diff --git a/examples/folio/composeApp/src/wasmJsMain/resources/index.html b/examples/folio/composeApp/src/wasmJsMain/resources/index.html deleted file mode 100644 index dfc1982..0000000 --- a/examples/folio/composeApp/src/wasmJsMain/resources/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Folio - - - -
- - - diff --git a/examples/folio/core/build.gradle.kts b/examples/folio/core/build.gradle.kts new file mode 100644 index 0000000..375fba2 --- /dev/null +++ b/examples/folio/core/build.gradle.kts @@ -0,0 +1,68 @@ +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) + alias(libs.plugins.metro) +} + +kotlin { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + 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) + implementation(libs.kotlinx.browser) + } + } +} + +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/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt similarity index 51% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt rename to examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt index ff19f55..cd3f674 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/data/DriverFactory.android.kt +++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/data/DriverFactory.android.kt @@ -1,11 +1,12 @@ -package app.folio.data +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 fun create(): SqlDriver = - AndroidSqliteDriver(LedgerDatabase.Schema, context, "ledger.db") + actual suspend fun create(): SqlDriver = + AndroidSqliteDriver(LedgerDatabase.Schema.synchronous(), context, "ledger.db") } diff --git a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt similarity index 84% rename from examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt rename to examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt index 9adf3f6..adefad1 100644 --- a/examples/folio/composeApp/src/androidMain/kotlin/app/folio/platform/Platform.android.kt +++ b/examples/folio/core/src/androidMain/kotlin/app/folio/core/platform/Platform.android.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import java.util.UUID diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt similarity index 76% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt index 5601094..ff29a77 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Account.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Account.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data data class Account( val id: String, diff --git a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt similarity index 53% rename from examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt index 50b6df4..07ee724 100644 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/DriverFactory.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/DriverFactory.kt @@ -1,7 +1,7 @@ -package app.folio.data +package app.folio.core.data import app.cash.sqldelight.db.SqlDriver expect class DriverFactory { - fun create(): SqlDriver + 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/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt similarity index 58% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt index 38293c3..020407e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Repository.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Repository.kt @@ -1,22 +1,19 @@ -package app.folio.data +package app.folio.core.data -import app.folio.platform.Platform +import app.folio.core.platform.Platform +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.StateFlow -object Repository { - private var _store: LedgerStore? = null - private val store: LedgerStore - get() = _store ?: error("Repository not initialized") +@SingleIn(AppScope::class) +@Inject +class Repository(private val store: LedgerStore) { + val accounts: StateFlow> = store.accounts + val transactions: StateFlow> = store.transactions + val session: StateFlow = store.session - val accounts: StateFlow> get() = store.accounts - val transactions: StateFlow> get() = store.transactions - val session: StateFlow get() = store.session - - fun init() { - if (_store == null) _store = createLedgerStore() - } - - fun createAccount(name: String): Account { + 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)" } @@ -28,7 +25,7 @@ object Repository { fun getAccount(id: String): Account? = accounts.value.firstOrNull { it.id == id } - fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction { + 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( @@ -43,7 +40,6 @@ object Repository { return txn } - fun setSession(user: String) = store.upsertSession(user, Platform.now()) - - fun clearSession() = store.clearSession() + suspend fun setSession(user: String) = store.upsertSession(user, Platform.now()) + suspend fun clearSession() = store.clearSession() } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt similarity index 71% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt index 0e0929f..d3bae1c 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Session.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Session.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data data class Session( val user: String, diff --git a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt similarity index 56% rename from examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt index 7357c76..5720b71 100644 --- a/examples/folio/composeApp/src/sqlMain/kotlin/app/folio/data/SqlLedgerStore.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/SqlLedgerStore.kt @@ -1,9 +1,13 @@ -package app.folio.data +package app.folio.core.data +import app.cash.sqldelight.async.coroutines.awaitAsOne import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOneOrNull import app.folio.db.LedgerDatabase +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,49 +16,50 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -class SqlLedgerStore(factory: DriverFactory) : LedgerStore { - private val db = LedgerDatabase(factory.create()) - private val q = db.ledgerQueries +@SingleIn(AppScope::class) +@Inject +class SqlLedgerStore(db: LedgerDatabase) : LedgerStore { + private val queries = db.ledgerQueries private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override val accounts: StateFlow> = - q.selectAllAccounts() + 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> = - q.selectAllTxns() + queries.selectAllTxns() .asFlow() .mapToList(Dispatchers.Default) .map { rows -> rows.map { it.toDomain() } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) override val session: StateFlow = - q.selectSession() + queries.selectSession() .asFlow() .mapToOneOrNull(Dispatchers.Default) - .map { it?.let { row -> Session(row.user, row.loggedInAt) } } + .map { row -> row?.let { Session(it.user, it.loggedInAt) } } .stateIn(scope, SharingStarted.Eagerly, null) - override fun accountExistsByName(name: String): Boolean = - q.accountExistsByName(name).executeAsOne() + override suspend fun accountExistsByName(name: String): Boolean = + queries.accountExistsByName(name).awaitAsOne() - override fun insertAccount(id: String, name: String, createdAt: Long) { - q.insertAccount(id, name, createdAt) + override suspend fun insertAccount(id: String, name: String, createdAt: Long) { + queries.insertAccount(id, name, createdAt) } - override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) { - q.insertTxn(id, accountId, type.name, amount, note, createdAt) + override 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 fun upsertSession(user: String, loggedInAt: Long) { - q.upsertSession(user, loggedInAt) + override suspend fun upsertSession(user: String, loggedInAt: Long) { + queries.upsertSession(user, loggedInAt) } - override fun clearSession() { - q.clearSession() + override suspend fun clearSession() { + queries.clearSession() } } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt similarity index 87% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt index c9f1a54..33162ad 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/data/Transaction.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/data/Transaction.kt @@ -1,4 +1,4 @@ -package app.folio.data +package app.folio.core.data enum class TxnType { credit, debit } diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt similarity index 69% rename from examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt rename to examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt index dc4e3ce..5d0650e 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/platform/Platform.kt +++ b/examples/folio/core/src/commonMain/kotlin/app/folio/core/platform/Platform.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform expect object Platform { fun now(): Long diff --git a/examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq b/examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq similarity index 100% rename from examples/folio/composeApp/src/commonMain/sqldelight/app/folio/db/Ledger.sq rename to examples/folio/core/src/commonMain/sqldelight/app/folio/db/Ledger.sq 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/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt similarity index 89% rename from examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt rename to examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt index 6276e14..f4a0f37 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/platform/Platform.ios.kt +++ b/examples/folio/core/src/iosMain/kotlin/app/folio/core/platform/Platform.ios.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import platform.Foundation.NSDate import platform.Foundation.NSUUID 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..3679cbc --- /dev/null +++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/data/DriverFactory.wasmJs.kt @@ -0,0 +1,70 @@ +@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class) + +package app.folio.core.data + +import app.cash.sqldelight.async.coroutines.awaitCreate +import app.cash.sqldelight.async.coroutines.awaitMigrate +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 driver = WebWorkerDriver(createSqliteWorker()) + val schema = LedgerDatabase.Schema + val current = readUserVersion(driver) + val target = schema.version + when { + current == 0L && !schemaExists(driver) -> { + schema.awaitCreate(driver) + writeUserVersion(driver, target) + } + current == 0L -> writeUserVersion(driver, target) + current < target -> { + schema.awaitMigrate(driver, current, target) + writeUserVersion(driver, target) + } + } + return driver + } +} + +private suspend fun readUserVersion(driver: SqlDriver): Long { + var version = 0L + driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version", + mapper = { cursor -> + app.cash.sqldelight.db.QueryResult.AsyncValue { + if (cursor.next().await()) version = cursor.getLong(0) ?: 0L + Unit + } + }, + parameters = 0, + ).await() + return version +} + +private suspend fun writeUserVersion(driver: SqlDriver, version: Long) { + driver.execute(null, "PRAGMA user_version = $version", 0).await() +} + +private suspend fun schemaExists(driver: SqlDriver): Boolean { + var exists = false + driver.executeQuery( + identifier = null, + sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1", + mapper = { cursor -> + app.cash.sqldelight.db.QueryResult.AsyncValue { + exists = cursor.next().await() + Unit + } + }, + parameters = 0, + ).await() + return exists +} + +private fun createSqliteWorker(): Worker = + js("""new Worker(new URL("./sqlite.worker.js", import.meta.url), { type: "module" })""") diff --git a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt similarity index 96% rename from examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt rename to examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt index 467b5d8..76d3685 100644 --- a/examples/folio/composeApp/src/wasmJsMain/kotlin/app/folio/platform/Platform.wasmJs.kt +++ b/examples/folio/core/src/wasmJsMain/kotlin/app/folio/core/platform/Platform.wasmJs.kt @@ -1,4 +1,4 @@ -package app.folio.platform +package app.folio.core.platform import kotlin.random.Random diff --git a/examples/folio/gradle/libs.versions.toml b/examples/folio/gradle/libs.versions.toml new file mode 100644 index 0000000..5e4f40b --- /dev/null +++ b/examples/folio/gradle/libs.versions.toml @@ -0,0 +1,42 @@ +[versions] +kotlin = "2.3.20" +agp = "8.13.2" +compose-multiplatform = "1.10.3" +sqldelight = "2.3.2" +kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.9.0" +lifecycle-viewmodel-compose = "2.10.0" +activity-compose = "1.13.0" +metro = "1.0.0-RC4" +navigation-compose = "2.9.2" +kotlinx-browser = "0.5.0" +sqlite-wasm = "3.53.0-build1" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +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" } + +lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" } +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } + +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } + +kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +metro = { id = "dev.zacsweers.metro", version.ref = "metro" } diff --git a/examples/folio/justfile b/examples/folio/justfile index 263e8e0..98f8321 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -40,14 +40,14 @@ install: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:installDebug + ./gradlew :app:androidApp:installDebug # Remove the folio APK from the connected device. uninstall: #!/usr/bin/env bash set -euo pipefail export ANDROID_HOME="$(just _android-home)" - ./gradlew :composeApp:uninstallDebug + ./gradlew :app:androidApp:uninstallDebug # Remove gradle + iOS build directories. clean: @@ -55,13 +55,13 @@ clean: set -euo pipefail export ANDROID_HOME="$(just _android-home)" ./gradlew clean - rm -rf iosApp/build + rm -rf app/iosApp/build # Regenerate iosApp.xcodeproj from project.yml. ios-gen: #!/usr/bin/env bash set -euo pipefail - cd iosApp && xcodegen generate + cd app/iosApp && xcodegen generate # Build + install + launch on a booted iOS simulator (boots IOS_DEVICE if none). ios: @@ -74,11 +74,11 @@ ios: sleep 3 fi just ios-gen - xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \ + xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \ -destination 'platform=iOS Simulator,name={{ios_device}}' \ - -derivedDataPath iosApp/build \ + -derivedDataPath app/iosApp/build \ build | tail -5 - xcrun simctl install booted iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app + xcrun simctl install booted app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app xcrun simctl launch booted app.folio # Run 'sanderling test' against the folio app. Uses a connected device if one is @@ -98,6 +98,20 @@ test: --seed "{{seed}}" \ --output "{{output}}" +# Serve the wasmJs web app from a webpack dev server with COOP/COEP headers. +web: + #!/usr/bin/env bash + set -euo pipefail + export ANDROID_HOME="$(just _android-home)" + ./gradlew :app:webApp:wasmJsBrowserDevelopmentRun --continuous + +# Produce a webpack distributable bundle for the web app. +web-build: + #!/usr/bin/env bash + set -euo pipefail + export ANDROID_HOME="$(just _android-home)" + ./gradlew :app:webApp:wasmJsBrowserDevelopmentExecutableDistribution + # Build + install + run sanderling spec on iOS simulator. test-ios: #!/usr/bin/env bash diff --git a/examples/folio/kotlin-js-store/wasm/yarn.lock b/examples/folio/kotlin-js-store/wasm/yarn.lock index 5f4567d..bb54683 100644 --- a/examples/folio/kotlin-js-store/wasm/yarn.lock +++ b/examples/folio/kotlin-js-store/wasm/yarn.lock @@ -6,3 +6,8 @@ version "3.2.0" resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +"@sqlite.org/sqlite-wasm@3.53.0-build1": + version "3.53.0-build1" + resolved "https://registry.yarnpkg.com/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.53.0-build1.tgz#066cab9189973c39edbb7078c55bb4daa1cd2d30" + integrity sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g== diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index f7d2af3..d08f056 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -5,23 +5,24 @@ import { always, extract, from, + keyedBy, next, now, weighted, + whenRoute, } from "@sanderling/spec"; interface Account { - id: string; + name: string; balance: number; } interface LedgerRow { - id: string; + key: string; signed: number; } // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. -// formatCents always uses $ prefix, . as decimal, , as thousands separator. function parseDollarCents(text: string | undefined): number { if (!text) return 0; const sign = text.startsWith("-") ? -1 : 1; @@ -29,89 +30,80 @@ function parseDollarCents(text: string | undefined): number { return digits ? sign * parseInt(digits, 10) : 0; } -// Route and auth state derived from screen root nodes -const loggedIn = extract(s => s.ax.find({ accessibilityText: "LoginScreen" }) == null); +// Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS) +const loggedIn = extract(s => s.ax.find({ testTag: "LoginScreen" }) == null); const route = extract(s => { - if (s.ax.find({ accessibilityText: "LoginScreen" })) return "login"; - if (s.ax.find({ accessibilityText: "HomeScreen" })) return "home"; - if (s.ax.find({ accessibilityText: "AddAccountScreen" })) return "add-account"; - if (s.ax.find({ accessibilityText: "LedgerScreen" })) return "ledger"; - if (s.ax.find({ accessibilityText: "AddTransactionScreen" })) return "add-transaction"; + if (s.ax.find({ testTag: "LoginScreen" })) return "login"; + if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account"; + if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction"; + if (s.ax.find({ testTag: "LedgerScreen" })) return "ledger"; + if (s.ax.find({ testTag: "HomeScreen" })) return "home"; return null; }); -// All element lookups scoped through their screen root -const accounts = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") - .map(el => ({ - id: el.desc?.split(":")[1] ?? "", - balance: parseDollarCents(el.find({ accessibilityText: "account_balance" })?.text), - })) ?? []); -const ledgerRows = extract(s => - s.ax.find({ accessibilityText: "LedgerScreen" })?.findAll("descPrefix:ledger_row:") - .map(el => ({ - id: el.desc?.split(":")[1] ?? "", - signed: parseDollarCents(el.find({ accessibilityText: "txn_amount" })?.text), - })) ?? []); -const ledgerBalance = extract(s => - parseDollarCents( - s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "ledger_balance_display" })?.text - )); -const activeAccountId = extract(s => { - const desc = s.ax.find("descPrefix:LedgerScreen:")?.desc; - return desc ? (desc.split(":")[1] ?? null) : null; -}); +// Account cards on Home: identity is the AccountName text; balance comes from AccountBalance. +const accounts = extract(s => + s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({ + name: card.find({ testTag: "AccountName" })?.text ?? "", + balance: parseDollarCents(card.find({ testTag: "AccountBalance" })?.text), + }))); -// focusedInput lives in the app root (not inside any screen), so unscoped -const focusedInput = extract(s => - s.ax.find("descPrefix:focused_input:")?.desc?.split(":")[1] ?? null); +// Ledger rows: identity composed from the row's stable testTag'd cells. +const ledgerRows = extract(s => + s.ax.findAll([{ testTag: "LedgerScreen" }, { testTag: "LedgerRow" }]).map(row => ({ + key: keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), + signed: parseDollarCents(row.find({ testTag: "TxnAmount" })?.text), + }))); + +const ledgerBalance = extract(s => + parseDollarCents(s.ax.find({ testTag: "LedgerBalance" })?.text)); + +const focusedFieldTag = extract(s => s.ax.find({ focused: "true" })?.id ?? null); const loginEmailField = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_email" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])); const loginPasswordField = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_password" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }])); const loginSubmit = extract(s => - s.ax.find({ accessibilityText: "LoginScreen" })?.find({ accessibilityText: "login_submit" })); + s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }])); const addAccountButton = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.find({ accessibilityText: "add_account_button" })); + s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }])); const accountNameField = extract(s => - s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "account_name_field" })); + s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }])); const addAccountSubmit = extract(s => - s.ax.find({ accessibilityText: "AddAccountScreen" })?.find({ accessibilityText: "add_account_submit" })); + s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }])); const addTxnButton = extract(s => - s.ax.find({ accessibilityText: "LedgerScreen" })?.find({ accessibilityText: "add_txn_button" })); + s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }])); const txnAmountField = extract(s => - s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_amount" })); + s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }])); const txnSubmit = extract(s => - s.ax.find({ accessibilityText: "AddTransactionScreen" })?.find({ accessibilityText: "txn_submit" })); + s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }])); const accountCards = extract(s => - s.ax.find({ accessibilityText: "HomeScreen" })?.findAll("descPrefix:account_card:") ?? []); -const backButton = extract(s => s.ax.find("desc:Back")); + s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])); +const backButton = extract(s => s.ax.find({ testTag: "BackButton" })); -// Property 1: every new account starts with balance === 0 -// Guard: only check when accounts were visible in the previous step too. -// Without this, navigating away from HomeScreen (accounts=[]) then back -// makes every account look "new", causing false positives on pre-existing balances. +// Property 1: every newly-appearing account starts with balance === 0. +// Identity is by visible name. Guard against navigation transitions where +// accounts vanish from the visible tree. const newAccountBalanceIsZero = always( next(() => { const prev = accounts.previous ?? []; const curr = accounts.current; if (prev.length === 0 || curr.length === 0) return true; - const prevIds = new Set(prev.map(a => a.id)); - const newAccounts = curr.filter(a => !prevIds.has(a.id)); - return newAccounts.every(a => a.balance === 0); + const prevNames = new Set(prev.map(a => a.name)); + return curr.filter(a => !prevNames.has(a.name)).every(a => a.balance === 0); }) ); -// Property 2: every new transaction changes the account ledger balance by exactly its signed amount +// Property 2: a newly-added ledger row changes the ledger balance by exactly its signed amount. const newTxnChangesBalance = always( - now(() => activeAccountId.current !== null).implies( + now(() => route.current === "ledger").implies( next(() => { - const prevRows = ledgerRows.previous ?? []; - const curRows = ledgerRows.current; - if (curRows.length !== prevRows.length + 1) return true; - const prevIds = new Set(prevRows.map(r => r.id)); - const added = curRows.find(r => !prevIds.has(r.id)); + const prev = ledgerRows.previous ?? []; + const curr = ledgerRows.current; + if (curr.length !== prev.length + 1) return true; + const prevKeys = new Set(prev.map(r => r.key)); + const added = curr.find(r => !prevKeys.has(r.key)); if (!added) return true; const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); return delta === added.signed && delta !== 0; @@ -122,15 +114,15 @@ const newTxnChangesBalance = always( const DEMO_EMAIL = "demo@folio.app"; const DEMO_PASSWORD = "ledger123"; -// Login if not already in - step by step based on which field has focus +// Login: drive the form via focus state read from the native focused="true" attr. const login = actions(() => { if (loggedIn.current) return []; - const focus = focusedInput.current; - if (focus === "login_password") { + const focus = focusedFieldTag.current; + if (focus === "LoginPassword") { const submit = loginSubmit.current; return submit ? [Tap({ on: submit })] : []; } - if (focus === "login_email") { + if (focus === "LoginEmail") { const pwd = loginPasswordField.current; return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : []; } @@ -140,47 +132,37 @@ const login = actions(() => { const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]); -// Add an account: home -> tap add -> type name -> submit -const addAccount = actions(() => { - if (!loggedIn.current) return []; +const addAccount = whenRoute(route, ["home", "add-account"], () => { if (route.current === "home") { const btn = addAccountButton.current; return btn ? [Tap({ on: btn })] : []; } - if (route.current === "add-account") { - const field = accountNameField.current; - const submit = addAccountSubmit.current; - const opts = []; - if (field) opts.push(InputText({ into: field, text: accountNames.generate() })); - if (submit) opts.push(Tap({ on: submit })); - return opts; - } - return []; + const field = accountNameField.current; + const submit = addAccountSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: accountNames.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; }); const amounts = from(["10", "50", "25", "100", "5"]); -// Add a transaction: home -> tap account card -> tap add txn -> type amount -> submit -const addTxn = actions(() => { - if (!loggedIn.current) return []; +const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => { if (route.current === "home") { const cards = accountCards.current; if (cards.length === 0) return []; - return [Tap({ on: cards[Math.floor(Math.random() * cards.length)] })]; + return [Tap({ on: from(cards).generate() })]; } if (route.current === "ledger") { const btn = addTxnButton.current; return btn ? [Tap({ on: btn })] : []; } - if (route.current === "add-transaction") { - const field = txnAmountField.current; - const submit = txnSubmit.current; - const opts = []; - if (field) opts.push(InputText({ into: field, text: amounts.generate() })); - if (submit) opts.push(Tap({ on: submit })); - return opts; - } - return []; + const field = txnAmountField.current; + const submit = txnSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: amounts.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; }); const back = actions(() => { @@ -193,12 +175,14 @@ export const properties = { newTxnChangesBalance, }; +export const setup = login; + export const actionsRoot = weighted( - [50, login], - [30, addAccount], - [30, addTxn], - [5, back], + [50, addAccount], + [40, addTxn], + [10, back], ); -(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; +(globalThis as { actions?: unknown; properties?: unknown; setup?: unknown }).actions = actionsRoot; (globalThis as { properties?: unknown }).properties = properties; +(globalThis as { setup?: unknown }).setup = setup; diff --git a/examples/folio/settings.gradle.kts b/examples/folio/settings.gradle.kts index 2f2b992..253dd56 100644 --- a/examples/folio/settings.gradle.kts +++ b/examples/folio/settings.gradle.kts @@ -15,6 +15,12 @@ dependencyResolutionManagement { } } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = "folio" -include(":composeApp") +include(":core") +include(":app:shared") +include(":app:ui-components") +include(":app:androidApp") +include(":app:webApp") diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 78ee995..56c1511 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -18,12 +18,15 @@ // // Cross-platform aliases are expanded automatically: "label" / "accessibilityLabel" // resolve to accessibilityText; "content-desc" also checks accessibilityText and -// vice-versa; "identifier" / "accessibilityIdentifier" resolve to resource-id. +// vice-versa; "identifier" / "accessibilityIdentifier" / "testTag" resolve to +// resource-id (and to each other) so a Compose testTag matches whether the +// underlying platform exposes it as resource-id (Android) or accessibilityIdentifier (iOS). package hierarchy import ( "encoding/json" "fmt" + "maps" "regexp" "strconv" "strings" @@ -113,10 +116,12 @@ var attributeAliases = map[string][]string{ // accessibilityText is the canonical key; also check content-desc for Android/web "accessibilityText": {"content-desc"}, // resource-id canonical key; also check identifier (iOS AXElement raw field) - "resource-id": {"identifier"}, + "resource-id": {"identifier", "accessibilityIdentifier"}, // iOS identifier names - "identifier": {"resource-id"}, - "accessibilityIdentifier": {"resource-id"}, + "identifier": {"resource-id", "accessibilityIdentifier"}, + "accessibilityIdentifier": {"resource-id", "identifier"}, + // Compose testTag surfaces as resource-id on Android, accessibilityIdentifier on iOS + "testTag": {"resource-id", "identifier", "accessibilityIdentifier"}, // iOS AXElement raw name for hintText "placeholderValue": {"hintText"}, // iOS AXElement raw name for class @@ -189,6 +194,9 @@ func elementFromNode(node *treeNodeJSON) *Element { if element.ResourceID == "" { element.ResourceID = attrs["identifier"] } + if element.ResourceID == "" { + element.ResourceID = attrs["accessibilityIdentifier"] + } element.Text = attrs["text"] element.Description = attrs["content-desc"] if element.Description == "" { @@ -222,9 +230,7 @@ func elementFromNode(node *treeNodeJSON) *Element { } element.Attributes = make(map[string]string, len(attrs)+5) - for k, v := range attrs { - element.Attributes[k] = v - } + maps.Copy(element.Attributes, attrs) if node.Clickable != nil { element.Attributes["clickable"] = strconv.FormatBool(*node.Clickable) } @@ -291,6 +297,22 @@ func (t *Tree) FindAllNodes(selector string) []*Node { return searchSubtree(t.Root, kind, value) } +// FindBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindBySelectorPath(path []Selector) *Node { + if t == nil || t.Root == nil { + return nil + } + return t.Root.FindBySelectorPath(path) +} + +// FindAllBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node { + if t == nil || t.Root == nil { + return nil + } + return t.Root.FindAllBySelectorPath(path) +} + // Find returns the first Node in this node's subtree (descendants only) matching // the string selector. Path queries within the selector are not supported here. func (n *Node) Find(selector string) *Node { @@ -339,6 +361,45 @@ func (n *Node) FindAllBySelector(sel Selector) []*Node { return result } +// FindBySelectorPath walks a chain of selectors. The first selector is matched +// against descendants of the receiver; each subsequent selector is matched +// against descendants of the previous match. Returns the deepest match or nil. +func (n *Node) FindBySelectorPath(path []Selector) *Node { + if len(path) == 0 { + return nil + } + for _, child := range n.Children { + for _, candidate := range searchSubtreeBySelector(child, path[0]) { + if len(path) == 1 { + return candidate + } + if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil { + return deeper + } + } + } + return nil +} + +// FindAllBySelectorPath returns every deepest match for the selector chain +// scoped under the receiver. +func (n *Node) FindAllBySelectorPath(path []Selector) []*Node { + if len(path) == 0 { + return nil + } + var result []*Node + for _, child := range n.Children { + for _, candidate := range searchSubtreeBySelector(child, path[0]) { + if len(path) == 1 { + result = append(result, candidate) + continue + } + result = append(result, candidate.FindAllBySelectorPath(path[1:])...) + } + } + return result +} + func findPathNode(root *Node, segments []string) *Node { if root == nil || len(segments) == 0 { return nil diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index ae896d1..e016a30 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -464,6 +464,157 @@ func TestNodeFindScopedSearch(t *testing.T) { } } +func TestTestTagAliasMatchesResourceIDAndroid(t *testing.T) { + input := `{ + "attributes": {"resource-id": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + if len(searchSubtreeBySelector(tree.Root, sel)) == 0 { + t.Fatal("expected testTag selector to match resource-id on Android") + } +} + +func TestTestTagAliasMatchesAccessibilityIdentifierIOS(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + matches := searchSubtreeBySelector(tree.Root, sel) + if len(matches) == 0 { + t.Fatal("expected testTag selector to match accessibilityIdentifier on iOS") + } +} + +func TestTestTagAliasMatchesIdentifierIOSRaw(t *testing.T) { + input := `{ + "attributes": {"identifier": "AccountCard", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}} + matches := searchSubtreeBySelector(tree.Root, sel) + if len(matches) == 0 { + t.Fatal("expected testTag selector to match identifier on iOS raw AXElement") + } +} + +func TestResourceIDFallsBackToAccessibilityIdentifier(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "MyButton", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "resource-id", Value: "MyButton"}}} + if len(searchSubtreeBySelector(tree.Root, sel)) == 0 { + t.Fatal("expected resource-id selector to fall back to accessibilityIdentifier") + } +} + +func TestElementResourceIDPopulatesFromAccessibilityIdentifier(t *testing.T) { + input := `{ + "attributes": {"accessibilityIdentifier": "LoginEmail", "bounds": "[0,0,100,100]"}, + "children": [] + }` + tree, _ := Parse(input) + if len(tree.Elements) == 0 { + t.Fatal("no elements parsed") + } + if got := tree.Elements[0].ResourceID; got != "LoginEmail" { + t.Fatalf("ResourceID = %q, want LoginEmail (iOS Compose accessibilityIdentifier path)", got) + } +} + +const selectorPathDump = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + }, + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,200,540,400]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Savings", "bounds": "[10,210,200,240]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Travel", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] +}` + +func TestFindBySelectorPathSingleSegment(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}} + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen") + } + if got := node.Element.Attributes["testTag"]; got != "HomeScreen" { + t.Fatalf("testTag = %q, want HomeScreen", got) + } +} + +func TestFindBySelectorPathScopedDescent(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen > AccountCard > AccountName") + } + if node.Element.Text != "Checking" { + t.Fatalf("text = %q, want Checking", node.Element.Text) + } +} + +func TestFindBySelectorPathRespectsScope(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "LedgerScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + } + if node := tree.FindBySelectorPath(path); node != nil { + t.Fatalf("AccountCard is under HomeScreen only, expected nil, got %+v", node.Element) + } +} + +func TestFindAllBySelectorPathReturnsAllDeepestMatches(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + matches := tree.FindAllBySelectorPath(path) + if len(matches) != 2 { + t.Fatalf("want 2 matches (Checking, Savings), got %d", len(matches)) + } +} + +func TestFindBySelectorPathEmptyPathReturnsNil(t *testing.T) { + tree, _ := Parse(selectorPathDump) + if tree.FindBySelectorPath(nil) != nil { + t.Fatal("empty path should return nil") + } +} + func TestNodeFindDoesNotReturnSiblings(t *testing.T) { tree, _ := Parse(pathDump) a2Node := tree.FindNode("id:A2") diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index 1a37e68..fa1b7e9 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -7,6 +7,58 @@ import ( "github.com/priyanshujain/sanderling/internal/hierarchy" ) +// TestStateAxObjectSelectorTestTagAlias verifies that an object selector +// `{ testTag: "X" }` resolves through the testTag alias to match an element +// whose source attributes carry resource-id="X" (the Compose +// testTagsAsResourceId=true case on Android). +func TestStateAxObjectSelectorTestTagAlias(t *testing.T) { + src := `{ + "attributes": {"class": "android.widget.LinearLayout"}, + "children": [ + { + "attributes": {"resource-id": "LoginScreen", "class": "android.view.View"}, + "children": [ + { + "attributes": {"resource-id": "LoginEmail", "class": "android.widget.EditText"}, + "children": [] + } + ] + } + ] + }` + tree, err := hierarchy.Parse(src) + if err != nil { + t.Fatal(err) + } + + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.loginRoot = __sanderling__.extract(state => { + const r = state.ax.find({ testTag: "LoginScreen" }); + return r ? "matched" : "miss"; + }); + globalThis.loginEmailViaChain = __sanderling__.extract(state => { + const r = state.ax.find({ testTag: "LoginScreen" }); + if (!r) return "outer-miss"; + const inner = r.find({ testTag: "LoginEmail" }); + return inner ? "inner-matched" : "inner-miss"; + }); + `) + + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + + root := verifier.runtime.GlobalObject().Get("loginRoot").ToObject(verifier.runtime).Get("current").String() + if root != "matched" { + t.Fatalf("loginRoot = %q, want matched", root) + } + chain := verifier.runtime.GlobalObject().Get("loginEmailViaChain").ToObject(verifier.runtime).Get("current").String() + if chain != "inner-matched" { + t.Fatalf("loginEmailViaChain = %q, want inner-matched", chain) + } +} + // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip // actually lets the spec resolve selectors through state.ax.find. // Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent. diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index f8facbe..6d67b0e 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -14,9 +14,10 @@ type extractorState struct { type formulaState struct { predicate goja.Callable - // err latches the first goja error returned by predicate. The thunk - // returns false on error so the LTL evaluator marks the property - // violated; PredicateError surfaces the underlying cause. + // err holds the goja error from this thunk's most recent invocation, or + // nil if the latest call succeeded. The thunk returns false on error so + // the LTL evaluator marks the property violated; PredicateError surfaces + // the underlying cause for the current step. err error } diff --git a/internal/verifier/marshal.go b/internal/verifier/marshal.go index 6d15f45..7055531 100644 --- a/internal/verifier/marshal.go +++ b/internal/verifier/marshal.go @@ -136,7 +136,8 @@ func nodeObject(runtime *goja.Runtime, node *hierarchy.Node, selector string) go return object } -// findNodeFromJS dispatches a JS value (string or object) to Tree-level node lookup. +// findNodeFromJS dispatches a JS value (string, object, or array of objects) +// to Tree-level node lookup. func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) *hierarchy.Node { if goja.IsUndefined(arg) || goja.IsNull(arg) { return nil @@ -147,6 +148,9 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value) if s, ok := arg.Export().(string); ok { return tree.FindNode(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -162,6 +166,9 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va if s, ok := arg.Export().(string); ok { return tree.FindAllNodes(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return tree.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -177,6 +184,9 @@ func findNodeInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, arg go if s, ok := arg.Export().(string); ok { return node.Find(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -192,6 +202,9 @@ func findAllNodesInSubtreeFromJS(runtime *goja.Runtime, node *hierarchy.Node, ar if s, ok := arg.Export().(string); ok { return node.FindAll(s) } + if path, ok := selectorPathFromJS(runtime, arg); ok { + return node.FindAllBySelectorPath(path) + } sel := selectorFromJSObject(runtime, arg) if len(sel.Filters) == 0 { return nil @@ -219,6 +232,37 @@ func selectorFromJSObject(runtime *goja.Runtime, arg goja.Value) hierarchy.Selec return sel } +// selectorPathFromJS recognizes a JS array of selector objects and converts it +// into a Selector chain. Returns ok=false for non-arrays so callers fall +// through to single-object dispatch. +func selectorPathFromJS(runtime *goja.Runtime, arg goja.Value) ([]hierarchy.Selector, bool) { + exported := arg.Export() + slice, ok := exported.([]any) + if !ok { + return nil, false + } + obj := arg.ToObject(runtime) + if obj == nil { + return nil, false + } + path := make([]hierarchy.Selector, 0, len(slice)) + for index := range slice { + entry := obj.Get(fmt.Sprintf("%d", index)) + if entry == nil || goja.IsUndefined(entry) || goja.IsNull(entry) { + return nil, false + } + sel := selectorFromJSObject(runtime, entry) + if len(sel.Filters) == 0 { + return nil, false + } + path = append(path, sel) + } + if len(path) == 0 { + return nil, false + } + return path, true +} + // selectorStringFromJS returns a string representation of the selector argument // for tagging returned element objects (used by selectorOf to reconstruct the // selector when the element is passed back as an action target). diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index f86986c..523349a 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -7,6 +7,9 @@ import ( "strings" "testing" + "github.com/dop251/goja" + + "github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/ltl" ) @@ -176,6 +179,108 @@ func TestNextAction_EmptyGeneratorReturnsErrNoAction(t *testing.T) { } } +func TestNextAction_SetupTakesPrecedenceWhenYielding(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:setup" })]); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:setup" { + t.Errorf("setup precedence: got %q, want id:setup", action.On) + } +} + +func TestNextAction_FallsThroughToActionsWhenSetupEmpty(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.setup = __sanderling__.actions(() => []); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("fallthrough: got %q, want id:main", action.On) + } +} + +func TestNextAction_SetupReengagesAfterRegression(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.loggedIn = __sanderling__.extract(state => state.snapshots["loggedIn"] === true); + globalThis.setup = __sanderling__.actions(() => { + if (loggedIn.current) return []; + return [__sanderling__.tap({ on: "id:login" })]; + }); + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + + push := func(loggedIn bool) { + raw := json.RawMessage(`false`) + if loggedIn { + raw = json.RawMessage(`true`) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"loggedIn": raw}}); err != nil { + t.Fatal(err) + } + } + + push(false) + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 1 (logged out): got %q, want id:login", action.On) + } + + push(true) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Fatalf("step 2 (logged in): got %q, want id:main", action.On) + } + + push(false) + action, err = verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:login" { + t.Fatalf("step 3 (regressed): got %q, want id:login", action.On) + } +} + +func TestNextAction_NoSetupRegistered(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:main" })]); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + + if verifier.setupGenerator != nil { + t.Errorf("setupGenerator should be nil when spec does not export setup") + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.On != "id:main" { + t.Errorf("got %q, want id:main", action.On) + } +} + func TestInputText_RoundTrip(t *testing.T) { verifier := newVerifier(t) mustLoad(t, verifier, ` @@ -258,3 +363,142 @@ func TestLoad_AcceptsSpecWithoutPropertiesOrActions(t *testing.T) { t.Errorf("expected ErrNoAction, got %v", err) } } + +// TestSelectorPath_ScopedDescent ensures the JS-side `find([{...}, {...}])` +// shape walks each segment scoped under the previous match. +func TestSelectorPath_ScopedDescent(t *testing.T) { + const treeJSON = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Other", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] + }` + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.found = __sanderling__.extract(state => + state.ax.find([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }, { testTag: "AccountName" }]) + ); + globalThis.foundUnreachable = __sanderling__.extract(state => + state.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AccountCard" }]) + ); + globalThis.allInHome = __sanderling__.extract(state => + state.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountName" }]) + ); + `) + tree, err := hierarchy.Parse(treeJSON) + if err != nil { + t.Fatal(err) + } + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil { + t.Fatal(err) + } + found := verifier.runtime.GlobalObject().Get("found").ToObject(verifier.runtime).Get("current") + if found == nil || goja.IsUndefined(found) { + t.Fatal("expected path lookup to find AccountName under HomeScreen > AccountCard") + } + text := found.ToObject(verifier.runtime).Get("text") + if text.String() != "Checking" { + t.Fatalf("text = %q, want Checking", text.String()) + } + unreachable := verifier.runtime.GlobalObject().Get("foundUnreachable").ToObject(verifier.runtime).Get("current") + if !goja.IsUndefined(unreachable) { + t.Fatalf("AccountCard is not under LedgerScreen, expected undefined, got %v", unreachable) + } + allInHome := verifier.runtime.GlobalObject().Get("allInHome").ToObject(verifier.runtime).Get("current") + allObject := allInHome.ToObject(verifier.runtime) + length := allObject.Get("length").ToInteger() + if length != 1 { + t.Fatalf("findAll path length = %d, want 1 (Checking only, not Other in LedgerScreen)", length) + } +} + +// TestFrom_SeededReplayIsDeterministic guarantees `from()` over a per-step +// dynamic array picks the same element under the same seed across runs. The +// folio spec relies on this to replace Math.random() in account-card taps. +func TestFrom_SeededReplayIsDeterministic(t *testing.T) { + pickedSequence := func(seed uint64) []string { + verifier := newVerifier(t, WithRand(rand.New(rand.NewPCG(seed, 0)))) + mustLoad(t, verifier, ` + globalThis.actions = __sanderling__.actions(() => { + const cards = ["card_a", "card_b", "card_c", "card_d"]; + return [__sanderling__.tap({ on: __sanderling__.from(cards).generate() })]; + }); + `) + _ = verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}) + var picks []string + for range 20 { + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + picks = append(picks, action.On) + } + return picks + } + first := pickedSequence(1234) + second := pickedSequence(1234) + for i := range first { + if first[i] != second[i] { + t.Fatalf("step %d: %q != %q (replay not deterministic)", i, first[i], second[i]) + } + } + other := pickedSequence(5678) + identical := true + for i := range first { + if first[i] != other[i] { + identical = false + break + } + } + if identical { + t.Fatal("expected different seeds to produce different pick sequences") + } +} + +// PredicateError must reflect the most recent step's predicate result, not a +// latched first-step error. The runner logs PredicateError once per step; if it +// stays pinned to step 1 forever, downstream debugging looks frozen even though +// the underlying state is changing. +func TestPredicateError_ReflectsCurrentStepNotFirstStep(t *testing.T) { + const spec = ` +globalThis.counter = __sanderling__.extract(state => state.snapshots["count"]); +globalThis.properties = { + reportsCounter: __sanderling__.always(() => { throw new Error("count=" + counter.current); }), +}; +` + verifier := newVerifier(t) + mustLoad(t, verifier, spec) + + for step := 1; step <= 3; step++ { + raw := json.RawMessage([]byte{'"', byte('0' + step), '"'}) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{"count": raw}}); err != nil { + t.Fatal(err) + } + _ = verifier.EvaluateProperties() + + got := verifier.PredicateError("reportsCounter") + if got == nil { + t.Fatalf("step %d: PredicateError = nil, want non-nil", step) + } + want := "count=" + string(rune('0'+step)) + if !strings.Contains(got.Error(), want) { + t.Errorf("step %d: PredicateError = %q, want to contain %q", step, got.Error(), want) + } + } +} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 24fd683..0ac7729 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -20,6 +20,7 @@ type Verifier struct { properties map[string]int // property name -> formula-spec index actionGenerator goja.Value + setupGenerator goja.Value evaluators map[string]*ltl.Evaluator @@ -56,8 +57,9 @@ func New(options ...Option) (*Verifier, error) { } // Load executes the bundled spec source. The spec is expected to assign its -// property formulas to globalThis.properties and its root action generator -// to globalThis.actions. +// property formulas to globalThis.properties, its root action generator to +// globalThis.actions, and optionally a setup (precondition) action generator +// to globalThis.setup. func (v *Verifier) Load(source string) error { if _, err := v.runtime.RunString(source); err != nil { return fmt.Errorf("run spec: %w", err) @@ -88,6 +90,10 @@ func (v *Verifier) Load(source string) error { v.actionGenerator = actionsValue } + if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) { + v.setupGenerator = setupValue + } + return nil } @@ -216,6 +222,10 @@ func (v *Verifier) PushSnapshot(input SnapshotInput) error { if err := v.runtime.GlobalObject().Set("state", state); err != nil { return fmt.Errorf("set state: %w", err) } + // Extractor previous/current advance exactly once per PushSnapshot. + // Predicate thunks read these slots but never trigger advancement, so + // invoking a thunk multiple times between snapshots is value-stable. + // refreshPredicateErrors relies on this to safely re-call predicates. for index, extractor := range v.extractors { previous := extractor.handle.Get("current") _ = extractor.handle.Set("previous", previous) @@ -254,6 +264,7 @@ func (v *Verifier) EvaluateProperties() map[string]ltl.Verdict { for name, evaluator := range v.evaluators { verdicts[name] = evaluator.ObserveAt(stepTime) } + v.refreshPredicateErrors() return verdicts } @@ -273,12 +284,22 @@ func (v *Verifier) Residuals() map[string]ltl.Formula { return residuals } -// NextAction resolves the root action generator into a single Action. -// Returns ErrNoAction when no branch of the generator produces one after a -// small number of retries. Retrying avoids wedging when most branches of a -// weighted generator produce no action on the current screen (e.g. a gated -// login-phone generator when the app is already past login). +// NextAction resolves an action for the current step. The setup generator, +// when registered, runs first; if it yields an action, that wins. When setup +// returns ErrNoAction (all branches empty) the call falls through to the +// root action generator with the existing retry semantics. Setup is consulted +// every step, so state regression (e.g. a logout under fuzz) automatically +// re-engages the precondition. func (v *Verifier) NextAction() (Action, error) { + if v.setupGenerator != nil { + action, err := v.resolveGenerator(v.setupGenerator) + if err == nil { + return action, nil + } + if !errors.Is(err, ErrNoAction) { + return Action{}, err + } + } if v.actionGenerator == nil { return Action{}, ErrNoAction } @@ -302,15 +323,38 @@ func (v *Verifier) formulaThunk(index int) func() bool { formula := v.formulas[index] result, err := formula.predicate(goja.Undefined()) if err != nil { - if formula.err == nil { - formula.err = err - } + formula.err = err return false } + formula.err = nil return result.ToBoolean() } } +// refreshPredicateErrors re-invokes every registered predicate so that +// formula.err reflects the current step rather than a latched first-step +// throw. EvaluateProperties short-circuits once a property has latched to +// violated, so without this refresh the runner's per-step "predicate error" +// log freezes on whatever the predicate threw at step 1. The refreshed errors +// have no effect on verdicts. +// +// Invariant: predicates may be re-invoked here outside the LTL gate that +// would normally skip them (e.g. an `implies` consequent whose antecedent is +// false). They must therefore be side-effect-free reads of extractor state; +// any spec that asserts internal preconditions inside a predicate could +// surface a spurious error in the inspect UI without affecting verdicts. +func (v *Verifier) refreshPredicateErrors() { + for _, formula := range v.formulas { + result, err := formula.predicate(goja.Undefined()) + if err != nil { + formula.err = err + continue + } + _ = result + formula.err = nil + } +} + // PredicateError returns the first goja error raised by any thunk in the // named property's formula tree, or nil if none fired. Callers typically // consult this after EvaluateProperties reports a violation to distinguish diff --git a/pkg/spec/README.md b/pkg/spec/README.md index 6894b96..c651ddf 100644 --- a/pkg/spec/README.md +++ b/pkg/spec/README.md @@ -13,10 +13,12 @@ npm install --save-dev @sanderling/spec ## Usage ```ts -import { extract, always, eventually, now, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; +import { extract, always, eventually, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec"; const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar")); const balance = extract((s) => (s.snapshots.balance as number) ?? 0); +const emailField = extract((s) => s.ax.find("id:email-field")); +const submitButton = extract((s) => s.ax.find("id:sign-in-button")); export const properties = { balanceNeverNegative: always(() => balance.current >= 0), @@ -25,17 +27,41 @@ export const properties = { const doLogin = actions(() => { if (loggedIn.current) return []; - const email = state.ax.find("id:email-field"); - const submit = state.ax.find("id:sign-in-button"); + const email = emailField.current; + const submit = submitButton.current; if (!email || !submit) return []; return [InputText({ into: email, text: "test@example.com" }), Tap({ on: submit })]; }); -export const actions = weighted( +export const actionsRoot = weighted( [50, doLogin], [10, taps], [2, swipes], ); ``` +## Setup actions + +Some action generators are not fuzz targets but preconditions: they drive the +app from a fresh state into the surface you actually want to fuzz (login, +onboarding, permission grants, seed data). Export them as `setup` instead of +mixing them into `actionsRoot`. The runner tries `setup` first; if it yields +no action, it falls through to `actionsRoot`. State regressing back across the +precondition (e.g. logout under fuzz) automatically re-engages setup. + +```ts +const login = actions(() => { + if (loggedIn.current) return []; + return [InputText({ into: emailField.current!, text: "demo@app.test" }), Tap({ on: submitButton.current! })]; +}); + +export const setup = login; +export const actionsRoot = weighted([60, browse], [40, edit]); + +(globalThis as { setup?: unknown }).setup = setup; +``` + +Setup is just an `ActionGenerator`; compose with `actions`, `weighted`, or +`whenRoute` exactly like the main pool. + Works identically across Android, iOS, and web targets. diff --git a/pkg/spec/src/actions.ts b/pkg/spec/src/actions.ts index 9e2f160..1010ea3 100644 --- a/pkg/spec/src/actions.ts +++ b/pkg/spec/src/actions.ts @@ -17,6 +17,19 @@ export function actions(generator: () => Action[]): ActionGenerator { return globalThis.__sanderling__.actions(generator); } +export function whenRoute( + routeExtractor: { readonly current: string | null }, + routes: string | readonly string[], + body: () => Action[], +): ActionGenerator { + const allowed = typeof routes === "string" ? [routes] : routes; + return actions(() => { + const current = routeExtractor.current; + if (current === null || !allowed.includes(current)) return []; + return body(); + }); +} + export function weighted(...entries: WeightedEntry[]): ActionGenerator { return globalThis.__sanderling__.weighted(...entries); } diff --git a/pkg/spec/src/extract.ts b/pkg/spec/src/extract.ts index 320e6e2..4c08087 100644 --- a/pkg/spec/src/extract.ts +++ b/pkg/spec/src/extract.ts @@ -1,5 +1,17 @@ -import type { Extracted, State } from "./types.ts"; +import type { AccessibilityElement, Extracted, State } from "./types.ts"; export function extract(getter: (state: State) => T): Extracted { return globalThis.__sanderling__.extract(getter); } + +const KEY_DELIMITER = "\x1f"; + +export function keyedBy( + element: AccessibilityElement | undefined, + tags: readonly string[], +): string { + if (!element) return ""; + return tags + .map(tag => element.find({ testTag: tag })?.text ?? "") + .join(KEY_DELIMITER); +} diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index f046b41..e60355f 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -13,6 +13,7 @@ export type { Point, PressKeyAction, Sampler, + SelectorPath, Snapshots, State, SwipeAction, @@ -22,7 +23,7 @@ export type { WeightedEntry, } from "./types.ts"; -export { extract } from "./extract.ts"; +export { extract, keyedBy } from "./extract.ts"; export { always, eventually, next, now } from "./ltl.ts"; export { InputText, @@ -37,4 +38,5 @@ export { taps, waitOnce, weighted, + whenRoute, } from "./actions.ts"; diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index c1c1ef7..a3c53ea 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -1,6 +1,8 @@ export type Snapshots = Record; export type AttrSelector = Record; +export type SelectorPath = readonly AttrSelector[]; + export interface AccessibilityElement { id?: string; text?: string; @@ -15,13 +17,13 @@ export interface AccessibilityElement { x?: number; y?: number; attrs?: Record; - find(selector: string | AttrSelector): AccessibilityElement | undefined; - findAll(selector: string | AttrSelector): AccessibilityElement[]; + find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined; + findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[]; } export interface AccessibilityTree { - find(selector: string | AttrSelector): AccessibilityElement | undefined; - findAll(selector: string | AttrSelector): AccessibilityElement[]; + find(selector: string | AttrSelector | SelectorPath): AccessibilityElement | undefined; + findAll(selector: string | AttrSelector | SelectorPath): AccessibilityElement[]; } export interface LogEntry { diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index 891db82..e97502a 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -12,6 +12,7 @@ import { eventually, extract, from, + keyedBy, next, now, pressKey, @@ -19,6 +20,7 @@ import { taps, waitOnce, weighted, + whenRoute, } from "../src/index.ts"; import type { AccessibilityElement, @@ -217,7 +219,11 @@ test("Tap returns a TapAction with the supplied selector", () => { test("Tap accepts an AccessibilityElement", () => { installFakeRuntime(); - const element: AccessibilityElement = { id: "login_continue" }; + const element: AccessibilityElement = { + id: "login_continue", + find: () => undefined, + findAll: () => [], + }; const action = Tap({ on: element }); assert.equal(action.kind, "Tap"); assert.equal(action.on, element); @@ -277,6 +283,82 @@ test("from forwards items to the runtime", () => { assert.equal(sampler.generate(), "a"); }); +function elementWithChildren( + cells: Record, +): AccessibilityElement { + return { + find: selector => { + if (typeof selector === "string" || Array.isArray(selector)) return undefined; + const tag = (selector as Record).testTag; + if (!tag) return undefined; + const text = cells[tag]; + if (text === undefined) return undefined; + return { text, find: () => undefined, findAll: () => [] }; + }, + findAll: () => [], + }; +} + +test("keyedBy joins testTag-resolved texts with a stable delimiter", () => { + installFakeRuntime(); + const row = elementWithChildren({ + TxnDate: "2026-04-26", + TxnNote: "Coffee", + TxnAmount: "$5.00", + }); + const key = keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]); + assert.equal(key, "2026-04-26\x1fCoffee\x1f$5.00"); +}); + +test("keyedBy returns empty string for an undefined element", () => { + installFakeRuntime(); + assert.equal(keyedBy(undefined, ["TxnDate"]), ""); +}); + +test("keyedBy substitutes empty strings for missing children", () => { + installFakeRuntime(); + const row = elementWithChildren({ TxnDate: "2026-04-26" }); + assert.equal( + keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), + "2026-04-26\x1f\x1f", + ); +}); + +test("whenRoute returns [] when current route does not match", () => { + installFakeRuntime(); + const route = { current: "home" as string | null }; + let bodyCalled = false; + const generator = whenRoute(route, "ledger", () => { + bodyCalled = true; + return [Tap({ on: "id:x" })]; + }); + assert.deepEqual(generator.generate(), []); + assert.equal(bodyCalled, false); +}); + +test("whenRoute calls body when current route matches", () => { + installFakeRuntime(); + const route = { current: "ledger" as string | null }; + const generator = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]); + const result = generator.generate(); + assert.equal(result.length, 1); + assert.equal(result[0]?.kind, "Tap"); +}); + +test("whenRoute accepts an array of allowed routes", () => { + installFakeRuntime(); + const route = { current: "add-account" as string | null }; + const generator = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]); + assert.equal(generator.generate().length, 1); +}); + +test("whenRoute returns [] for null route", () => { + installFakeRuntime(); + const route = { current: null as string | null }; + const generator = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]); + assert.deepEqual(generator.generate(), []); +}); + test("default generators proxy through to the runtime", () => { installFakeRuntime(); assert.equal(taps.__sanderlingActionGenerator, true);