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,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)}"
}