mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-05 04:27:09 +00:00
refactor(folio): delete :composeApp and retarget tooling
Removes the old monolithic module now that :core / :app:shared / :app:ui-components / :app:androidApp own the source. Updates: - justfile install/uninstall recipes -> :app:androidApp - iosApp/project.yml framework path -> ../app/shared/..., baseName Shared (was ComposeApp); pre-build script invokes :app:shared:linkDebugFrameworkIosSimulatorArm64 - iosApp/iosApp/iOSApp.swift -> import Shared - README -> mentions SQLDelight unification, drops the data-carrier contentDescription notes (now stale), no Layout section per repo convention
This commit is contained in:
1 parent
5121f777f2
commit
3288364424
63 files changed
+21
-2370
No files matched your search
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
A minimal Kotlin Multiplatform personal-ledger app: login with demo
|
A minimal Kotlin Multiplatform personal-ledger app: login with demo
|
||||||
credentials, create accounts, add credits and debits. Shared UI across
|
credentials, create accounts, add credits and debits. Shared UI across
|
||||||
Android, iOS, and Web via Compose Multiplatform. Doubles as the example
|
Android and iOS via Compose Multiplatform. Doubles as the example
|
||||||
sanderling runs its property-based specs against.
|
sanderling runs its property-based specs against.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
- Kotlin Multiplatform + Compose Multiplatform (shared UI)
|
||||||
- kotlinx.serialization for file-backed persistence
|
- SQLDelight for the data layer (unified across platforms)
|
||||||
- kotlinx.coroutines for state flows
|
- kotlinx.coroutines for state flows
|
||||||
|
- kotlinx.serialization for `@Serializable` route types
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -35,8 +36,8 @@ IOS_DEVICE="iPhone 15" just ios # pick a different simulator
|
|||||||
```
|
```
|
||||||
|
|
||||||
`just ios` regenerates `iosApp/iosApp.xcodeproj` from `iosApp/project.yml`,
|
`just ios` regenerates `iosApp/iosApp.xcodeproj` from `iosApp/project.yml`,
|
||||||
builds the KMP framework, links it into the SwiftUI host, installs, and
|
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
|
||||||
launches.
|
into the SwiftUI host, installs, and launches.
|
||||||
|
|
||||||
## Demo credentials
|
## Demo credentials
|
||||||
|
|
||||||
@@ -69,9 +70,13 @@ Traces land in `./sanderling/runs/<timestamp>/`.
|
|||||||
|
|
||||||
## How it connects to sanderling
|
## How it connects to sanderling
|
||||||
|
|
||||||
- UI elements expose state via content descriptions (`account:$id:$balance`,
|
- Each screen sets a stable Compose `testTag` (`HomeScreen`, `AccountCard`,
|
||||||
`ledger_row:$id:$signed`, `ledger_balance:$cents`, `active_account:$id`,
|
`LedgerRow`, `TxnAmount`, ...). The Sanderling SDK resolves `testTag` to
|
||||||
`focused_input:$field`)
|
`resource-id` on Android and `accessibilityIdentifier` on iOS.
|
||||||
|
- Identity for list items is the visible text content (account name; txn
|
||||||
|
note + amount). No synthetic IDs encoded in semantics.
|
||||||
|
- `contentDescription` is reserved for real accessibility labels, never as a
|
||||||
|
data carrier.
|
||||||
- `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`,
|
- `sanderling/spec.ts` imports `@sanderling/spec`, reads state via `s.ax.*`,
|
||||||
asserts properties, and weights the actions the fuzzer picks from
|
asserts properties, and weights the actions the fuzzer picks from.
|
||||||
- `just test` invokes `sanderling test` against the installed APK
|
- `just test` invokes `sanderling test` against the installed APK.
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
|
||||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
kotlin("multiplatform")
|
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
|
||||||
id("org.jetbrains.compose")
|
|
||||||
id("app.cash.sqldelight")
|
|
||||||
}
|
|
||||||
|
|
||||||
val sanderlingVersion = findProperty("sanderling.version") as String? ?: "0.0.0-dev"
|
|
||||||
val sqldelightVersion = "2.3.2"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
|
||||||
compilerOptions {
|
|
||||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
|
||||||
optIn.add("kotlin.js.ExperimentalWasmJsInterop")
|
|
||||||
}
|
|
||||||
|
|
||||||
androidTarget {
|
|
||||||
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listOf(
|
|
||||||
iosX64(),
|
|
||||||
iosArm64(),
|
|
||||||
iosSimulatorArm64(),
|
|
||||||
).forEach { target ->
|
|
||||||
target.binaries.framework {
|
|
||||||
baseName = "ComposeApp"
|
|
||||||
isStatic = true
|
|
||||||
binaryOption("bundleId", "app.folio")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
outputModuleName.set("composeApp")
|
|
||||||
browser {
|
|
||||||
commonWebpackConfig {
|
|
||||||
outputFileName = "composeApp.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
|
|
||||||
applyHierarchyTemplate {
|
|
||||||
common {
|
|
||||||
group("sql") {
|
|
||||||
withAndroidTarget()
|
|
||||||
group("apple") {
|
|
||||||
group("ios") {
|
|
||||||
withIosX64()
|
|
||||||
withIosArm64()
|
|
||||||
withIosSimulatorArm64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group("wasmJs") {
|
|
||||||
withWasmJs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
implementation(compose.ui)
|
|
||||||
implementation(compose.components.resources)
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
|
||||||
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
|
|
||||||
implementation("app.cash.sqldelight:coroutines-extensions:$sqldelightVersion")
|
|
||||||
}
|
|
||||||
|
|
||||||
androidMain.dependencies {
|
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
|
||||||
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
|
||||||
}
|
|
||||||
|
|
||||||
iosMain.dependencies {
|
|
||||||
implementation("app.cash.sqldelight:native-driver:$sqldelightVersion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "app.folio"
|
|
||||||
compileSdk = 36
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "app.folio"
|
|
||||||
minSdk = 24
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = 1
|
|
||||||
versionName = sanderlingVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
isDebuggable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets["main"].apply {
|
|
||||||
manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
|
||||||
res.srcDirs("src/androidMain/res")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqldelight {
|
|
||||||
databases {
|
|
||||||
create("LedgerDatabase") {
|
|
||||||
packageName.set("app.folio.db")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<application
|
|
||||||
android:name=".FolioApplication"
|
|
||||||
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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import app.folio.data.AndroidLedgerContext
|
|
||||||
import app.folio.data.Repository
|
|
||||||
|
|
||||||
class FolioApplication : Application() {
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
AndroidLedgerContext.context = applicationContext
|
|
||||||
Repository.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContent { App() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
|
||||||
import app.folio.db.LedgerDatabase
|
|
||||||
|
|
||||||
actual class DriverFactory(private val context: Context) {
|
|
||||||
actual fun create(): SqlDriver =
|
|
||||||
AndroidSqliteDriver(LedgerDatabase.Schema, context, "ledger.db")
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
object AndroidLedgerContext {
|
|
||||||
lateinit var context: Context
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun createLedgerStore(): LedgerStore =
|
|
||||||
SqlLedgerStore(DriverFactory(AndroidLedgerContext.context))
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
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))
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
actual object Platform {
|
|
||||||
actual fun now(): Long = System.currentTimeMillis()
|
|
||||||
actual fun makeId(): String = UUID.randomUUID().toString()
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.feature.account.AddAccountScreen
|
|
||||||
import app.folio.feature.auth.LoginScreen
|
|
||||||
import app.folio.feature.home.HomeScreen
|
|
||||||
import app.folio.feature.ledger.AddTransactionScreen
|
|
||||||
import app.folio.feature.ledger.LedgerScreen
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.FocusTracker
|
|
||||||
import app.folio.ui.theme.LedgerTheme
|
|
||||||
import app.folio.ui.theme.LocalTokens
|
|
||||||
import app.folio.ui.theme.Tokens
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun App() {
|
|
||||||
val session by Repository.session.collectAsState()
|
|
||||||
val route by Navigator.current.collectAsState()
|
|
||||||
val focusedInput by FocusTracker.current.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(session, route) {
|
|
||||||
if (session == null && route !is Route.Login) {
|
|
||||||
Navigator.replace(Route.Login)
|
|
||||||
} else if (session != null && route is Route.Login) {
|
|
||||||
Navigator.replace(Route.Home)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LedgerTheme {
|
|
||||||
val t = Tokens()
|
|
||||||
CompositionLocalProvider(LocalTokens provides t) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(t.bg)
|
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
|
||||||
if (focusedInput != null) {
|
|
||||||
Box(Modifier.size(1.dp).semantics { contentDescription = "focused_input:$focusedInput" })
|
|
||||||
}
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
when (val r = route) {
|
|
||||||
Route.Login -> LoginScreen(onLoggedIn = { Navigator.replace(Route.Home) })
|
|
||||||
Route.Home -> HomeScreen(
|
|
||||||
user = session?.user ?: "",
|
|
||||||
onLogout = {
|
|
||||||
Repository.clearSession()
|
|
||||||
Navigator.replace(Route.Login)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Route.AddAccount -> AddAccountScreen()
|
|
||||||
is Route.Ledger -> LedgerScreen(accountId = r.accountId)
|
|
||||||
is Route.AddTransaction -> AddTransactionScreen(accountId = r.accountId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
// Assumes single-focus: only one input can be focused at a time, so
|
|
||||||
// enter() overwriting is safe. leave() is id-gated so a stale dispose
|
|
||||||
// from a previously-focused field cannot clobber the active focus.
|
|
||||||
object FocusTracker {
|
|
||||||
private val _current = MutableStateFlow<String?>(null)
|
|
||||||
val current: StateFlow<String?> = _current.asStateFlow()
|
|
||||||
|
|
||||||
fun enter(id: String) {
|
|
||||||
_current.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun leave(id: String) {
|
|
||||||
if (_current.value == id) _current.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
data class Account(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val createdAt: Long,
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
interface LedgerStore {
|
|
||||||
val accounts: StateFlow<List<Account>>
|
|
||||||
val transactions: StateFlow<List<Transaction>>
|
|
||||||
val session: StateFlow<Session?>
|
|
||||||
|
|
||||||
fun accountExistsByName(name: String): Boolean
|
|
||||||
fun insertAccount(id: String, name: String, createdAt: Long)
|
|
||||||
fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long)
|
|
||||||
fun upsertSession(user: String, loggedInAt: Long)
|
|
||||||
fun clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
expect fun createLedgerStore(): LedgerStore
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import app.folio.platform.Platform
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
object Repository {
|
|
||||||
private var _store: LedgerStore? = null
|
|
||||||
private val store: LedgerStore
|
|
||||||
get() = _store ?: error("Repository not initialized")
|
|
||||||
|
|
||||||
val accounts: StateFlow<List<Account>> get() = store.accounts
|
|
||||||
val transactions: StateFlow<List<Transaction>> get() = store.transactions
|
|
||||||
val session: StateFlow<Session?> get() = store.session
|
|
||||||
|
|
||||||
fun init() {
|
|
||||||
if (_store == null) _store = createLedgerStore()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSession(user: String) = store.upsertSession(user, Platform.now())
|
|
||||||
|
|
||||||
fun clearSession() = store.clearSession()
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
data class Session(
|
|
||||||
val user: String,
|
|
||||||
val loggedInAt: Long,
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package app.folio.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,
|
|
||||||
)
|
|
||||||
-102
@@ -1,102 +0,0 @@
|
|||||||
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.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import 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 AddAccountScreen() {
|
|
||||||
val t = LocalTokens.current
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
val err by AddAccountUiState.addAccountError.collectAsState()
|
|
||||||
|
|
||||||
BackHandler { Navigator.back(Route.Home) }
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose { AddAccountUiState.addAccountError.value = "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
val trimmed = name.trim()
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
AddAccountUiState.addAccountError.value = "Account name is required"; return
|
|
||||||
}
|
|
||||||
if (trimmed.length > 40) {
|
|
||||||
AddAccountUiState.addAccountError.value = "Name is too long (max 40 characters)"; return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Repository.createAccount(trimmed)
|
|
||||||
AddAccountUiState.addAccountError.value = ""
|
|
||||||
Navigator.replace(Route.Home)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
AddAccountUiState.addAccountError.value = e.message ?: "Could not create account"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen(
|
|
||||||
modifier = Modifier.testTag("AddAccountScreen").semantics { contentDescription = "AddAccountScreen" },
|
|
||||||
header = {
|
|
||||||
Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) })
|
|
||||||
},
|
|
||||||
footer = {
|
|
||||||
AppButton(
|
|
||||||
text = "Create account",
|
|
||||||
onClick = ::submit,
|
|
||||||
style = ButtonStyle.Primary,
|
|
||||||
enabled = name.trim().isNotEmpty(),
|
|
||||||
description = "add_account_submit",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FieldLabel("Account name")
|
|
||||||
TextInput(
|
|
||||||
value = name,
|
|
||||||
onChange = { name = it; AddAccountUiState.addAccountError.value = "" },
|
|
||||||
placeholder = "e.g. Checking",
|
|
||||||
invalid = err.isNotEmpty(),
|
|
||||||
label = "Account name",
|
|
||||||
description = "account_name_field",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ErrorText(err)
|
|
||||||
Text(
|
|
||||||
"Use a short, recognizable name. You can create as many accounts as you need.",
|
|
||||||
style = Type.caption,
|
|
||||||
color = t.textMuted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
package app.folio.feature.account
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
object AddAccountUiState {
|
|
||||||
val addAccountError = MutableStateFlow("")
|
|
||||||
}
|
|
||||||
-111
@@ -1,111 +0,0 @@
|
|||||||
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.DisposableEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import 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
|
|
||||||
|
|
||||||
const val DEMO_EMAIL = "[email protected]"
|
|
||||||
const val DEMO_PASSWORD = "ledger123"
|
|
||||||
|
|
||||||
private fun checkCredentials(email: String, password: String): Boolean {
|
|
||||||
return email.trim().lowercase() == DEMO_EMAIL && password == DEMO_PASSWORD
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginScreen(onLoggedIn: (String) -> Unit) {
|
|
||||||
val t = LocalTokens.current
|
|
||||||
var email by remember { mutableStateOf("") }
|
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
val err by LoginUiState.loginError.collectAsState()
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose { LoginUiState.loginError.value = "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
if (email.isBlank() || password.isEmpty()) {
|
|
||||||
LoginUiState.loginError.value = "Enter email and password"; return
|
|
||||||
}
|
|
||||||
if (!checkCredentials(email, password)) {
|
|
||||||
LoginUiState.loginError.value = "Invalid email or password"; return
|
|
||||||
}
|
|
||||||
LoginUiState.loginError.value = ""
|
|
||||||
val user = email.trim().lowercase()
|
|
||||||
Repository.setSession(user)
|
|
||||||
onLoggedIn(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen(modifier = Modifier.testTag("LoginScreen").semantics { contentDescription = "LoginScreen" }) {
|
|
||||||
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 = email,
|
|
||||||
onChange = { email = it; LoginUiState.loginError.value = "" },
|
|
||||||
placeholder = DEMO_EMAIL,
|
|
||||||
invalid = err.isNotEmpty(),
|
|
||||||
keyboardType = KeyboardType.Email,
|
|
||||||
label = "Email",
|
|
||||||
description = "login_email",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FieldLabel("Password")
|
|
||||||
TextInput(
|
|
||||||
value = password,
|
|
||||||
onChange = { password = it; LoginUiState.loginError.value = "" },
|
|
||||||
placeholder = "••••••••",
|
|
||||||
password = true,
|
|
||||||
invalid = err.isNotEmpty(),
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
label = "Password",
|
|
||||||
description = "login_password",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ErrorText(err)
|
|
||||||
AppButton(
|
|
||||||
text = "Sign in",
|
|
||||||
onClick = ::submit,
|
|
||||||
style = ButtonStyle.Primary,
|
|
||||||
description = "login_submit",
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package app.folio.feature.auth
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
object LoginUiState {
|
|
||||||
val loginError = MutableStateFlow("")
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
import app.folio.platform.formatCents
|
|
||||||
import app.folio.platform.initialsOf
|
|
||||||
import app.folio.platform.signedAmount
|
|
||||||
import 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 HomeScreen(user: String, onLogout: () -> Unit) {
|
|
||||||
val t = LocalTokens.current
|
|
||||||
val accounts by Repository.accounts.collectAsState()
|
|
||||||
val txns by Repository.transactions.collectAsState()
|
|
||||||
val total = balanceOf(txns)
|
|
||||||
|
|
||||||
Screen(
|
|
||||||
modifier = Modifier.testTag("HomeScreen").semantics { contentDescription = "HomeScreen" },
|
|
||||||
header = {
|
|
||||||
Header(
|
|
||||||
title = "Accounts",
|
|
||||||
subtitle = user,
|
|
||||||
right = {
|
|
||||||
IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
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(
|
|
||||||
"${accounts.size} account${if (accounts.size == 1) "" else "s"}",
|
|
||||||
style = Type.caption,
|
|
||||||
color = t.textFaint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AppButton(
|
|
||||||
text = "+ Add account",
|
|
||||||
onClick = { Navigator.push(Route.AddAccount) },
|
|
||||||
style = ButtonStyle.Primary,
|
|
||||||
description = "add_account_button",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (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)) {
|
|
||||||
accounts.forEach { a ->
|
|
||||||
val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) }
|
|
||||||
val count = txns.count { it.accountId == a.id }
|
|
||||||
AccountCard(
|
|
||||||
id = a.id,
|
|
||||||
name = a.name,
|
|
||||||
initials = initialsOf(a.name),
|
|
||||||
count = count,
|
|
||||||
balance = bal,
|
|
||||||
onClick = { Navigator.push(Route.Ledger(a.id)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccountCard(
|
|
||||||
id: String,
|
|
||||||
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))
|
|
||||||
.semantics { contentDescription = "account_card:$id" }
|
|
||||||
.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.semantics { contentDescription = "account_balance" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-162
@@ -1,162 +0,0 @@
|
|||||||
package app.folio.feature.ledger
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.data.TxnType
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.platform.parseCents
|
|
||||||
import app.folio.ui.BackHandler
|
|
||||||
import app.folio.ui.component.AppButton
|
|
||||||
import app.folio.ui.component.BackButton
|
|
||||||
import app.folio.ui.component.ButtonStyle
|
|
||||||
import app.folio.ui.component.EmptyState
|
|
||||||
import app.folio.ui.component.ErrorText
|
|
||||||
import app.folio.ui.component.FieldLabel
|
|
||||||
import app.folio.ui.component.Header
|
|
||||||
import app.folio.ui.component.Screen
|
|
||||||
import app.folio.ui.component.Segmented
|
|
||||||
import app.folio.ui.component.TextInput
|
|
||||||
import app.folio.ui.theme.Type
|
|
||||||
|
|
||||||
private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""")
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AddTransactionScreen(accountId: String) {
|
|
||||||
val accounts by Repository.accounts.collectAsState()
|
|
||||||
val account = accounts.firstOrNull { it.id == accountId }
|
|
||||||
|
|
||||||
BackHandler { Navigator.back(Route.Ledger(accountId)) }
|
|
||||||
|
|
||||||
if (account == null) {
|
|
||||||
Screen(
|
|
||||||
header = {
|
|
||||||
Header(title = "Add transaction", left = { BackButton { Navigator.back(Route.Home) } })
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
EmptyState(title = "Account not found", subtitle = "")
|
|
||||||
AppButton(
|
|
||||||
text = "Back to accounts",
|
|
||||||
onClick = { Navigator.replace(Route.Home) },
|
|
||||||
style = ButtonStyle.Secondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var type by remember { mutableStateOf(TxnType.credit) }
|
|
||||||
var amount by remember { mutableStateOf("") }
|
|
||||||
var note by remember { mutableStateOf("") }
|
|
||||||
val err by AddTransactionUiState.txnError.collectAsState()
|
|
||||||
|
|
||||||
DisposableEffect(type) {
|
|
||||||
AddTransactionUiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit"
|
|
||||||
onDispose { AddTransactionUiState.txnFormType.value = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose { AddTransactionUiState.txnError.value = "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
val cents = parseCents(amount)
|
|
||||||
if (cents == null) {
|
|
||||||
AddTransactionUiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return
|
|
||||||
}
|
|
||||||
if (cents <= 0) {
|
|
||||||
AddTransactionUiState.txnError.value = "Amount must be greater than zero"; return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Repository.createTransaction(accountId, type, cents, note)
|
|
||||||
AddTransactionUiState.txnError.value = ""
|
|
||||||
Navigator.back(Route.Home)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
AddTransactionUiState.txnError.value = e.message ?: "Could not save transaction"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen(
|
|
||||||
modifier = Modifier.testTag("AddTransactionScreen").semantics { contentDescription = "AddTransactionScreen" },
|
|
||||||
header = {
|
|
||||||
Header(
|
|
||||||
title = "Add transaction",
|
|
||||||
subtitle = account.name,
|
|
||||||
left = { BackButton { Navigator.back(Route.Ledger(accountId)) } },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
footer = {
|
|
||||||
AppButton(
|
|
||||||
text = if (type == TxnType.credit) "Add credit" else "Add debit",
|
|
||||||
onClick = ::submit,
|
|
||||||
style = ButtonStyle.Primary,
|
|
||||||
enabled = amount.isNotBlank(),
|
|
||||||
description = "txn_submit",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Segmented(
|
|
||||||
selected = if (type == TxnType.credit) 0 else 1,
|
|
||||||
labels = listOf("Credit", "Debit"),
|
|
||||||
onSelect = {
|
|
||||||
AddTransactionUiState.txnError.value = ""
|
|
||||||
type = if (it == 0) TxnType.credit else TxnType.debit
|
|
||||||
},
|
|
||||||
descriptions = listOf("txn_credit", "txn_debit"),
|
|
||||||
)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FieldLabel("Amount")
|
|
||||||
TextInput(
|
|
||||||
value = amount,
|
|
||||||
onChange = {
|
|
||||||
AddTransactionUiState.txnError.value = ""
|
|
||||||
if (AMOUNT_REGEX.matches(it) || it.isEmpty()) {
|
|
||||||
amount = it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "0.00",
|
|
||||||
invalid = err.isNotEmpty(),
|
|
||||||
keyboardType = KeyboardType.Decimal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
textStyle = Type.amountInput,
|
|
||||||
label = "Amount",
|
|
||||||
description = "txn_amount",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FieldLabel("Note (optional)")
|
|
||||||
TextInput(
|
|
||||||
value = note,
|
|
||||||
onChange = {
|
|
||||||
AddTransactionUiState.txnError.value = ""
|
|
||||||
note = it.take(80)
|
|
||||||
},
|
|
||||||
placeholder = "What's this for?",
|
|
||||||
label = "Note",
|
|
||||||
description = "txn_note",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ErrorText(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package app.folio.feature.ledger
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
object AddTransactionUiState {
|
|
||||||
val txnError = MutableStateFlow("")
|
|
||||||
val txnFormType = MutableStateFlow<String?>(null)
|
|
||||||
}
|
|
||||||
-175
@@ -1,175 +0,0 @@
|
|||||||
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.getValue
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.navigation.Navigator
|
|
||||||
import app.folio.navigation.Route
|
|
||||||
import app.folio.data.TxnType
|
|
||||||
import app.folio.platform.balanceOf
|
|
||||||
import app.folio.platform.formatCents
|
|
||||||
import app.folio.platform.formatDate
|
|
||||||
import app.folio.platform.signedAmount
|
|
||||||
import 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 LedgerScreen(accountId: String) {
|
|
||||||
val t = LocalTokens.current
|
|
||||||
val accounts by Repository.accounts.collectAsState()
|
|
||||||
val allTxns by Repository.transactions.collectAsState()
|
|
||||||
|
|
||||||
BackHandler { Navigator.back(Route.Home) }
|
|
||||||
|
|
||||||
val account = accounts.firstOrNull { it.id == accountId }
|
|
||||||
if (account == null) {
|
|
||||||
Screen(
|
|
||||||
header = {
|
|
||||||
Header(title = "Account", left = { BackButton { Navigator.back(Route.Home) } })
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
EmptyState(title = "Account not found", subtitle = "It may have been deleted.")
|
|
||||||
AppButton(
|
|
||||||
text = "Back to accounts",
|
|
||||||
onClick = { Navigator.replace(Route.Home) },
|
|
||||||
style = ButtonStyle.Secondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val txns = allTxns
|
|
||||||
.filter { it.accountId == accountId }
|
|
||||||
.sortedByDescending { it.createdAt }
|
|
||||||
val balance = balanceOf(txns)
|
|
||||||
|
|
||||||
Screen(
|
|
||||||
modifier = Modifier.testTag("LedgerScreen").semantics { contentDescription = "LedgerScreen:$accountId" },
|
|
||||||
header = {
|
|
||||||
Header(
|
|
||||||
title = account.name,
|
|
||||||
subtitle = "Ledger",
|
|
||||||
left = { BackButton { Navigator.back(Route.Home) } },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
footer = {
|
|
||||||
AppButton(
|
|
||||||
text = "+ Add transaction",
|
|
||||||
onClick = { Navigator.push(Route.AddTransaction(accountId)) },
|
|
||||||
style = ButtonStyle.Primary,
|
|
||||||
description = "add_txn_button",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Card {
|
|
||||||
Text("BALANCE", style = Type.label, color = t.textMuted)
|
|
||||||
Text(
|
|
||||||
formatCents(balance),
|
|
||||||
style = Type.balance,
|
|
||||||
color = t.text,
|
|
||||||
modifier = Modifier.semantics { contentDescription = "ledger_balance_display" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"ACTIVITY",
|
|
||||||
style = Type.label,
|
|
||||||
color = t.textFaint,
|
|
||||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp, start = 4.dp),
|
|
||||||
)
|
|
||||||
if (txns.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),
|
|
||||||
) {
|
|
||||||
txns.forEachIndexed { i, txn ->
|
|
||||||
TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt))
|
|
||||||
if (i != txns.lastIndex) {
|
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(t.border))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: String) {
|
|
||||||
val t = LocalTokens.current
|
|
||||||
val signed = if (type == TxnType.credit) amount else -amount
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 14.dp)
|
|
||||||
.semantics { contentDescription = "ledger_row:$id" },
|
|
||||||
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.semantics { contentDescription = "txn_amount" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package app.folio.navigation
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
object Navigator {
|
|
||||||
private val stack = ArrayDeque<Route>().apply { addLast(Route.Home) }
|
|
||||||
private val _current = MutableStateFlow<Route>(Route.Home)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package app.folio.navigation
|
|
||||||
|
|
||||||
sealed interface Route {
|
|
||||||
data object Login : Route
|
|
||||||
data object Home : Route
|
|
||||||
data object AddAccount : Route
|
|
||||||
data class Ledger(val accountId: String) : Route
|
|
||||||
data class AddTransaction(val accountId: String) : Route
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
import app.folio.data.Transaction
|
|
||||||
import app.folio.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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
expect object Platform {
|
|
||||||
fun now(): Long
|
|
||||||
fun makeId(): String
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package app.folio.ui
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
expect fun BackHandler(onBack: () -> Unit)
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.folio.ui.theme.LocalTokens
|
|
||||||
import app.folio.ui.theme.RadiusMd
|
|
||||||
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,
|
|
||||||
description: 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 (description != null) Modifier.semantics { contentDescription = description }
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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.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,
|
|
||||||
description: String,
|
|
||||||
icon: ImageVector,
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
.semantics { contentDescription = description },
|
|
||||||
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, description = "Back", icon = Icons.Back)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.selected
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
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,
|
|
||||||
descriptions: 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 desc = descriptions?.getOrNull(i)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(RadiusSm))
|
|
||||||
.background(if (active) t.surface3 else t.surface)
|
|
||||||
.semantics {
|
|
||||||
this.selected = active
|
|
||||||
if (desc != null) contentDescription = desc
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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.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.FocusTracker
|
|
||||||
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? = null,
|
|
||||||
description: 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
|
|
||||||
if (description != null) {
|
|
||||||
if (it.isFocused) FocusTracker.enter(description)
|
|
||||||
else FocusTracker.leave(description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.semantics {
|
|
||||||
val desc = description ?: label
|
|
||||||
if (desc != null) contentDescription = desc
|
|
||||||
if (invalid) stateDescription = "Invalid"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package app.folio.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
val Mono: FontFamily = FontFamily.Monospace
|
|
||||||
|
|
||||||
object Type {
|
|
||||||
val body = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.Normal)
|
|
||||||
val bodyStrong = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
val title = TextStyle(fontFamily = Mono, fontSize = 20.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
|
||||||
val caption = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal)
|
|
||||||
val label = TextStyle(fontFamily = Mono, fontSize = 12.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.7.sp)
|
|
||||||
val balance = TextStyle(fontFamily = Mono, fontSize = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
|
||||||
val amountInput = TextStyle(fontFamily = Mono, fontSize = 36.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp)
|
|
||||||
val button = TextStyle(fontFamily = Mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
val brand = TextStyle(fontFamily = Mono, fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import androidx.compose.ui.window.ComposeUIViewController
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.ui.IosBackGesture
|
|
||||||
import kotlin.native.ObjCName
|
|
||||||
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 {
|
|
||||||
Repository.init()
|
|
||||||
app.folio.sanderling.AuthSnapshots
|
|
||||||
app.folio.sanderling.AccountSnapshots
|
|
||||||
app.folio.sanderling.LedgerSnapshots
|
|
||||||
app.folio.sanderling.NavigationSnapshots
|
|
||||||
app.folio.sanderling.SanderlingIos.start()
|
|
||||||
val vc = ComposeUIViewController { App() }
|
|
||||||
val gesture = UIScreenEdgePanGestureRecognizer(
|
|
||||||
target = backGestureTarget,
|
|
||||||
action = NSSelectorFromString("handleGesture:"),
|
|
||||||
)
|
|
||||||
gesture.edges = UIRectEdgeLeft
|
|
||||||
vc.view.addGestureRecognizer(gesture)
|
|
||||||
return vc
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
|
||||||
import app.folio.db.LedgerDatabase
|
|
||||||
|
|
||||||
actual class DriverFactory {
|
|
||||||
actual fun create(): SqlDriver =
|
|
||||||
NativeSqliteDriver(LedgerDatabase.Schema, "ledger.db")
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
actual fun createLedgerStore(): LedgerStore = SqlLedgerStore(DriverFactory())
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
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))
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package app.folio.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
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package app.folio.ui
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
actual fun BackHandler(onBack: () -> Unit) {
|
|
||||||
val current by rememberUpdatedState(onBack)
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val id = IosBackGesture.register { current() }
|
|
||||||
onDispose { IosBackGesture.unregister(id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
|
|
||||||
expect class DriverFactory {
|
|
||||||
fun create(): SqlDriver
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package app.folio.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(factory: DriverFactory) : LedgerStore {
|
|
||||||
private val db = LedgerDatabase(factory.create())
|
|
||||||
private val q = db.ledgerQueries
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
|
|
||||||
override val accounts: StateFlow<List<Account>> =
|
|
||||||
q.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>> =
|
|
||||||
q.selectAllTxns()
|
|
||||||
.asFlow()
|
|
||||||
.mapToList(Dispatchers.Default)
|
|
||||||
.map { rows -> rows.map { it.toDomain() } }
|
|
||||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
|
||||||
|
|
||||||
override val session: StateFlow<Session?> =
|
|
||||||
q.selectSession()
|
|
||||||
.asFlow()
|
|
||||||
.mapToOneOrNull(Dispatchers.Default)
|
|
||||||
.map { it?.let { row -> Session(row.user, row.loggedInAt) } }
|
|
||||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
|
||||||
|
|
||||||
override fun accountExistsByName(name: String): Boolean =
|
|
||||||
q.accountExistsByName(name).executeAsOne()
|
|
||||||
|
|
||||||
override fun insertAccount(id: String, name: String, createdAt: Long) {
|
|
||||||
q.insertAccount(id, name, createdAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
|
|
||||||
q.insertTxn(id, accountId, type.name, amount, note, createdAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upsertSession(user: String, loggedInAt: Long) {
|
|
||||||
q.upsertSession(user, loggedInAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearSession() {
|
|
||||||
q.clearSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun app.folio.db.Txns.toDomain(): Transaction =
|
|
||||||
Transaction(
|
|
||||||
id = id,
|
|
||||||
accountId = accountId,
|
|
||||||
type = TxnType.valueOf(type),
|
|
||||||
amount = amount,
|
|
||||||
note = note,
|
|
||||||
createdAt = createdAt,
|
|
||||||
)
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
internal data class Snapshot(
|
|
||||||
val accounts: List<Account>,
|
|
||||||
val transactions: List<Transaction>,
|
|
||||||
val session: Session?,
|
|
||||||
) {
|
|
||||||
fun encode(): String = buildString {
|
|
||||||
for (a in accounts) {
|
|
||||||
append("A\t").append(esc(a.id)).append('\t').append(esc(a.name)).append('\t').append(a.createdAt).append('\n')
|
|
||||||
}
|
|
||||||
for (t in transactions) {
|
|
||||||
append("T\t").append(esc(t.id)).append('\t').append(esc(t.accountId)).append('\t')
|
|
||||||
.append(t.type.name).append('\t').append(t.amount).append('\t')
|
|
||||||
.append(esc(t.note)).append('\t').append(t.createdAt).append('\n')
|
|
||||||
}
|
|
||||||
session?.let { append("S\t").append(esc(it.user)).append('\t').append(it.loggedInAt).append('\n') }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun decode(raw: String): Snapshot? {
|
|
||||||
val accounts = mutableListOf<Account>()
|
|
||||||
val transactions = mutableListOf<Transaction>()
|
|
||||||
var session: Session? = null
|
|
||||||
for (line in raw.split('\n')) {
|
|
||||||
if (line.isEmpty()) continue
|
|
||||||
val parts = line.split('\t')
|
|
||||||
when (parts[0]) {
|
|
||||||
"A" -> if (parts.size == 4) accounts += Account(unesc(parts[1]), unesc(parts[2]), parts[3].toLong())
|
|
||||||
"T" -> if (parts.size == 7) transactions += Transaction(
|
|
||||||
id = unesc(parts[1]),
|
|
||||||
accountId = unesc(parts[2]),
|
|
||||||
type = TxnType.valueOf(parts[3]),
|
|
||||||
amount = parts[4].toLong(),
|
|
||||||
note = unesc(parts[5]),
|
|
||||||
createdAt = parts[6].toLong(),
|
|
||||||
)
|
|
||||||
"S" -> if (parts.size == 3) session = Session(unesc(parts[1]), parts[2].toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Snapshot(accounts, transactions, session)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun esc(s: String) = s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n")
|
|
||||||
private fun unesc(s: String): String {
|
|
||||||
val out = StringBuilder(s.length)
|
|
||||||
var i = 0
|
|
||||||
while (i < s.length) {
|
|
||||||
val c = s[i]
|
|
||||||
if (c == '\\' && i + 1 < s.length) {
|
|
||||||
when (s[i + 1]) {
|
|
||||||
't' -> out.append('\t')
|
|
||||||
'n' -> out.append('\n')
|
|
||||||
'\\' -> out.append('\\')
|
|
||||||
else -> out.append(s[i + 1])
|
|
||||||
}
|
|
||||||
i += 2
|
|
||||||
} else {
|
|
||||||
out.append(c); i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package app.folio.data
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.browser.localStorage
|
|
||||||
|
|
||||||
private const val STORAGE_KEY = "sanderling.ledger.v1"
|
|
||||||
|
|
||||||
class WebLedgerStore : LedgerStore {
|
|
||||||
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
|
|
||||||
private val _transactions = MutableStateFlow<List<Transaction>>(emptyList())
|
|
||||||
private val _session = MutableStateFlow<Session?>(null)
|
|
||||||
|
|
||||||
override val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
|
||||||
override val transactions: StateFlow<List<Transaction>> = _transactions.asStateFlow()
|
|
||||||
override val session: StateFlow<Session?> = _session.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun accountExistsByName(name: String): Boolean =
|
|
||||||
_accounts.value.any { it.name.equals(name, ignoreCase = true) }
|
|
||||||
|
|
||||||
override fun insertAccount(id: String, name: String, createdAt: Long) {
|
|
||||||
_accounts.value = _accounts.value + Account(id, name, createdAt)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun insertTxn(id: String, accountId: String, type: TxnType, amount: Long, note: String, createdAt: Long) {
|
|
||||||
_transactions.value = _transactions.value + Transaction(id, accountId, type, amount, note, createdAt)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun upsertSession(user: String, loggedInAt: Long) {
|
|
||||||
_session.value = Session(user, loggedInAt)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearSession() {
|
|
||||||
_session.value = null
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun load() {
|
|
||||||
val raw = localStorage.getItem(STORAGE_KEY) ?: return
|
|
||||||
val parsed = Snapshot.decode(raw) ?: return
|
|
||||||
_accounts.value = parsed.accounts
|
|
||||||
_transactions.value = parsed.transactions
|
|
||||||
_session.value = parsed.session
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun save() {
|
|
||||||
val snap = Snapshot(_accounts.value, _transactions.value, _session.value)
|
|
||||||
localStorage.setItem(STORAGE_KEY, snap.encode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun createLedgerStore(): LedgerStore = WebLedgerStore()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package app.folio
|
|
||||||
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
|
||||||
import app.folio.data.Repository
|
|
||||||
import app.folio.ui.WebBackGesture
|
|
||||||
import kotlinx.browser.document
|
|
||||||
import kotlinx.browser.window
|
|
||||||
import org.w3c.dom.events.Event
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
fun main() {
|
|
||||||
Repository.init()
|
|
||||||
window.history.pushState(null, "", window.location.href)
|
|
||||||
window.addEventListener("popstate", { _: Event ->
|
|
||||||
if (WebBackGesture.dispatch()) {
|
|
||||||
window.history.pushState(null, "", window.location.href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val target = document.getElementById("app") ?: document.body!!
|
|
||||||
ComposeViewport(target) { App() }
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package app.folio.platform
|
|
||||||
|
|
||||||
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()")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package app.folio.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,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<title>Folio</title>
|
|
||||||
<style>
|
|
||||||
html, body { margin: 0; padding: 0; height: 100%; background: #ffffff; overscroll-behavior: none; }
|
|
||||||
body { display: flex; justify-content: center; }
|
|
||||||
#app { width: 100%; max-width: 480px; height: 100%; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script src="composeApp.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import Shared
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- iosApp
|
- iosApp
|
||||||
dependencies:
|
dependencies:
|
||||||
- framework: ../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework
|
- framework: ../app/shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework
|
||||||
embed: false
|
embed: false
|
||||||
- sdk: libsqlite3.tbd
|
- sdk: libsqlite3.tbd
|
||||||
settings:
|
settings:
|
||||||
@@ -28,7 +28,7 @@ targets:
|
|||||||
PRODUCT_NAME: iosApp
|
PRODUCT_NAME: iosApp
|
||||||
INFOPLIST_FILE: iosApp/Info.plist
|
INFOPLIST_FILE: iosApp/Info.plist
|
||||||
FRAMEWORK_SEARCH_PATHS:
|
FRAMEWORK_SEARCH_PATHS:
|
||||||
- $(SRCROOT)/../composeApp/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
|
- $(SRCROOT)/../app/shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
|
||||||
OTHER_LDFLAGS:
|
OTHER_LDFLAGS:
|
||||||
- -ObjC
|
- -ObjC
|
||||||
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
|
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
|
||||||
@@ -37,7 +37,7 @@ targets:
|
|||||||
script: |
|
script: |
|
||||||
cd "$SRCROOT/.."
|
cd "$SRCROOT/.."
|
||||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
|
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
|
||||||
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64
|
./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64
|
||||||
outputFiles:
|
outputFiles:
|
||||||
- $(SRCROOT)/../composeApp/build/bin/iosSimulatorArm64/debugFramework/ComposeApp.framework/ComposeApp
|
- $(SRCROOT)/../app/shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared
|
||||||
basedOnDependencyAnalysis: false
|
basedOnDependencyAnalysis: false
|
||||||
@@ -40,14 +40,14 @@ install:
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export ANDROID_HOME="$(just _android-home)"
|
export ANDROID_HOME="$(just _android-home)"
|
||||||
./gradlew :composeApp:installDebug
|
./gradlew :app:androidApp:installDebug
|
||||||
|
|
||||||
# Remove the folio APK from the connected device.
|
# Remove the folio APK from the connected device.
|
||||||
uninstall:
|
uninstall:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export ANDROID_HOME="$(just _android-home)"
|
export ANDROID_HOME="$(just _android-home)"
|
||||||
./gradlew :composeApp:uninstallDebug
|
./gradlew :app:androidApp:uninstallDebug
|
||||||
|
|
||||||
# Remove gradle + iOS build directories.
|
# Remove gradle + iOS build directories.
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
Reference in new issue
Block a user