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:
pj committed 2026-04-26 14:30:59 +07:00
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 ...).")
}
@@ -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,
)
}
}
}
@@ -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)
}
}
}
}
@@ -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"),
)
}
}
@@ -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))
}
}
}
@@ -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)
}
}
}
@@ -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") }
}
}
}
}
@@ -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"),
)
}
}
@@ -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
}
}
@@ -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() }
}
@@ -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)
}
}
@@ -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),
)
}
@@ -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)
}
@@ -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()
}
}
@@ -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")
}
@@ -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()
}
}
}
}
@@ -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)
}
}
}
}
@@ -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)
}
+10 -5
View File
@@ -1,7 +1,12 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
id("org.jetbrains.compose") version "1.10.3" apply false
id("app.cash.sqldelight") version "2.3.2" apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.plugin.compose) apply false
alias(libs.plugins.kotlin.plugin.serialization) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.sqldelight) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.metro) apply false
}
+67
View File
@@ -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)
}
}
}
@@ -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)}"
}
+1
View File
@@ -17,6 +17,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-async-extensions = { module = "app.cash.sqldelight:async-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" }
+6 -1
View File
@@ -15,6 +15,11 @@ dependencyResolutionManagement {
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "folio"
include(":composeApp")
include(":core")
include(":app:shared")
include(":app:ui-components")
include(":app:androidApp")