mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-03 19:47:10 +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
+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)}"
|
||||
}
|
||||
Reference in new issue
Block a user