mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
refactor(folio): introduce nested KotlinConf-style modules
Split the monolithic :composeApp into :core, :app:shared, :app:ui-components, and :app:androidApp. The old module is still present and remains the source of truth until the next commits remove it; both compile in parallel to keep iOS/Android builds green during the cut-over. Highlights: - :core - SQLDelight schema + LedgerStore + Repository (now an injectable class, not a singleton object). Methods are suspend to match generateAsync = true. - :app:ui-components - design system primitives. IconButton/AppButton APIs revised: label = real contentDescription, testTag = stable selector. Drops the data-carrier description argument. - :app:shared - per-screen ViewModels colocated with screens; pure composables on (state, onEvent); LocalAppComponent CompositionLocal for hand-rolled DI; @Serializable Route. Hosts the iOS framework (baseName Shared). - :app:androidApp - thin Android entry that constructs the DriverFactory and hands it to App(). - gradle/libs.versions.toml centralises versions; settings.gradle.kts enables type-safe project accessors. Deferred to follow-up PRs (per the design discussion): - Metro DI: hand-rolled AppComponent for now; Metro graphs are mostly ceremony for an app this size and add KSP/version risk. - Navigation3: kept the existing Navigator-as-backstack class, injected rather than singleton; nav3 isn't shipping a stable multiplatform artifact for commonMain consumption yet. - :app:webApp + OPFS sqlite worker: web persistence is real new wiring (custom worker on @sqlite.org/sqlite-wasm). Master's WebLedgerStore + Snapshot is being removed by this PR; web stays buildable as a klib but no app-level wasm binary lands here.
This commit is contained in:
1 parent
df44012d3e
commit
5121f777f2
61 files changed
+2492
-6
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.plugin.compose)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.folio"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.folio"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = sanderlingVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes { debug { isDebuggable = true } }
|
||||||
|
|
||||||
|
sourceSets["main"].apply {
|
||||||
|
manifest.srcFile("src/main/AndroidManifest.xml")
|
||||||
|
res.srcDirs("src/main/res")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.app.shared)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="Folio"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package app.folio
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import app.folio.core.data.DriverFactory
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val driverFactory = DriverFactory(applicationContext)
|
||||||
|
setContent { App(driverFactory) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.plugin.compose)
|
||||||
|
alias(libs.plugins.kotlin.plugin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
optIn.add("kotlin.js.ExperimentalWasmJsInterop")
|
||||||
|
}
|
||||||
|
|
||||||
|
androidTarget {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
|
||||||
|
target.binaries.framework {
|
||||||
|
baseName = "Shared"
|
||||||
|
isStatic = true
|
||||||
|
binaryOption("bundleId", "app.folio.shared")
|
||||||
|
export(projects.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs { browser() }
|
||||||
|
|
||||||
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(projects.core)
|
||||||
|
api(projects.app.uiComponents)
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.lifecycle.viewmodel.compose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.folio.shared"
|
||||||
|
compileSdk = 36
|
||||||
|
defaultConfig { minSdk = 24 }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package app.folio.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.activity.compose.BackHandler as AndroidBackHandler
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BackHandler(onBack: () -> Unit) = AndroidBackHandler(onBack = onBack)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package app.folio.util
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private val DATE_FMT = SimpleDateFormat("MMM d, h:mm a", Locale.US)
|
||||||
|
|
||||||
|
actual fun formatDate(epochMillis: Long): String = DATE_FMT.format(Date(epochMillis))
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package app.folio
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import app.folio.core.data.DriverFactory
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.core.data.SqlLedgerStore
|
||||||
|
import app.folio.db.LedgerDatabase
|
||||||
|
import app.folio.feature.account.AddAccountRoute
|
||||||
|
import app.folio.feature.auth.LoginRoute
|
||||||
|
import app.folio.feature.home.HomeRoute
|
||||||
|
import app.folio.feature.ledger.AddTransactionRoute
|
||||||
|
import app.folio.feature.ledger.LedgerRoute
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import app.folio.ui.theme.LedgerTheme
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Tokens
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App(driverFactory: DriverFactory) {
|
||||||
|
var component by remember { mutableStateOf<AppComponent?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val driver = driverFactory.create()
|
||||||
|
val db = LedgerDatabase(driver)
|
||||||
|
val store = SqlLedgerStore(db)
|
||||||
|
component = AppComponent(repository = Repository(store), navigator = Navigator(initial = Route.Home))
|
||||||
|
}
|
||||||
|
|
||||||
|
LedgerTheme {
|
||||||
|
val t = Tokens()
|
||||||
|
CompositionLocalProvider(LocalTokens provides t) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(t.bg)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val c = component
|
||||||
|
if (c == null) {
|
||||||
|
// Loading: keep blank to avoid surprising the spec runner.
|
||||||
|
} else {
|
||||||
|
CompositionLocalProvider(LocalAppComponent provides c) {
|
||||||
|
AppContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppContent() {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val session by component.repository.session.collectAsState()
|
||||||
|
val route by component.navigator.current.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(session, route) {
|
||||||
|
if (session == null && route !is Route.Login) {
|
||||||
|
component.navigator.replace(Route.Login)
|
||||||
|
} else if (session != null && route is Route.Login) {
|
||||||
|
component.navigator.replace(Route.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
when (val r = route) {
|
||||||
|
Route.Login -> LoginRoute()
|
||||||
|
Route.Home -> HomeRoute()
|
||||||
|
Route.AddAccount -> AddAccountRoute()
|
||||||
|
is Route.Ledger -> LedgerRoute(accountId = r.accountId)
|
||||||
|
is Route.AddTransaction -> AddTransactionRoute(accountId = r.accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package app.folio
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
|
||||||
|
class AppComponent(
|
||||||
|
val repository: Repository,
|
||||||
|
val navigator: Navigator,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalAppComponent = staticCompositionLocalOf<AppComponent> {
|
||||||
|
error("AppComponent not provided. Wrap content in CompositionLocalProvider(LocalAppComponent provides ...).")
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package app.folio.feature.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.folio.LocalAppComponent
|
||||||
|
import app.folio.ui.BackHandler
|
||||||
|
import app.folio.ui.component.AppButton
|
||||||
|
import app.folio.ui.component.BackButton
|
||||||
|
import app.folio.ui.component.ButtonStyle
|
||||||
|
import app.folio.ui.component.ErrorText
|
||||||
|
import app.folio.ui.component.FieldLabel
|
||||||
|
import app.folio.ui.component.Header
|
||||||
|
import app.folio.ui.component.Screen
|
||||||
|
import app.folio.ui.component.TextInput
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddAccountRoute() {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val vm: AddAccountViewModel = viewModel { AddAccountViewModel(component.repository, component.navigator) }
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
AddAccountScreen(state = state, onEvent = vm::onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddAccountScreen(state: AddAccountUiState, onEvent: (AddAccountEvent) -> Unit) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
BackHandler { onEvent(AddAccountEvent.Back) }
|
||||||
|
|
||||||
|
Screen(
|
||||||
|
modifier = Modifier.testTag("AddAccountScreen"),
|
||||||
|
header = {
|
||||||
|
Header(title = "New account", left = { BackButton(onClick = { onEvent(AddAccountEvent.Back) }) })
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
AppButton(
|
||||||
|
text = "Create account",
|
||||||
|
onClick = { onEvent(AddAccountEvent.Submit) },
|
||||||
|
style = ButtonStyle.Primary,
|
||||||
|
enabled = state.name.trim().isNotEmpty(),
|
||||||
|
testTag = "AddAccountSubmit",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FieldLabel("Account name")
|
||||||
|
TextInput(
|
||||||
|
value = state.name,
|
||||||
|
onChange = { onEvent(AddAccountEvent.NameChange(it)) },
|
||||||
|
placeholder = "e.g. Checking",
|
||||||
|
invalid = state.error.isNotEmpty(),
|
||||||
|
label = "Account name",
|
||||||
|
testTag = "AccountNameField",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ErrorText(state.error)
|
||||||
|
Text(
|
||||||
|
"Use a short, recognizable name. You can create as many accounts as you need.",
|
||||||
|
style = Type.caption,
|
||||||
|
color = t.textMuted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package app.folio.feature.account
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class AddAccountUiState(
|
||||||
|
val name: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface AddAccountEvent {
|
||||||
|
data class NameChange(val value: String) : AddAccountEvent
|
||||||
|
data object Submit : AddAccountEvent
|
||||||
|
data object Back : AddAccountEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddAccountViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val navigator: Navigator,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(AddAccountUiState())
|
||||||
|
val state: StateFlow<AddAccountUiState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app.folio.feature.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.folio.LocalAppComponent
|
||||||
|
import app.folio.ui.component.AppButton
|
||||||
|
import app.folio.ui.component.ButtonStyle
|
||||||
|
import app.folio.ui.component.Card
|
||||||
|
import app.folio.ui.component.ErrorText
|
||||||
|
import app.folio.ui.component.FieldLabel
|
||||||
|
import app.folio.ui.component.Screen
|
||||||
|
import app.folio.ui.component.TextInput
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginRoute() {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val vm: LoginViewModel = viewModel { LoginViewModel(component.repository, component.navigator) }
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
LoginScreen(state = state, onEvent = vm::onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(state: LoginUiState, onEvent: (LoginEvent) -> Unit) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Screen(modifier = Modifier.testTag("LoginScreen")) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FieldLabel("Email")
|
||||||
|
TextInput(
|
||||||
|
value = state.email,
|
||||||
|
onChange = { onEvent(LoginEvent.EmailChange(it)) },
|
||||||
|
placeholder = DEMO_EMAIL,
|
||||||
|
invalid = state.error.isNotEmpty(),
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
label = "Email",
|
||||||
|
testTag = "LoginEmail",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FieldLabel("Password")
|
||||||
|
TextInput(
|
||||||
|
value = state.password,
|
||||||
|
onChange = { onEvent(LoginEvent.PasswordChange(it)) },
|
||||||
|
placeholder = "••••••••",
|
||||||
|
password = true,
|
||||||
|
invalid = state.error.isNotEmpty(),
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
label = "Password",
|
||||||
|
testTag = "LoginPassword",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ErrorText(state.error)
|
||||||
|
AppButton(
|
||||||
|
text = "Sign in",
|
||||||
|
onClick = { onEvent(LoginEvent.Submit) },
|
||||||
|
style = ButtonStyle.Primary,
|
||||||
|
testTag = "LoginSubmit",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Card(dashed = true) {
|
||||||
|
Text("Demo credentials", style = Type.caption, color = t.textMuted)
|
||||||
|
Text("email: $DEMO_EMAIL", style = Type.body, color = t.text)
|
||||||
|
Text("password: $DEMO_PASSWORD", style = Type.body, color = t.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package app.folio.feature.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
const val DEMO_EMAIL = "[email protected]"
|
||||||
|
const val DEMO_PASSWORD = "ledger123"
|
||||||
|
|
||||||
|
data class LoginUiState(
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface LoginEvent {
|
||||||
|
data class EmailChange(val value: String) : LoginEvent
|
||||||
|
data class PasswordChange(val value: String) : LoginEvent
|
||||||
|
data object Submit : LoginEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val navigator: Navigator,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(LoginUiState())
|
||||||
|
val state: StateFlow<LoginUiState> = _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
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package app.folio.feature.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.folio.LocalAppComponent
|
||||||
|
import app.folio.util.balanceOf
|
||||||
|
import app.folio.util.formatCents
|
||||||
|
import app.folio.util.initialsOf
|
||||||
|
import app.folio.util.signedAmount
|
||||||
|
import app.folio.ui.component.AppButton
|
||||||
|
import app.folio.ui.component.ButtonStyle
|
||||||
|
import app.folio.ui.component.EmptyState
|
||||||
|
import app.folio.ui.component.Header
|
||||||
|
import app.folio.ui.component.IconButton
|
||||||
|
import app.folio.ui.component.Screen
|
||||||
|
import app.folio.ui.icon.Icons
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusLg
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeRoute() {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val vm: HomeViewModel = viewModel { HomeViewModel(component.repository, component.navigator) }
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
HomeScreen(state = state, onEvent = vm::onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(state: HomeUiState, onEvent: (HomeEvent) -> Unit) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
val total = balanceOf(state.transactions)
|
||||||
|
|
||||||
|
Screen(
|
||||||
|
modifier = Modifier.testTag("HomeScreen"),
|
||||||
|
header = {
|
||||||
|
Header(
|
||||||
|
title = "Accounts",
|
||||||
|
subtitle = state.user,
|
||||||
|
right = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onEvent(HomeEvent.Logout) },
|
||||||
|
label = "Log out",
|
||||||
|
icon = Icons.Logout,
|
||||||
|
testTag = "LogoutButton",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
|
Column {
|
||||||
|
Text("TOTAL BALANCE", style = Type.label, color = t.textMuted)
|
||||||
|
Text(formatCents(total), style = Type.balance, color = t.text)
|
||||||
|
}
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"${state.accounts.size} account${if (state.accounts.size == 1) "" else "s"}",
|
||||||
|
style = Type.caption,
|
||||||
|
color = t.textFaint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppButton(
|
||||||
|
text = "+ Add account",
|
||||||
|
onClick = { onEvent(HomeEvent.AddAccount) },
|
||||||
|
style = ButtonStyle.Primary,
|
||||||
|
testTag = "AddAccountButton",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (state.accounts.isEmpty()) {
|
||||||
|
EmptyState(
|
||||||
|
title = "No accounts yet",
|
||||||
|
subtitle = "Create your first account to start tracking transactions.",
|
||||||
|
icon = Icons.Bank,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
state.accounts.forEach { a ->
|
||||||
|
val bal = state.transactions.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
|
||||||
|
val count = state.transactions.count { it.accountId == a.id }
|
||||||
|
AccountCard(
|
||||||
|
name = a.name,
|
||||||
|
initials = initialsOf(a.name),
|
||||||
|
count = count,
|
||||||
|
balance = bal,
|
||||||
|
onClick = { onEvent(HomeEvent.OpenAccount(a.id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountCard(
|
||||||
|
name: String,
|
||||||
|
initials: String,
|
||||||
|
count: Int,
|
||||||
|
balance: Long,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
val txnLabel = if (count == 1) "1 transaction" else "$count transactions"
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusLg))
|
||||||
|
.background(t.surface)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||||
|
.testTag("AccountCard")
|
||||||
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(t.surface3),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(initials, style = Type.bodyStrong, color = t.text)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style = Type.bodyStrong,
|
||||||
|
color = t.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(txnLabel, style = Type.caption, color = t.textMuted)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
formatCents(balance),
|
||||||
|
style = Type.bodyStrong,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.testTag("AccountBalance"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package app.folio.feature.home
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.folio.core.data.Account
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.core.data.Transaction
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class HomeUiState(
|
||||||
|
val user: String = "",
|
||||||
|
val accounts: List<Account> = emptyList(),
|
||||||
|
val transactions: List<Transaction> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface HomeEvent {
|
||||||
|
data object Logout : HomeEvent
|
||||||
|
data object AddAccount : HomeEvent
|
||||||
|
data class OpenAccount(val accountId: String) : HomeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomeViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val navigator: Navigator,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<HomeUiState> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package app.folio.feature.ledger
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.folio.LocalAppComponent
|
||||||
|
import app.folio.core.data.TxnType
|
||||||
|
import app.folio.ui.BackHandler
|
||||||
|
import app.folio.ui.component.AppButton
|
||||||
|
import app.folio.ui.component.BackButton
|
||||||
|
import app.folio.ui.component.ButtonStyle
|
||||||
|
import app.folio.ui.component.EmptyState
|
||||||
|
import app.folio.ui.component.ErrorText
|
||||||
|
import app.folio.ui.component.FieldLabel
|
||||||
|
import app.folio.ui.component.Header
|
||||||
|
import app.folio.ui.component.Screen
|
||||||
|
import app.folio.ui.component.Segmented
|
||||||
|
import app.folio.ui.component.TextInput
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddTransactionRoute(accountId: String) {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val vm: AddTransactionViewModel = viewModel(key = "addTxn:$accountId") {
|
||||||
|
AddTransactionViewModel(component.repository, component.navigator, accountId)
|
||||||
|
}
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
AddTransactionScreen(state = state, onEvent = vm::onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddTransactionScreen(state: AddTransactionUiState, onEvent: (AddTransactionEvent) -> Unit) {
|
||||||
|
BackHandler { onEvent(AddTransactionEvent.Back) }
|
||||||
|
|
||||||
|
val account = state.account
|
||||||
|
if (account == null) {
|
||||||
|
Screen(
|
||||||
|
header = {
|
||||||
|
Header(title = "Add transaction", left = { BackButton { onEvent(AddTransactionEvent.BackToHome) } })
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
EmptyState(title = "Account not found", subtitle = "")
|
||||||
|
AppButton(
|
||||||
|
text = "Back to accounts",
|
||||||
|
onClick = { onEvent(AddTransactionEvent.BackToHome) },
|
||||||
|
style = ButtonStyle.Secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen(
|
||||||
|
modifier = Modifier.testTag("AddTransactionScreen"),
|
||||||
|
header = {
|
||||||
|
Header(
|
||||||
|
title = "Add transaction",
|
||||||
|
subtitle = account.name,
|
||||||
|
left = { BackButton { onEvent(AddTransactionEvent.Back) } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
AppButton(
|
||||||
|
text = if (state.type == TxnType.credit) "Add credit" else "Add debit",
|
||||||
|
onClick = { onEvent(AddTransactionEvent.Submit) },
|
||||||
|
style = ButtonStyle.Primary,
|
||||||
|
enabled = state.amount.isNotBlank(),
|
||||||
|
testTag = "TxnSubmit",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Segmented(
|
||||||
|
selected = if (state.type == TxnType.credit) 0 else 1,
|
||||||
|
labels = listOf("Credit", "Debit"),
|
||||||
|
onSelect = {
|
||||||
|
onEvent(AddTransactionEvent.TypeChange(if (it == 0) TxnType.credit else TxnType.debit))
|
||||||
|
},
|
||||||
|
testTags = listOf("TxnTypeCredit", "TxnTypeDebit"),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FieldLabel("Amount")
|
||||||
|
TextInput(
|
||||||
|
value = state.amount,
|
||||||
|
onChange = { onEvent(AddTransactionEvent.AmountChange(it)) },
|
||||||
|
placeholder = "0.00",
|
||||||
|
invalid = state.error.isNotEmpty(),
|
||||||
|
keyboardType = KeyboardType.Decimal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
textStyle = Type.amountInput,
|
||||||
|
label = "Amount",
|
||||||
|
testTag = "TxnAmountField",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FieldLabel("Note (optional)")
|
||||||
|
TextInput(
|
||||||
|
value = state.note,
|
||||||
|
onChange = { onEvent(AddTransactionEvent.NoteChange(it)) },
|
||||||
|
placeholder = "What's this for?",
|
||||||
|
label = "Note",
|
||||||
|
testTag = "TxnNoteField",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ErrorText(state.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package app.folio.feature.ledger
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.folio.core.data.Account
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.core.data.TxnType
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import app.folio.util.parseCents
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
|
||||||
|
|
||||||
|
data class AddTransactionUiState(
|
||||||
|
val account: Account? = null,
|
||||||
|
val type: TxnType = TxnType.credit,
|
||||||
|
val amount: String = "",
|
||||||
|
val note: String = "",
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface AddTransactionEvent {
|
||||||
|
data class TypeChange(val type: TxnType) : AddTransactionEvent
|
||||||
|
data class AmountChange(val value: String) : AddTransactionEvent
|
||||||
|
data class NoteChange(val value: String) : AddTransactionEvent
|
||||||
|
data object Submit : AddTransactionEvent
|
||||||
|
data object Back : AddTransactionEvent
|
||||||
|
data object BackToHome : AddTransactionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddTransactionViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val navigator: Navigator,
|
||||||
|
private val accountId: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val form = MutableStateFlow(AddTransactionUiState())
|
||||||
|
|
||||||
|
val state: StateFlow<AddTransactionUiState> = 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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
package app.folio.feature.ledger
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.folio.LocalAppComponent
|
||||||
|
import app.folio.core.data.TxnType
|
||||||
|
import app.folio.util.balanceOf
|
||||||
|
import app.folio.util.formatCents
|
||||||
|
import app.folio.util.formatDate
|
||||||
|
import app.folio.ui.BackHandler
|
||||||
|
import app.folio.ui.component.AppButton
|
||||||
|
import app.folio.ui.component.BackButton
|
||||||
|
import app.folio.ui.component.ButtonStyle
|
||||||
|
import app.folio.ui.component.Card
|
||||||
|
import app.folio.ui.component.EmptyState
|
||||||
|
import app.folio.ui.component.Header
|
||||||
|
import app.folio.ui.component.Screen
|
||||||
|
import app.folio.ui.icon.Icons
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusLg
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LedgerRoute(accountId: String) {
|
||||||
|
val component = LocalAppComponent.current
|
||||||
|
val vm: LedgerViewModel = viewModel(key = "ledger:$accountId") {
|
||||||
|
LedgerViewModel(component.repository, component.navigator, accountId)
|
||||||
|
}
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
LedgerScreen(state = state, onEvent = vm::onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LedgerScreen(state: LedgerUiState, onEvent: (LedgerEvent) -> Unit) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
|
||||||
|
BackHandler { onEvent(LedgerEvent.Back) }
|
||||||
|
|
||||||
|
val account = state.account
|
||||||
|
if (account == null) {
|
||||||
|
Screen(
|
||||||
|
header = {
|
||||||
|
Header(title = "Account", left = { BackButton { onEvent(LedgerEvent.Back) } })
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
EmptyState(title = "Account not found", subtitle = "It may have been deleted.")
|
||||||
|
AppButton(
|
||||||
|
text = "Back to accounts",
|
||||||
|
onClick = { onEvent(LedgerEvent.BackToHome) },
|
||||||
|
style = ButtonStyle.Secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val balance = balanceOf(state.transactions)
|
||||||
|
|
||||||
|
Screen(
|
||||||
|
modifier = Modifier.testTag("LedgerScreen"),
|
||||||
|
header = {
|
||||||
|
Header(
|
||||||
|
title = account.name,
|
||||||
|
subtitle = "Ledger",
|
||||||
|
left = { BackButton { onEvent(LedgerEvent.Back) } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
footer = {
|
||||||
|
AppButton(
|
||||||
|
text = "+ Add transaction",
|
||||||
|
onClick = { onEvent(LedgerEvent.AddTransaction) },
|
||||||
|
style = ButtonStyle.Primary,
|
||||||
|
testTag = "AddTransactionButton",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Card {
|
||||||
|
Text("BALANCE", style = Type.label, color = t.textMuted)
|
||||||
|
Text(
|
||||||
|
formatCents(balance),
|
||||||
|
style = Type.balance,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.testTag("LedgerBalance"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"ACTIVITY",
|
||||||
|
style = Type.label,
|
||||||
|
color = t.textFaint,
|
||||||
|
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
|
||||||
|
)
|
||||||
|
if (state.transactions.isEmpty()) {
|
||||||
|
EmptyState(
|
||||||
|
title = "No transactions yet",
|
||||||
|
subtitle = "Add your first credit or debit to see it here.",
|
||||||
|
icon = Icons.Lines,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusLg))
|
||||||
|
.background(t.surface)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
state.transactions.forEachIndexed { i, txn ->
|
||||||
|
TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
|
||||||
|
if (i != state.transactions.lastIndex) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
val signed = if (type == TxnType.credit) amount else -amount
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 14.dp)
|
||||||
|
.testTag("LedgerRow"),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(t.surface2)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(10.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(if (type == TxnType.credit) "+" else "-", style = Type.bodyStrong, color = t.text)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (note.isNotEmpty()) note else if (type == TxnType.credit) "Credit" else "Debit",
|
||||||
|
style = Type.body,
|
||||||
|
color = t.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(date, style = Type.caption, color = t.textFaint)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
formatCents(signed, signed = true),
|
||||||
|
style = Type.bodyStrong,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.testTag("TxnAmount"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package app.folio.feature.ledger
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.folio.core.data.Account
|
||||||
|
import app.folio.core.data.Repository
|
||||||
|
import app.folio.core.data.Transaction
|
||||||
|
import app.folio.navigation.Navigator
|
||||||
|
import app.folio.navigation.Route
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
data class LedgerUiState(
|
||||||
|
val account: Account? = null,
|
||||||
|
val transactions: List<Transaction> = emptyList(),
|
||||||
|
val accountId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface LedgerEvent {
|
||||||
|
data object Back : LedgerEvent
|
||||||
|
data object AddTransaction : LedgerEvent
|
||||||
|
data object BackToHome : LedgerEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class LedgerViewModel(
|
||||||
|
private val repository: Repository,
|
||||||
|
private val navigator: Navigator,
|
||||||
|
private val accountId: String,
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<LedgerUiState> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package app.folio.navigation
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class Navigator(initial: Route = Route.Home) {
|
||||||
|
private val stack = ArrayDeque<Route>().apply { addLast(initial) }
|
||||||
|
private val _current = MutableStateFlow<Route>(initial)
|
||||||
|
val current: StateFlow<Route> = _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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package app.folio.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
expect fun BackHandler(onBack: () -> Unit)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package app.folio.util
|
||||||
|
|
||||||
|
import app.folio.core.data.Transaction
|
||||||
|
import app.folio.core.data.TxnType
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
|
private const val CURRENCY = "$"
|
||||||
|
private val AMOUNT_REGEX = Regex("""^\d+(\.\d{1,2})?$""")
|
||||||
|
|
||||||
|
fun formatCents(cents: Long, signed: Boolean = false): String {
|
||||||
|
val abs = cents.absoluteValue
|
||||||
|
val dollars = abs / 100
|
||||||
|
val rem = (abs % 100).toInt()
|
||||||
|
val dollarsStr = withThousandsSeparator(dollars)
|
||||||
|
val remStr = rem.toString().padStart(2, '0')
|
||||||
|
val body = "$CURRENCY$dollarsStr.$remStr"
|
||||||
|
return when {
|
||||||
|
cents < 0 -> "-$body"
|
||||||
|
signed && cents > 0 -> "+$body"
|
||||||
|
else -> body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withThousandsSeparator(value: Long): String {
|
||||||
|
val s = value.toString()
|
||||||
|
if (s.length <= 3) return s
|
||||||
|
val out = StringBuilder()
|
||||||
|
var count = 0
|
||||||
|
for (i in s.length - 1 downTo 0) {
|
||||||
|
out.append(s[i])
|
||||||
|
count++
|
||||||
|
if (count == 3 && i != 0) {
|
||||||
|
out.append(',')
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.reverse().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCents(input: String): Long? {
|
||||||
|
val trimmed = input.trim().replace(",", "")
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
if (!AMOUNT_REGEX.matches(trimmed)) return null
|
||||||
|
val dot = trimmed.indexOf('.')
|
||||||
|
val whole: String
|
||||||
|
val frac: String
|
||||||
|
if (dot < 0) {
|
||||||
|
whole = trimmed
|
||||||
|
frac = ""
|
||||||
|
} else {
|
||||||
|
whole = trimmed.substring(0, dot)
|
||||||
|
frac = trimmed.substring(dot + 1)
|
||||||
|
}
|
||||||
|
val fracPadded = (frac + "00").substring(0, 2)
|
||||||
|
val wholeLong = whole.toLongOrNull() ?: return null
|
||||||
|
val fracLong = fracPadded.toLongOrNull() ?: return null
|
||||||
|
val total = wholeLong * 100 + fracLong
|
||||||
|
if (total < 0) return null
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
|
||||||
|
|
||||||
|
fun balanceOf(txns: List<Transaction>): Long = txns.sumOf { signedAmount(it) }
|
||||||
|
|
||||||
|
fun initialsOf(name: String): String {
|
||||||
|
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
if (parts.isEmpty()) return "?"
|
||||||
|
if (parts.size == 1) return parts[0].take(2).uppercase()
|
||||||
|
return (parts.first().first().toString() + parts.last().first().toString()).uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun formatDate(epochMillis: Long): String
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package app.folio
|
||||||
|
|
||||||
|
import androidx.compose.ui.window.ComposeUIViewController
|
||||||
|
import app.folio.core.data.DriverFactory
|
||||||
|
import app.folio.ui.IosBackGesture
|
||||||
|
import kotlinx.cinterop.BetaInteropApi
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.ObjCAction
|
||||||
|
import platform.Foundation.NSSelectorFromString
|
||||||
|
import platform.UIKit.UIGestureRecognizerStateEnded
|
||||||
|
import platform.UIKit.UIRectEdgeLeft
|
||||||
|
import platform.UIKit.UIScreenEdgePanGestureRecognizer
|
||||||
|
import platform.UIKit.UIViewController
|
||||||
|
import platform.darwin.NSObject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
|
||||||
|
class BackGestureTarget : NSObject() {
|
||||||
|
@ObjCAction
|
||||||
|
fun handleGesture(gesture: UIScreenEdgePanGestureRecognizer) {
|
||||||
|
if (gesture.state == UIGestureRecognizerStateEnded) {
|
||||||
|
IosBackGesture.dispatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val backGestureTarget = BackGestureTarget()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
fun MainViewController(): UIViewController {
|
||||||
|
val driverFactory = DriverFactory()
|
||||||
|
val vc = ComposeUIViewController { App(driverFactory) }
|
||||||
|
val gesture = UIScreenEdgePanGestureRecognizer(
|
||||||
|
target = backGestureTarget,
|
||||||
|
action = NSSelectorFromString("handleGesture:"),
|
||||||
|
)
|
||||||
|
gesture.edges = UIRectEdgeLeft
|
||||||
|
vc.view.addGestureRecognizer(gesture)
|
||||||
|
return vc
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package app.folio.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BackHandler(onBack: () -> Unit) {
|
||||||
|
val current by rememberUpdatedState(onBack)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val id = IosBackGesture.register { current() }
|
||||||
|
onDispose { IosBackGesture.unregister(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package app.folio.ui
|
||||||
|
|
||||||
|
object IosBackGesture {
|
||||||
|
private var nextId = 0
|
||||||
|
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
|
||||||
|
|
||||||
|
fun register(onBack: () -> Unit): Int {
|
||||||
|
val id = nextId++
|
||||||
|
callbacks.add(id to onBack)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(id: Int) {
|
||||||
|
callbacks.removeAll { it.first == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatch(): Boolean {
|
||||||
|
val top = callbacks.lastOrNull() ?: return false
|
||||||
|
top.second()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package app.folio.util
|
||||||
|
|
||||||
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.NSDateFormatter
|
||||||
|
import platform.Foundation.NSLocale
|
||||||
|
import platform.Foundation.dateWithTimeIntervalSince1970
|
||||||
|
|
||||||
|
private val DATE_FMT = NSDateFormatter().apply {
|
||||||
|
locale = NSLocale("en_US_POSIX")
|
||||||
|
dateFormat = "MMM d, h:mm a"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun date(epochMillis: Long): NSDate =
|
||||||
|
NSDate.dateWithTimeIntervalSince1970(epochMillis / 1000.0)
|
||||||
|
|
||||||
|
actual fun formatDate(epochMillis: Long): String = DATE_FMT.stringFromDate(date(epochMillis))
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app.folio.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BackHandler(onBack: () -> Unit) {
|
||||||
|
val current by rememberUpdatedState(onBack)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val id = WebBackGesture.register { current() }
|
||||||
|
onDispose { WebBackGesture.unregister(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object WebBackGesture {
|
||||||
|
private var nextId = 0
|
||||||
|
private val callbacks = mutableListOf<Pair<Int, () -> Unit>>()
|
||||||
|
|
||||||
|
fun register(onBack: () -> Unit): Int {
|
||||||
|
val id = nextId++
|
||||||
|
callbacks.add(id to onBack)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(id: Int) {
|
||||||
|
callbacks.removeAll { it.first == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatch(): Boolean {
|
||||||
|
val top = callbacks.lastOrNull() ?: return false
|
||||||
|
top.second()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package app.folio.util
|
||||||
|
|
||||||
|
private val MONTHS = arrayOf(
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun formatDate(epochMillis: Long): String {
|
||||||
|
val month = jsDateMonth(epochMillis.toDouble())
|
||||||
|
val day = jsDateDay(epochMillis.toDouble())
|
||||||
|
val hour24 = jsDateHours(epochMillis.toDouble())
|
||||||
|
val minute = jsDateMinutes(epochMillis.toDouble())
|
||||||
|
val am = hour24 < 12
|
||||||
|
val hour12 = when {
|
||||||
|
hour24 == 0 -> 12
|
||||||
|
hour24 > 12 -> hour24 - 12
|
||||||
|
else -> hour24
|
||||||
|
}
|
||||||
|
val minuteStr = minute.toString().padStart(2, '0')
|
||||||
|
val suffix = if (am) "AM" else "PM"
|
||||||
|
return "${MONTHS[month]} $day, $hour12:$minuteStr $suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsDateMonth(ms: Double): Int = js("new Date(ms).getMonth()")
|
||||||
|
private fun jsDateDay(ms: Double): Int = js("new Date(ms).getDate()")
|
||||||
|
private fun jsDateHours(ms: Double): Int = js("new Date(ms).getHours()")
|
||||||
|
private fun jsDateMinutes(ms: Double): Int = js("new Date(ms).getMinutes()")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.plugin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
optIn.add("kotlin.js.ExperimentalWasmJsInterop")
|
||||||
|
}
|
||||||
|
|
||||||
|
androidTarget {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs { browser() }
|
||||||
|
|
||||||
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(compose.runtime)
|
||||||
|
api(compose.foundation)
|
||||||
|
api(compose.material3)
|
||||||
|
api(compose.ui)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.folio.ui"
|
||||||
|
compileSdk = 36
|
||||||
|
defaultConfig { minSdk = 24 }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusMd
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
enum class ButtonStyle { Primary, Secondary, Ghost }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: ButtonStyle = ButtonStyle.Secondary,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
testTag: String? = null,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
val (bg, fg, border) = when (style) {
|
||||||
|
ButtonStyle.Primary -> Triple(t.text, t.onAccent, t.text)
|
||||||
|
ButtonStyle.Secondary -> Triple(t.surface2, t.text, t.borderStrong)
|
||||||
|
ButtonStyle.Ghost -> Triple(t.bg, t.textMuted, t.bg)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusMd))
|
||||||
|
.background(if (enabled) bg else t.surface3)
|
||||||
|
.border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd))
|
||||||
|
.then(if (testTag != null) Modifier.testTag(testTag) else Modifier)
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.padding(vertical = 14.dp, horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(text, style = Type.button, color = if (enabled) fg else t.textFaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusLg
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Card(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dashed: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusLg))
|
||||||
|
.background(if (dashed) t.surface2 else t.surface)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(RadiusLg))
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EmptyState(title: String, subtitle: String, icon: ImageVector? = null) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.background(t.surface2, CircleShape)
|
||||||
|
.border(1.dp, t.border, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = rememberVectorPainter(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = t.textMuted,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
}
|
||||||
|
Text(title, style = Type.bodyStrong, color = t.text, textAlign = TextAlign.Center)
|
||||||
|
Text(subtitle, style = Type.caption, color = t.textMuted, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorText(err: String?) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Text(
|
||||||
|
err ?: "",
|
||||||
|
style = Type.caption,
|
||||||
|
color = t.text,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FieldLabel(text: String) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Text(text.uppercase(), style = Type.label, color = t.textMuted)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.ScreenPad
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Header(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
left: @Composable (() -> Unit)? = null,
|
||||||
|
right: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = ScreenPad, end = ScreenPad, top = 14.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (left != null) left()
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Type.title,
|
||||||
|
color = t.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(subtitle, style = Type.caption, color = t.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (right != null) right()
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.icon.Icons
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
testTag: String? = null,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(t.surface2)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(10.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.then(if (testTag != null) Modifier.testTag(testTag) else Modifier)
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = rememberVectorPainter(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = t.text,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackButton(onClick: () -> Unit) {
|
||||||
|
IconButton(onClick = onClick, label = "Back", icon = Icons.Back, testTag = "BackButton")
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.ScreenPad
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Screen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
header: @Composable (() -> Unit)? = null,
|
||||||
|
footer: @Composable (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Column(Modifier.fillMaxSize().then(modifier)) {
|
||||||
|
if (header != null) header()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = ScreenPad, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
if (footer != null) {
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(t.bg)
|
||||||
|
.padding(horizontal = ScreenPad, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
footer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusMd
|
||||||
|
import app.folio.ui.theme.RadiusSm
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Segmented(
|
||||||
|
selected: Int,
|
||||||
|
labels: List<String>,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
testTags: List<String>? = null,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusMd))
|
||||||
|
.background(t.surface)
|
||||||
|
.border(1.dp, t.border, RoundedCornerShape(RadiusMd))
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
labels.forEachIndexed { i, label ->
|
||||||
|
val active = i == selected
|
||||||
|
val tag = testTags?.getOrNull(i)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(RadiusSm))
|
||||||
|
.background(if (active) t.surface3 else t.surface)
|
||||||
|
.then(if (tag != null) Modifier.testTag(tag) else Modifier)
|
||||||
|
.semantics { this.selected = active }
|
||||||
|
.clickable(role = Role.Tab) { onSelect(i) }
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, style = Type.button, color = if (active) t.text else t.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package app.folio.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.folio.ui.theme.LocalTokens
|
||||||
|
import app.folio.ui.theme.RadiusMd
|
||||||
|
import app.folio.ui.theme.Type
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextInput(
|
||||||
|
value: String,
|
||||||
|
onChange: (String) -> Unit,
|
||||||
|
placeholder: String = "",
|
||||||
|
invalid: Boolean = false,
|
||||||
|
password: Boolean = false,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
textAlign: TextAlign = TextAlign.Start,
|
||||||
|
textStyle: TextStyle = Type.body,
|
||||||
|
label: String,
|
||||||
|
testTag: String? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val t = LocalTokens.current
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val borderColor = if (focused || invalid) t.text else t.border
|
||||||
|
val bg = if (focused) t.surface2 else t.surface
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(RadiusMd))
|
||||||
|
.background(bg)
|
||||||
|
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(RadiusMd))
|
||||||
|
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||||
|
contentAlignment = if (textAlign == TextAlign.Center) Alignment.Center else Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (value.isEmpty() && placeholder.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
placeholder,
|
||||||
|
style = textStyle.copy(color = t.textFaint, textAlign = textAlign),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onChange,
|
||||||
|
textStyle = textStyle.copy(color = t.text, textAlign = textAlign),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||||
|
cursorBrush = SolidColor(t.text),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.then(if (testTag != null) Modifier.testTag(testTag) else Modifier)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
if (invalid) stateDescription = "Invalid"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package app.folio.ui.icon
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object Icons {
|
||||||
|
val Back: ImageVector = strokeIcon("M15 18 L9 12 L15 6")
|
||||||
|
|
||||||
|
val Logout: ImageVector = strokeIcon(
|
||||||
|
"M9 21 H5 A2 2 0 0 1 3 19 V5 A2 2 0 0 1 5 3 H9 " +
|
||||||
|
"M16 17 L21 12 L16 7 " +
|
||||||
|
"M21 12 H9",
|
||||||
|
)
|
||||||
|
|
||||||
|
val Bank: ImageVector = strokeIcon(
|
||||||
|
"M3 6 H21 V19 A2 2 0 0 1 19 21 H5 A2 2 0 0 1 3 19 Z " +
|
||||||
|
"M3 10 H21",
|
||||||
|
)
|
||||||
|
|
||||||
|
val Lines: ImageVector = strokeIcon(
|
||||||
|
"M3 6 H21 " +
|
||||||
|
"M3 12 H21 " +
|
||||||
|
"M3 18 H15",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun strokeIcon(path: String): ImageVector =
|
||||||
|
ImageVector.Builder(
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
).addPath(
|
||||||
|
pathData = addPathNodes(path),
|
||||||
|
stroke = SolidColor(Color.Black),
|
||||||
|
strokeLineWidth = 2f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
|
).build()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package app.folio.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
data class Tokens(
|
||||||
|
val bg: Color = Color(0xFFFFFFFF),
|
||||||
|
val surface: Color = Color(0xFFFAFAFA),
|
||||||
|
val surface2: Color = Color(0xFFF4F4F4),
|
||||||
|
val surface3: Color = Color(0xFFEBEBEB),
|
||||||
|
val border: Color = Color(0xFFE7E7E7),
|
||||||
|
val borderStrong: Color = Color(0xFFD6D6D6),
|
||||||
|
val text: Color = Color(0xFF000000),
|
||||||
|
val textMuted: Color = Color(0xFF6B6B6B),
|
||||||
|
val textFaint: Color = Color(0xFF9A9A9A),
|
||||||
|
val onAccent: Color = Color(0xFFFFFFFF),
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalTokens = staticCompositionLocalOf { Tokens() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LedgerTheme(content: @Composable () -> Unit) {
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val scheme = lightColorScheme(
|
||||||
|
background = Color(0xFFFFFFFF),
|
||||||
|
surface = Color(0xFFFAFAFA),
|
||||||
|
primary = Color(0xFF000000),
|
||||||
|
onPrimary = Color(0xFFFFFFFF),
|
||||||
|
)
|
||||||
|
MaterialTheme(colorScheme = scheme) { content() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val FrameWidth = 420.dp
|
||||||
|
val FrameHeight = 900.dp
|
||||||
|
val ScreenPad = 20.dp
|
||||||
|
val RadiusSm = 8.dp
|
||||||
|
val RadiusMd = 12.dp
|
||||||
|
val RadiusLg = 16.dp
|
||||||
|
val RadiusPill = 999.dp
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package app.folio.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Mono: FontFamily = FontFamily.Monospace
|
||||||
|
|
||||||
|
object Type {
|
||||||
|
val body = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Normal)
|
||||||
|
val bodyStrong = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
val title = TextStyle(fontFamily = Mono, fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
||||||
|
val caption = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal)
|
||||||
|
val label = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.7.sp)
|
||||||
|
val balance = TextStyle(fontFamily = Mono, fontSize = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
||||||
|
val amountInput = TextStyle(fontFamily = Mono, fontSize = 36.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
||||||
|
val button = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
val brand = TextStyle(fontFamily = Mono, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.13.2" apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
id("org.jetbrains.compose") version "1.10.3" apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
id("app.cash.sqldelight") version "2.3.2" apply false
|
alias(libs.plugins.kotlin.plugin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
||||||
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
|
alias(libs.plugins.sqldelight) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.metro) apply false
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.sqldelight)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
optIn.add("kotlin.js.ExperimentalWasmJsInterop")
|
||||||
|
}
|
||||||
|
|
||||||
|
androidTarget {
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs { browser() }
|
||||||
|
|
||||||
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
api(libs.sqldelight.runtime)
|
||||||
|
api(libs.sqldelight.coroutines.extensions)
|
||||||
|
api(libs.sqldelight.async.extensions)
|
||||||
|
}
|
||||||
|
androidMain.dependencies {
|
||||||
|
implementation(libs.sqldelight.android.driver)
|
||||||
|
}
|
||||||
|
iosMain.dependencies {
|
||||||
|
implementation(libs.sqldelight.native.driver)
|
||||||
|
}
|
||||||
|
wasmJsMain.dependencies {
|
||||||
|
implementation(libs.sqldelight.web.worker.driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.folio.core"
|
||||||
|
compileSdk = 36
|
||||||
|
defaultConfig { minSdk = 24 }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqldelight {
|
||||||
|
databases {
|
||||||
|
create("LedgerDatabase") {
|
||||||
|
packageName.set("app.folio.db")
|
||||||
|
generateAsync.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.cash.sqldelight.async.coroutines.synchronous
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||||
|
import app.folio.db.LedgerDatabase
|
||||||
|
|
||||||
|
actual class DriverFactory(private val context: Context) {
|
||||||
|
actual suspend fun create(): SqlDriver =
|
||||||
|
AndroidSqliteDriver(LedgerDatabase.Schema.synchronous(), context, "ledger.db")
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package app.folio.core.platform
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
actual object Platform {
|
||||||
|
actual fun now(): Long = System.currentTimeMillis()
|
||||||
|
actual fun makeId(): String = UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
data class Account(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
|
||||||
|
expect class DriverFactory {
|
||||||
|
suspend fun create(): SqlDriver
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface LedgerStore {
|
||||||
|
val accounts: StateFlow<List<Account>>
|
||||||
|
val transactions: StateFlow<List<Transaction>>
|
||||||
|
val session: StateFlow<Session?>
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import app.folio.core.platform.Platform
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class Repository(private val store: LedgerStore) {
|
||||||
|
val accounts: StateFlow<List<Account>> = store.accounts
|
||||||
|
val transactions: StateFlow<List<Transaction>> = store.transactions
|
||||||
|
val session: StateFlow<Session?> = store.session
|
||||||
|
|
||||||
|
suspend fun createAccount(name: String): Account {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
require(trimmed.isNotEmpty()) { "Name is required" }
|
||||||
|
require(trimmed.length <= 40) { "Name is too long (max 40)" }
|
||||||
|
require(!store.accountExistsByName(trimmed)) { "An account with that name already exists" }
|
||||||
|
val account = Account(id = Platform.makeId(), name = trimmed, createdAt = Platform.now())
|
||||||
|
store.insertAccount(account.id, account.name, account.createdAt)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAccount(id: String): Account? = accounts.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
|
||||||
|
require(amount > 0) { "Amount must be greater than zero" }
|
||||||
|
requireNotNull(getAccount(accountId)) { "Account not found" }
|
||||||
|
val txn = Transaction(
|
||||||
|
id = Platform.makeId(),
|
||||||
|
accountId = accountId,
|
||||||
|
type = type,
|
||||||
|
amount = amount,
|
||||||
|
note = note.trim().take(80),
|
||||||
|
createdAt = Platform.now(),
|
||||||
|
)
|
||||||
|
store.insertTxn(txn.id, txn.accountId, txn.type, txn.amount, txn.note, txn.createdAt)
|
||||||
|
return txn
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSession(user: String) = store.upsertSession(user, Platform.now())
|
||||||
|
suspend fun clearSession() = store.clearSession()
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val user: String,
|
||||||
|
val loggedInAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import app.cash.sqldelight.coroutines.asFlow
|
||||||
|
import app.cash.sqldelight.coroutines.mapToList
|
||||||
|
import app.cash.sqldelight.coroutines.mapToOneOrNull
|
||||||
|
import app.folio.db.LedgerDatabase
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
class SqlLedgerStore(db: LedgerDatabase) : LedgerStore {
|
||||||
|
private val queries = db.ledgerQueries
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
override val accounts: StateFlow<List<Account>> =
|
||||||
|
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<List<Transaction>> =
|
||||||
|
queries.selectAllTxns()
|
||||||
|
.asFlow()
|
||||||
|
.mapToList(Dispatchers.Default)
|
||||||
|
.map { rows -> rows.map { it.toDomain() } }
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
override val session: StateFlow<Session?> =
|
||||||
|
queries.selectSession()
|
||||||
|
.asFlow()
|
||||||
|
.mapToOneOrNull(Dispatchers.Default)
|
||||||
|
.map { row -> row?.let { Session(it.user, it.loggedInAt) } }
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
override suspend fun accountExistsByName(name: String): Boolean =
|
||||||
|
queries.accountExistsByName(name).awaitAsOne()
|
||||||
|
|
||||||
|
override suspend fun insertAccount(id: String, name: String, createdAt: Long) {
|
||||||
|
queries.insertAccount(id, name, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
|
||||||
|
queries.insertTxn(id, accountId, type.name, amount, note, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertSession(user: String, loggedInAt: Long) {
|
||||||
|
queries.upsertSession(user, loggedInAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearSession() {
|
||||||
|
queries.clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun app.folio.db.Txns.toDomain(): Transaction =
|
||||||
|
Transaction(
|
||||||
|
id = id,
|
||||||
|
accountId = accountId,
|
||||||
|
type = TxnType.valueOf(type),
|
||||||
|
amount = amount,
|
||||||
|
note = note,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
enum class TxnType { credit, debit }
|
||||||
|
|
||||||
|
data class Transaction(
|
||||||
|
val id: String,
|
||||||
|
val accountId: String,
|
||||||
|
val type: TxnType,
|
||||||
|
val amount: Long,
|
||||||
|
val note: String,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package app.folio.core.platform
|
||||||
|
|
||||||
|
expect object Platform {
|
||||||
|
fun now(): Long
|
||||||
|
fun makeId(): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
CREATE TABLE Accounts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
createdAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Txns (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
accountId TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX txn_account ON Txns(accountId);
|
||||||
|
CREATE INDEX txn_created ON Txns(createdAt);
|
||||||
|
|
||||||
|
CREATE TABLE AppSession (
|
||||||
|
key INTEGER NOT NULL PRIMARY KEY CHECK (key = 1),
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
loggedInAt INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
selectAllAccounts:
|
||||||
|
SELECT * FROM Accounts ORDER BY createdAt ASC;
|
||||||
|
|
||||||
|
accountExistsByName:
|
||||||
|
SELECT EXISTS(SELECT 1 FROM Accounts WHERE name = :name COLLATE NOCASE);
|
||||||
|
|
||||||
|
insertAccount:
|
||||||
|
INSERT INTO Accounts(id, name, createdAt) VALUES (?, ?, ?);
|
||||||
|
|
||||||
|
selectAllTxns:
|
||||||
|
SELECT * FROM Txns;
|
||||||
|
|
||||||
|
insertTxn:
|
||||||
|
INSERT INTO Txns(id, accountId, type, amount, note, createdAt) VALUES (?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
|
selectSession:
|
||||||
|
SELECT user, loggedInAt FROM AppSession WHERE key = 1;
|
||||||
|
|
||||||
|
upsertSession:
|
||||||
|
INSERT OR REPLACE INTO AppSession(key, user, loggedInAt) VALUES (1, ?, ?);
|
||||||
|
|
||||||
|
clearSession:
|
||||||
|
DELETE FROM AppSession WHERE key = 1;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package app.folio.core.platform
|
||||||
|
|
||||||
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.NSUUID
|
||||||
|
import platform.Foundation.timeIntervalSince1970
|
||||||
|
|
||||||
|
actual object Platform {
|
||||||
|
actual fun now(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong()
|
||||||
|
actual fun makeId(): String = NSUUID().UUIDString
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package app.folio.core.data
|
||||||
|
|
||||||
|
import app.cash.sqldelight.async.coroutines.awaitCreate
|
||||||
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
import app.cash.sqldelight.driver.worker.WebWorkerDriver
|
||||||
|
import app.folio.db.LedgerDatabase
|
||||||
|
import org.w3c.dom.Worker
|
||||||
|
|
||||||
|
actual class DriverFactory {
|
||||||
|
actual suspend fun create(): SqlDriver {
|
||||||
|
val worker = Worker(workerScriptUrl())
|
||||||
|
val driver = WebWorkerDriver(worker)
|
||||||
|
LedgerDatabase.Schema.awaitCreate(driver)
|
||||||
|
return driver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun workerScriptUrl(): String =
|
||||||
|
js("""new URL("./sqlite.worker.js", import.meta.url).toString()""")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package app.folio.core.platform
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
actual object Platform {
|
||||||
|
actual fun now(): Long = jsNow().toLong()
|
||||||
|
actual fun makeId(): String = cryptoRandomUuid() ?: fallbackUuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsNow(): Double = js("Date.now()")
|
||||||
|
|
||||||
|
private fun cryptoRandomUuid(): String? = js(
|
||||||
|
"(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') ? crypto.randomUUID() : null"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fallbackUuid(): String {
|
||||||
|
val bytes = ByteArray(16) { Random.nextInt(0, 256).toByte() }
|
||||||
|
bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x40).toByte()
|
||||||
|
bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte()
|
||||||
|
val hex = bytes.joinToString("") { ((it.toInt() and 0xff) or 0x100).toString(16).substring(1) }
|
||||||
|
return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}"
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
|||||||
|
|
||||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", 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-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
sqldelight-native-driver = { module = "app.cash.sqldelight:native-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" }
|
sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" }
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||||
|
|
||||||
rootProject.name = "folio"
|
rootProject.name = "folio"
|
||||||
|
|
||||||
include(":composeApp")
|
include(":core")
|
||||||
|
include(":app:shared")
|
||||||
|
include(":app:ui-components")
|
||||||
|
include(":app:androidApp")
|
||||||
Reference in new issue
Block a user