docs: add architecture docs, AGENTS.md, CLAUDE.md, and copy UI components
- Add docs/01-04 covering data model, interfaces, function TODOs, and implementation plan - Add AGENTS.md with project conventions, architecture rules, feature workflow, and git policy - Add CLAUDE.md pointing to AGENTS.md - Copy UI components and utils from info-krl-android (preference widgets, scrollbars, etc.) - Delete obsolete UiModule.kt
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# 01 — Data Model
|
||||
|
||||
Models are scoped to their feature under `dev.achmad.ledgerr.domain.<feature>.model`. No shared/common package — features import from each other's model package where needed.
|
||||
|
||||
---
|
||||
|
||||
## Feature: expense
|
||||
|
||||
```kotlin
|
||||
// DateRange.kt
|
||||
data class DateRange(
|
||||
val start: LocalDate,
|
||||
val end: LocalDate
|
||||
) {
|
||||
companion object {
|
||||
fun thisMonth(): DateRange {
|
||||
val now = LocalDate.now()
|
||||
return DateRange(now.withDayOfMonth(1), now.withDayOfMonth(now.lengthOfMonth()))
|
||||
}
|
||||
fun thisWeek(): DateRange {
|
||||
val now = LocalDate.now()
|
||||
return DateRange(now.with(DayOfWeek.MONDAY), now.with(DayOfWeek.SUNDAY))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expense.kt
|
||||
data class Expense(
|
||||
val id: Long = 0,
|
||||
val amount: Double, // always positive; represents outflow
|
||||
val categoryId: Long,
|
||||
val date: LocalDate,
|
||||
val note: String? = null,
|
||||
val recurringExpenseId: Long? = null, // set if auto-generated from a RecurringExpense
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// ExpenseWithCategory.kt
|
||||
data class ExpenseWithCategory(
|
||||
val expense: Expense,
|
||||
val category: Category // imported from domain.category.model
|
||||
)
|
||||
|
||||
// ExpenseSummary.kt
|
||||
data class ExpenseSummary(
|
||||
val totalAmount: Double,
|
||||
val byCategory: List<Pair<Category, Double>>, // sorted by amount DESC
|
||||
val period: DateRange
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: category
|
||||
|
||||
```kotlin
|
||||
// Category.kt
|
||||
data class Category(
|
||||
val id: Long = 0,
|
||||
val name: String,
|
||||
val color: Int, // ARGB
|
||||
val iconName: String? = null, // Material icon name
|
||||
val isDefault: Boolean = false // non-deletable; "Other" is always true
|
||||
)
|
||||
```
|
||||
|
||||
Default categories seeded on first install:
|
||||
|
||||
| Name | color (ARGB hex) | isDefault |
|
||||
|------|------------------|-----------|
|
||||
| Food & Drink | `0xFFFF9800` (orange) | false |
|
||||
| Transport | `0xFF2196F3` (blue) | false |
|
||||
| Housing | `0xFF795548` (brown) | false |
|
||||
| Health | `0xFFF44336` (red) | false |
|
||||
| Entertainment | `0xFF9C27B0` (purple) | false |
|
||||
| Shopping | `0xFFE91E63` (pink) | false |
|
||||
| Education | `0xFF4CAF50` (green) | false |
|
||||
| Other | `0xFF9E9E9E` (grey) | **true** |
|
||||
|
||||
`Other` is the permanent fallback when a user-defined category is deleted.
|
||||
|
||||
---
|
||||
|
||||
## Feature: recurring
|
||||
|
||||
```kotlin
|
||||
// RecurringInterval.kt
|
||||
enum class RecurringInterval {
|
||||
DAILY, WEEKLY, MONTHLY, YEARLY;
|
||||
|
||||
fun advance(from: LocalDate): LocalDate = when (this) {
|
||||
DAILY -> from.plusDays(1)
|
||||
WEEKLY -> from.plusWeeks(1)
|
||||
MONTHLY -> from.plusMonths(1)
|
||||
YEARLY -> from.plusYears(1)
|
||||
}
|
||||
}
|
||||
|
||||
// RecurringExpense.kt
|
||||
data class RecurringExpense(
|
||||
val id: Long = 0,
|
||||
val amount: Double,
|
||||
val categoryId: Long,
|
||||
val note: String? = null,
|
||||
val interval: RecurringInterval,
|
||||
val startDate: LocalDate,
|
||||
val nextDueDate: LocalDate, // initialized = startDate; advances after each processing
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
// RecurringExpenseWithCategory.kt
|
||||
data class RecurringExpenseWithCategory(
|
||||
val recurring: RecurringExpense,
|
||||
val category: Category
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: bankstatement
|
||||
|
||||
Each bank has its own raw model representing a single parsed row from the bank's PDF format. These exist so bank-specific parsing logic produces typed data before converting to the common `PendingImportExpense`.
|
||||
|
||||
```kotlin
|
||||
// PendingImportExpense.kt — common output for the review screen
|
||||
data class PendingImportExpense(
|
||||
val amount: Double,
|
||||
val date: LocalDate,
|
||||
val description: String, // raw text from PDF
|
||||
val suggestedCategoryId: Long? = null,
|
||||
val isSelected: Boolean = true // user can deselect before committing
|
||||
)
|
||||
|
||||
// BRIStatementEntry.kt
|
||||
data class BRIStatementEntry(
|
||||
val date: String, // raw string as it appears in PDF
|
||||
val description: String,
|
||||
val debit: String?, // raw amount string, null if not a debit
|
||||
val credit: String?,
|
||||
val balance: String?
|
||||
)
|
||||
|
||||
// JagoStatementEntry.kt
|
||||
data class JagoStatementEntry(
|
||||
val date: String,
|
||||
val description: String,
|
||||
val amount: String,
|
||||
val type: String // e.g. "DEBIT" / "KREDIT"
|
||||
)
|
||||
|
||||
// BNIStatementEntry.kt
|
||||
data class BNIStatementEntry(
|
||||
val date: String,
|
||||
val description: String,
|
||||
val debit: String?,
|
||||
val credit: String?,
|
||||
val balance: String?
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: export
|
||||
|
||||
No domain models — uses `Expense`, `ExpenseWithCategory`, and `DateRange` from the expense feature.
|
||||
|
||||
---
|
||||
|
||||
## Room Entities (data layer only)
|
||||
|
||||
Live in `data/local/entity/`. Mirror domain models with Room annotations; dates stored as `Long` (epoch day via `LocalDateConverter`).
|
||||
|
||||
- `CategoryEntity`
|
||||
- `ExpenseEntity`
|
||||
- `RecurringExpenseEntity`
|
||||
@@ -0,0 +1,185 @@
|
||||
# 02 — Interactors
|
||||
|
||||
Each feature under `dev.achmad.ledgerr.domain.<feature>.interactor` owns a set of interactor classes. Interactors are use-case classes that can hold logic and depend on whatever they need (DAOs, Android context, etc.). The UI layer depends only on domain — never on data directly.
|
||||
|
||||
Naming convention for methods:
|
||||
- `await(...)` — suspend, returns a single value
|
||||
- `awaitAll(...)` — suspend, returns a list
|
||||
- `subscribeOne(...)` — returns `Flow<T?>`
|
||||
- `subscribeAll(...)` — returns `Flow<List<T>>`
|
||||
- Filters are parameters, not separate functions
|
||||
|
||||
---
|
||||
|
||||
## Feature: expense
|
||||
|
||||
### `GetExpenses`
|
||||
```kotlin
|
||||
class GetExpenses(dao: ExpenseDao, categoryDao: CategoryDao) {
|
||||
fun subscribeAll(): Flow<List<ExpenseWithCategory>>
|
||||
fun subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>
|
||||
suspend fun awaitOne(id: Long): ExpenseWithCategory?
|
||||
suspend fun awaitAll(query: String = "", range: DateRange? = null): List<ExpenseWithCategory>
|
||||
}
|
||||
```
|
||||
|
||||
### `UpsertExpense`
|
||||
```kotlin
|
||||
class UpsertExpense(dao: ExpenseDao) {
|
||||
suspend fun await(expense: Expense): Long // insert if id=0, update otherwise
|
||||
}
|
||||
```
|
||||
|
||||
### `InsertExpenses`
|
||||
```kotlin
|
||||
class InsertExpenses(dao: ExpenseDao) {
|
||||
suspend fun awaitAll(expenses: List<Expense>) // bulk insert, single transaction
|
||||
}
|
||||
```
|
||||
|
||||
### `DeleteExpense`
|
||||
```kotlin
|
||||
class DeleteExpense(dao: ExpenseDao) {
|
||||
suspend fun await(id: Long)
|
||||
}
|
||||
```
|
||||
|
||||
### `ReassignExpenseCategory`
|
||||
```kotlin
|
||||
class ReassignExpenseCategory(dao: ExpenseDao) {
|
||||
suspend fun await(fromCategoryId: Long, toCategoryId: Long)
|
||||
}
|
||||
```
|
||||
|
||||
### `GetExpenseSummary`
|
||||
```kotlin
|
||||
class GetExpenseSummary(dao: ExpenseDao, categoryDao: CategoryDao) {
|
||||
suspend fun await(range: DateRange): ExpenseSummary
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: category
|
||||
|
||||
### `GetCategories`
|
||||
```kotlin
|
||||
class GetCategories(dao: CategoryDao) {
|
||||
fun subscribeAll(): Flow<List<Category>>
|
||||
suspend fun awaitOne(id: Long): Category?
|
||||
suspend fun awaitAll(): List<Category>
|
||||
suspend fun awaitDefault(): Category // returns the isDefault=true "Other" category
|
||||
}
|
||||
```
|
||||
|
||||
### `UpsertCategory`
|
||||
```kotlin
|
||||
class UpsertCategory(dao: CategoryDao) {
|
||||
suspend fun await(category: Category): Long
|
||||
}
|
||||
```
|
||||
|
||||
### `DeleteCategory`
|
||||
```kotlin
|
||||
class DeleteCategory(
|
||||
dao: CategoryDao,
|
||||
reassignExpenseCategory: ReassignExpenseCategory,
|
||||
getCategories: GetCategories,
|
||||
) {
|
||||
suspend fun await(id: Long)
|
||||
// internally: reassign orphaned expenses to "Other", then delete
|
||||
}
|
||||
```
|
||||
|
||||
### `SeedDefaultCategories`
|
||||
```kotlin
|
||||
class SeedDefaultCategories(dao: CategoryDao) {
|
||||
suspend fun await() // no-op if categories already exist
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: recurring
|
||||
|
||||
### `GetRecurringExpenses`
|
||||
```kotlin
|
||||
class GetRecurringExpenses(dao: RecurringExpenseDao, categoryDao: CategoryDao) {
|
||||
fun subscribeAll(): Flow<List<RecurringExpenseWithCategory>>
|
||||
suspend fun awaitOne(id: Long): RecurringExpense?
|
||||
}
|
||||
```
|
||||
|
||||
### `UpsertRecurringExpense`
|
||||
```kotlin
|
||||
class UpsertRecurringExpense(dao: RecurringExpenseDao) {
|
||||
suspend fun await(recurring: RecurringExpense): Long
|
||||
}
|
||||
```
|
||||
|
||||
### `DeleteRecurringExpense`
|
||||
```kotlin
|
||||
class DeleteRecurringExpense(dao: RecurringExpenseDao) {
|
||||
suspend fun await(id: Long)
|
||||
// does NOT delete expense instances already created from this template
|
||||
}
|
||||
```
|
||||
|
||||
### `ProcessDueRecurringExpenses`
|
||||
```kotlin
|
||||
class ProcessDueRecurringExpenses(
|
||||
recurringDao: RecurringExpenseDao,
|
||||
expenseDao: ExpenseDao,
|
||||
) {
|
||||
suspend fun await(today: LocalDate = LocalDate.now()): List<Expense>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature: bankstatement
|
||||
|
||||
### `BankStatementImporter` (interface)
|
||||
```kotlin
|
||||
interface BankStatementImporter {
|
||||
val bankName: String
|
||||
suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>
|
||||
}
|
||||
```
|
||||
|
||||
### `ImportBRIBankStatement : BankStatementImporter`
|
||||
```kotlin
|
||||
class ImportBRIBankStatement(context: Context) : BankStatementImporter {
|
||||
override val bankName = "BRI"
|
||||
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
|
||||
}
|
||||
```
|
||||
|
||||
### `ImportJagoBankStatement : BankStatementImporter`
|
||||
```kotlin
|
||||
class ImportJagoBankStatement(context: Context) : BankStatementImporter {
|
||||
override val bankName = "Jago"
|
||||
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
|
||||
}
|
||||
```
|
||||
|
||||
### `ImportBNIBankStatement : BankStatementImporter`
|
||||
```kotlin
|
||||
class ImportBNIBankStatement(context: Context) : BankStatementImporter {
|
||||
override val bankName = "BNI"
|
||||
override suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>> // stub
|
||||
}
|
||||
```
|
||||
|
||||
`DomainModule` binds all three as `List<BankStatementImporter>` so the UI can show a bank picker.
|
||||
|
||||
---
|
||||
|
||||
## Feature: export
|
||||
|
||||
### `ExportExpensesToCsv`
|
||||
```kotlin
|
||||
class ExportExpensesToCsv(expenseDao: ExpenseDao, categoryDao: CategoryDao, context: Context) {
|
||||
suspend fun await(range: DateRange, outputUri: Uri): Result<Unit>
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,174 @@
|
||||
# 03 — Function TODOs
|
||||
|
||||
What each interactor method does, inputs, outputs, and edge cases.
|
||||
|
||||
---
|
||||
|
||||
## expense / GetExpenses
|
||||
|
||||
### `subscribeAll(): Flow<List<ExpenseWithCategory>>`
|
||||
JOIN expenses with categories, ordered by `date DESC`, `createdAt DESC`. Emits on any change to either table.
|
||||
|
||||
### `subscribeByDateRange(range: DateRange): Flow<List<ExpenseWithCategory>>`
|
||||
Same join, filtered: `expense.date >= range.start AND expense.date <= range.end`. Ordered date DESC.
|
||||
|
||||
### `awaitOne(id: Long): ExpenseWithCategory?`
|
||||
Single JOIN lookup by expense id. Returns null if not found.
|
||||
|
||||
### `awaitAll(query: String, range: DateRange?): List<ExpenseWithCategory>`
|
||||
One-shot search. If `query` is blank and `range` is null, returns all expenses (ordered date DESC). If `query` is non-blank, filters: `note LIKE '%query%'` OR `CAST(amount AS TEXT) LIKE '%query%'` OR `category.name LIKE '%query%'`. If `range` is non-null, also filters by date range. Results ordered date DESC.
|
||||
|
||||
---
|
||||
|
||||
## expense / UpsertExpense
|
||||
|
||||
### `await(expense: Expense): Long`
|
||||
If `expense.id == 0L`: insert and return generated id. Otherwise: update by id and return the existing id. Uses Room `@Upsert`.
|
||||
|
||||
---
|
||||
|
||||
## expense / InsertExpenses
|
||||
|
||||
### `awaitAll(expenses: List<Expense>)`
|
||||
Bulk insert in a single Room transaction. Used after the user confirms PDF import. `expense.id` must be 0 for all items.
|
||||
|
||||
---
|
||||
|
||||
## expense / DeleteExpense
|
||||
|
||||
### `await(id: Long)`
|
||||
Deletes expense by primary key. Does not touch any `RecurringExpense` template that generated it — those continue running independently.
|
||||
|
||||
---
|
||||
|
||||
## expense / ReassignExpenseCategory
|
||||
|
||||
### `await(fromCategoryId: Long, toCategoryId: Long)`
|
||||
Single SQL `UPDATE expenses SET categoryId = :toCategoryId WHERE categoryId = :fromCategoryId`. Used by `DeleteCategory` before removing a category.
|
||||
|
||||
---
|
||||
|
||||
## expense / GetExpenseSummary
|
||||
|
||||
### `await(range: DateRange): ExpenseSummary`
|
||||
1. Fetch all expenses with category in range (one-shot, uses DAO directly).
|
||||
2. Sum all amounts → `totalAmount`.
|
||||
3. Group by category, sum per group, sort by amount DESC → `byCategory`.
|
||||
4. Return `ExpenseSummary(totalAmount, byCategory, range)`.
|
||||
|
||||
---
|
||||
|
||||
## category / GetCategories
|
||||
|
||||
### `subscribeAll(): Flow<List<Category>>`
|
||||
All categories ordered by name ASC, emits on change.
|
||||
|
||||
### `awaitOne(id: Long): Category?`
|
||||
Nullable lookup by primary key.
|
||||
|
||||
### `awaitAll(): List<Category>`
|
||||
One-shot list, ordered by name ASC.
|
||||
|
||||
### `awaitDefault(): Category`
|
||||
Returns the single category where `isDefault = true`. Throws `IllegalStateException` if not found (seeding hasn't run — should not happen in practice).
|
||||
|
||||
---
|
||||
|
||||
## category / UpsertCategory
|
||||
|
||||
### `await(category: Category): Long`
|
||||
Insert if `id == 0`, update otherwise. Returns the id. Does not allow changing `isDefault` — if the incoming category has `isDefault = true` but the existing DB record has `isDefault = false`, the DB value wins (impl reads existing `isDefault` before update). In practice, only the seeder sets `isDefault = true`.
|
||||
|
||||
---
|
||||
|
||||
## category / DeleteCategory
|
||||
|
||||
### `await(id: Long)`
|
||||
1. Guard: fetch category by id. If `isDefault = true`, throw `IllegalArgumentException("Cannot delete a default category")`.
|
||||
2. Find the fallback: call `getCategories.awaitDefault()` to get the "Other" category id.
|
||||
3. Call `reassignExpenseCategory.await(id, fallbackId)` to move orphaned expenses.
|
||||
4. Delete the category from the DB.
|
||||
|
||||
Steps 3 and 4 run sequentially in the interactor (not a DB transaction). If step 4 fails, expenses are already reassigned to Other — acceptable, retry is safe.
|
||||
|
||||
---
|
||||
|
||||
## category / SeedDefaultCategories
|
||||
|
||||
### `await()`
|
||||
Checks count of categories in DB. If count > 0, no-op. Otherwise inserts the 8 default categories (see `01-data-model.md`). Called from `MainApplication.onCreate` after `startKoin` completes, using a `CoroutineScope(Dispatchers.IO)`.
|
||||
|
||||
---
|
||||
|
||||
## recurring / GetRecurringExpenses
|
||||
|
||||
### `subscribeAll(): Flow<List<RecurringExpenseWithCategory>>`
|
||||
All recurring expenses (active and inactive) joined with category, ordered by `nextDueDate ASC`. Emits on change.
|
||||
|
||||
### `awaitOne(id: Long): RecurringExpense?`
|
||||
Nullable lookup by primary key.
|
||||
|
||||
---
|
||||
|
||||
## recurring / UpsertRecurringExpense
|
||||
|
||||
### `await(recurring: RecurringExpense): Long`
|
||||
Insert if `id == 0`, update otherwise. Caller must set `nextDueDate = startDate` on initial insert.
|
||||
|
||||
---
|
||||
|
||||
## recurring / DeleteRecurringExpense
|
||||
|
||||
### `await(id: Long)`
|
||||
Deletes the recurring template by id. Expense instances previously created from this template retain their `recurringExpenseId` value — they become historical records and are not deleted.
|
||||
|
||||
---
|
||||
|
||||
## recurring / ProcessDueRecurringExpenses
|
||||
|
||||
### `await(today: LocalDate): List<Expense>`
|
||||
1. Query recurring expenses where `isActive = true AND nextDueDate <= today`.
|
||||
2. For each due item:
|
||||
a. Create `Expense(amount, categoryId, note from template, date = nextDueDate, recurringExpenseId = template.id)`.
|
||||
b. Insert the expense.
|
||||
c. Advance: `newNextDueDate = interval.advance(current nextDueDate)`.
|
||||
d. Update the recurring template with `nextDueDate = newNextDueDate`.
|
||||
3. Return the list of created expenses so the caller can show a banner.
|
||||
|
||||
Edge case — app not opened for multiple intervals: advances `nextDueDate` by one interval per call. The next app open will process it again if still overdue. This avoids flooding the expense list with back-filled entries.
|
||||
|
||||
---
|
||||
|
||||
## bankstatement / BankStatementImporter
|
||||
|
||||
### `val bankName: String`
|
||||
Display name shown in the bank picker (e.g. `"BRI"`, `"Jago"`, `"BNI"`).
|
||||
|
||||
### `await(pdfUri: Uri): Result<List<PendingImportExpense>>`
|
||||
Contract for all implementations:
|
||||
1. Open PDF via `ContentResolver.openInputStream(pdfUri)`.
|
||||
2. Extract text with PDFBox-Android (`PDDocument` + `PDFTextStripper`), page by page.
|
||||
3. Parse bank-specific format into raw entry models (e.g. `BRIStatementEntry`).
|
||||
4. Filter to debit-only rows (skip credits/income).
|
||||
5. Map raw entries to `PendingImportExpense` — parse Indonesian number format (`1.500.000,00`), parse date strings to `LocalDate`.
|
||||
6. Return `Result.success(list)` or `Result.failure(exception)`.
|
||||
|
||||
**`ImportBRIBankStatement.await`** — stub: `Result.failure(NotImplementedError("BRI import not yet implemented"))`.
|
||||
|
||||
**`ImportJagoBankStatement.await`** — stub: same.
|
||||
|
||||
**`ImportBNIBankStatement.await`** — stub: same.
|
||||
|
||||
---
|
||||
|
||||
## export / ExportExpensesToCsv
|
||||
|
||||
### `await(range: DateRange, outputUri: Uri): Result<Unit>`
|
||||
1. Fetch all `ExpenseWithCategory` in range from DAO (one-shot).
|
||||
2. Open `OutputStream` via `context.contentResolver.openOutputStream(outputUri)`.
|
||||
3. Wrap with Okio `BufferedSink`.
|
||||
4. Write UTF-8 BOM (`0xEF, 0xBB, 0xBF`) for Excel compatibility.
|
||||
5. Write header: `Date,Category,Amount,Note`.
|
||||
6. For each expense: `date` in ISO 8601 (`yyyy-MM-dd`), then `category.name`, `amount`, `"note"` (double-quote the note to handle embedded commas).
|
||||
7. Close sink.
|
||||
8. Return `Result.success(Unit)` or `Result.failure(exception)`.
|
||||
@@ -0,0 +1,412 @@
|
||||
# 04 — Implementation Plan
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
In `gradle/libs.versions.toml`:
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
room = "2.7.1"
|
||||
pdfbox_android = "2.0.27.0"
|
||||
|
||||
[libraries]
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref = "pdfbox_android" }
|
||||
```
|
||||
|
||||
In `app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
implementation(libs.pdfbox.android)
|
||||
```
|
||||
|
||||
**No chart library** — Canvas-based (`drawArc` for pie, `drawRect` for bars).
|
||||
**No manifest permissions** — SAF handles file access.
|
||||
|
||||
---
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
dev.achmad.ledgerr/
|
||||
│
|
||||
├── core/ (existing)
|
||||
│ ├── network/
|
||||
│ └── preference/
|
||||
│
|
||||
├── data/
|
||||
│ └── local/
|
||||
│ ├── AppDatabase.kt
|
||||
│ ├── converter/
|
||||
│ │ └── LocalDateConverter.kt - LocalDate <-> Long (epoch day)
|
||||
│ ├── dao/
|
||||
│ │ ├── CategoryDao.kt
|
||||
│ │ ├── ExpenseDao.kt
|
||||
│ │ └── RecurringExpenseDao.kt
|
||||
│ ├── entity/
|
||||
│ │ ├── CategoryEntity.kt
|
||||
│ │ ├── ExpenseEntity.kt
|
||||
│ │ └── RecurringExpenseEntity.kt
|
||||
│ └── mapper/
|
||||
│ ├── CategoryMapper.kt
|
||||
│ ├── ExpenseMapper.kt
|
||||
│ └── RecurringExpenseMapper.kt
|
||||
│
|
||||
├── di/
|
||||
│ ├── CoreModule.kt - provides PreferenceStore as AndroidPreferenceStore
|
||||
│ ├── DataModule.kt - DB, DAOs
|
||||
│ ├── DomainModule.kt - all interactors
|
||||
│ ├── PreferenceModule.kt - preference class singletons
|
||||
│ └── util/
|
||||
│ └── KoinExtensions.kt (existing)
|
||||
│
|
||||
├── domain/
|
||||
│ ├── preference/
|
||||
│ │ ├── AppPreference.kt - app-wide: theme
|
||||
│ │ └── ExpensePreference.kt - expense display: default date range filter
|
||||
│ │
|
||||
│ ├── expense/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── DateRange.kt
|
||||
│ │ │ ├── Expense.kt
|
||||
│ │ │ ├── ExpenseWithCategory.kt
|
||||
│ │ │ └── ExpenseSummary.kt
|
||||
│ │ └── interactor/
|
||||
│ │ ├── GetExpenses.kt
|
||||
│ │ ├── UpsertExpense.kt
|
||||
│ │ ├── InsertExpenses.kt
|
||||
│ │ ├── DeleteExpense.kt
|
||||
│ │ ├── ReassignExpenseCategory.kt
|
||||
│ │ └── GetExpenseSummary.kt
|
||||
│ │
|
||||
│ ├── category/
|
||||
│ │ ├── model/
|
||||
│ │ │ └── Category.kt
|
||||
│ │ └── interactor/
|
||||
│ │ ├── GetCategories.kt
|
||||
│ │ ├── UpsertCategory.kt
|
||||
│ │ ├── DeleteCategory.kt
|
||||
│ │ └── SeedDefaultCategories.kt
|
||||
│ │
|
||||
│ ├── recurring/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── RecurringExpense.kt
|
||||
│ │ │ ├── RecurringExpenseWithCategory.kt
|
||||
│ │ │ └── RecurringInterval.kt
|
||||
│ │ └── interactor/
|
||||
│ │ ├── GetRecurringExpenses.kt
|
||||
│ │ ├── UpsertRecurringExpense.kt
|
||||
│ │ ├── DeleteRecurringExpense.kt
|
||||
│ │ └── ProcessDueRecurringExpenses.kt
|
||||
│ │
|
||||
│ ├── bankstatement/
|
||||
│ │ ├── model/
|
||||
│ │ │ ├── PendingImportExpense.kt
|
||||
│ │ │ ├── BRIStatementEntry.kt
|
||||
│ │ │ ├── JagoStatementEntry.kt
|
||||
│ │ │ └── BNIStatementEntry.kt
|
||||
│ │ └── interactor/
|
||||
│ │ ├── BankStatementImporter.kt - interface
|
||||
│ │ ├── ImportBRIBankStatement.kt - stub
|
||||
│ │ ├── ImportJagoBankStatement.kt - stub
|
||||
│ │ └── ImportBNIBankStatement.kt - stub
|
||||
│ │
|
||||
│ └── export/
|
||||
│ └── interactor/
|
||||
│ └── ExportExpensesToCsv.kt
|
||||
│
|
||||
└── ui/
|
||||
├── base/
|
||||
│ ├── MainApplication.kt
|
||||
│ └── MainActivity.kt (existing)
|
||||
├── components/ (copied from info-krl-android, see below)
|
||||
│ ├── AppBar.kt
|
||||
│ ├── CardSection.kt
|
||||
│ ├── FilterChipGroup.kt
|
||||
│ ├── HelpCard.kt
|
||||
│ ├── LabeledCheckbox.kt
|
||||
│ ├── LabeledRadioButton.kt
|
||||
│ ├── LazyGridScrollbar.kt
|
||||
│ ├── LazyListScrollbar.kt
|
||||
│ ├── LinkIcon.kt
|
||||
│ ├── Pill.kt
|
||||
│ ├── ResultScreen.kt
|
||||
│ ├── ScrollbarLazyColumn.kt
|
||||
│ ├── ScrollbarLazyGrid.kt
|
||||
│ ├── SpotlightOverlay.kt
|
||||
│ ├── TabText.kt
|
||||
│ └── preference/ (copied from info-krl-android)
|
||||
│ ├── Preference.kt
|
||||
│ ├── PreferenceItem.kt
|
||||
│ ├── PreferenceScreen.kt
|
||||
│ └── widget/
|
||||
│ ├── AlertDialogPreferenceWidget.kt
|
||||
│ ├── BasePreferenceWidget.kt
|
||||
│ ├── BasicMultiSelectListPreferenceWidget.kt
|
||||
│ ├── CheckPreferenceWidget.kt
|
||||
│ ├── EditTextPreferenceWidget.kt
|
||||
│ ├── InfoWidget.kt
|
||||
│ ├── ListPreferenceWidget.kt
|
||||
│ ├── ListSearchPreferenceWidget.kt
|
||||
│ ├── MultiSelectListPreferenceWidget.kt
|
||||
│ ├── PermissionPreferenceWidget.kt
|
||||
│ ├── PreferenceGroupHeader.kt
|
||||
│ ├── SwitchPreferenceWidget.kt
|
||||
│ ├── TextPreferenceWidget.kt
|
||||
│ └── TriStateListDialog.kt
|
||||
├── screens/
|
||||
│ ├── home/
|
||||
│ │ └── HomeScreen.kt - dashboard + recent expenses
|
||||
│ ├── expenses/
|
||||
│ │ └── ExpenseListScreen.kt - 2-tab screen (Expenses | Recurring)
|
||||
│ ├── add_edit_expense/
|
||||
│ │ └── AddEditExpenseScreen.kt - shared add & edit
|
||||
│ ├── category/
|
||||
│ │ └── CategoryScreen.kt
|
||||
│ ├── import_bank_statement/
|
||||
│ │ └── ImportBankStatementScreen.kt - 1 Voyager Screen, 2 internal composables:
|
||||
│ │ ImportBankStatementPickerContent (bank picker + SAF trigger + processing)
|
||||
│ │ ImportBankStatementConfirmationContent (review/edit/confirm)
|
||||
│ └── settings/
|
||||
│ └── SettingsScreen.kt - uses PreferenceScreen component
|
||||
├── util/ (copied from info-krl-android)
|
||||
│ ├── ColorUtil.kt
|
||||
│ ├── ModifierUtil.kt
|
||||
│ ├── NavigatorUtil.kt
|
||||
│ ├── PreferenceUtil.kt
|
||||
│ ├── StringUtil.kt
|
||||
│ ├── TextFieldUtil.kt
|
||||
│ ├── UiImage.kt
|
||||
│ └── UiText.kt
|
||||
└── theme/ (existing)
|
||||
├── Theme.kt
|
||||
└── Type.kt
|
||||
```
|
||||
|
||||
### ScreenModel pattern
|
||||
|
||||
ScreenModels are **not** registered in Koin. Interactors and preference classes are injected as constructor default parameters using `inject()` from `KoinExtensions`:
|
||||
|
||||
```kotlin
|
||||
class HomeScreenModel(
|
||||
private val getExpenses: GetExpenses = inject(),
|
||||
private val getExpenseSummary: GetExpenseSummary = inject(),
|
||||
private val processRecurring: ProcessDueRecurringExpenses = inject(),
|
||||
private val appPreference: AppPreference = inject(),
|
||||
) : ScreenModel { ... }
|
||||
```
|
||||
|
||||
In `Screen.Content()`:
|
||||
|
||||
```kotlin
|
||||
val screenModel = rememberScreenModel { HomeScreenModel() }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preference Classes
|
||||
|
||||
### `domain/preference/AppPreference.kt`
|
||||
|
||||
```kotlin
|
||||
class AppPreference(private val store: PreferenceStore) {
|
||||
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
|
||||
}
|
||||
|
||||
enum class AppTheme { LIGHT, DARK, SYSTEM }
|
||||
```
|
||||
|
||||
### `domain/preference/ExpensePreference.kt`
|
||||
|
||||
```kotlin
|
||||
class ExpensePreference(private val store: PreferenceStore) {
|
||||
fun defaultDateRange() = store.getEnum("expense_default_date_range", defaultValue = DateRangeOption.THIS_MONTH)
|
||||
}
|
||||
|
||||
enum class DateRangeOption { THIS_WEEK, THIS_MONTH }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
Pure stack navigation using Voyager `Navigator`. No `TabNavigator` at the root level.
|
||||
|
||||
```
|
||||
HomeScreen (root)
|
||||
├── FAB (expanded) ─────────────────────────── "Manual" → AddEditExpenseScreen
|
||||
│ "Import Bank Statement" → ImportBankStatementScreen
|
||||
├── Export icon ────────────────────────────── date range dialog → SAF CreateDocument → writes CSV
|
||||
├── "See all" / "View expenses" button ──────── → ExpenseListScreen
|
||||
│ ├── Tab 1: Expenses list (search + filter)
|
||||
│ │ └── FAB (expanded) → "Manual" → AddEditExpenseScreen
|
||||
│ │ → "Import" → ImportBankStatementScreen
|
||||
│ └── Tab 2: Recurring list
|
||||
├── "Manage Categories" button (dashboard) ──── → CategoryScreen
|
||||
└── Settings icon ──────────────────────────── → SettingsScreen
|
||||
|
||||
AddEditExpenseScreen
|
||||
└── "Manage Categories" button ───────────────── → CategoryScreen
|
||||
|
||||
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
|
||||
├── ImportBankStatementPickerContent — rendered when state is BankPicker or Processing
|
||||
│ • list of banks from List<BankStatementImporter>
|
||||
│ • selecting a bank opens SAF OpenDocument picker (PDF)
|
||||
│ • loading overlay on top while state is Processing
|
||||
└── ImportBankStatementConfirmationContent — rendered when state is Confirmation
|
||||
• list of PendingImportExpense
|
||||
• toggle selection per row
|
||||
• edit amount / date / description / category inline
|
||||
• "Import X items" → InsertExpenses.awaitAll() → navigator.pop()
|
||||
```
|
||||
|
||||
Notes:
|
||||
- **ExpenseListScreen tabs** are plain Compose `TabRow` / `HorizontalPager` — not Voyager tabs.
|
||||
- **FAB expansion** on both `HomeScreen` and `ExpenseListScreen`: tapping reveals two sub-actions; second tap or outside tap collapses it.
|
||||
- **Export** — tapping the export icon shows a date range picker dialog (start + end date); after confirming the range the SAF `CreateDocument` picker opens. The ScreenModel writes the CSV (ISO 8601 dates, UTF-8 BOM) when the URI comes back. Export icon lives in `HomeScreen` top bar, `ExpenseListScreen` top bar, and as a `TextPreference` in `SettingsScreen`.
|
||||
- **Import** goes through `ImportBankStatementScreen` (one Voyager Screen, one ScreenModel). The ScreenModel owns a `StateFlow<ImportState>` that switches between two internal composables: `ImportBankStatementPickerContent` (BankPicker + Processing overlay) and `ImportBankStatementConfirmationContent` (review, edit, confirm). On confirmation the ScreenModel calls `InsertExpenses.awaitAll()` then pops.
|
||||
- **CategoryScreen** reachable from the "Manage Categories" button on `HomeScreen` dashboard and from inside `AddEditExpenseScreen`.
|
||||
- **SettingsScreen** uses `PreferenceScreen` component (same pattern as `MoreTab` in info-krl-android). Exposes: theme selector (`ListPreference`), export action (`TextPreference`), clear data (`AlertDialogPreference`).
|
||||
|
||||
---
|
||||
|
||||
## DI Wiring
|
||||
|
||||
### CoreModule
|
||||
|
||||
```kotlin
|
||||
val coreModule = module {
|
||||
single {
|
||||
get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
single<PreferenceStore> { AndroidPreferenceStore(get()) }
|
||||
}
|
||||
```
|
||||
|
||||
### DataModule
|
||||
|
||||
```kotlin
|
||||
val dataModule = module {
|
||||
single {
|
||||
Room.databaseBuilder(androidApplication(), AppDatabase::class.java, "ledgerr.db").build()
|
||||
}
|
||||
single { get<AppDatabase>().categoryDao() }
|
||||
single { get<AppDatabase>().expenseDao() }
|
||||
single { get<AppDatabase>().recurringExpenseDao() }
|
||||
}
|
||||
```
|
||||
|
||||
### DomainModule
|
||||
|
||||
```kotlin
|
||||
val domainModule = module {
|
||||
// expense
|
||||
factory { GetExpenses(get(), get()) }
|
||||
factory { UpsertExpense(get()) }
|
||||
factory { InsertExpenses(get()) }
|
||||
factory { DeleteExpense(get()) }
|
||||
factory { ReassignExpenseCategory(get()) }
|
||||
factory { GetExpenseSummary(get(), get()) }
|
||||
|
||||
// category
|
||||
factory { GetCategories(get()) }
|
||||
factory { UpsertCategory(get()) }
|
||||
factory { DeleteCategory(get(), get(), get()) }
|
||||
factory { SeedDefaultCategories(get()) }
|
||||
|
||||
// recurring
|
||||
factory { GetRecurringExpenses(get(), get()) }
|
||||
factory { UpsertRecurringExpense(get()) }
|
||||
factory { DeleteRecurringExpense(get()) }
|
||||
factory { ProcessDueRecurringExpenses(get(), get()) }
|
||||
|
||||
// bankstatement
|
||||
factory<BankStatementImporter>(named("bri")) { ImportBRIBankStatement(androidContext()) }
|
||||
factory<BankStatementImporter>(named("jago")) { ImportJagoBankStatement(androidContext()) }
|
||||
factory<BankStatementImporter>(named("bni")) { ImportBNIBankStatement(androidContext()) }
|
||||
factory<List<BankStatementImporter>> {
|
||||
listOf(get(named("bri")), get(named("jago")), get(named("bni")))
|
||||
}
|
||||
|
||||
// export
|
||||
factory { ExportExpensesToCsv(get(), get(), androidContext()) }
|
||||
}
|
||||
```
|
||||
|
||||
### PreferenceModule
|
||||
|
||||
```kotlin
|
||||
val preferenceModule = module {
|
||||
single { AppPreference(get()) }
|
||||
single { ExpensePreference(get()) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### MainApplication
|
||||
|
||||
```kotlin
|
||||
class MainApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PDFBoxResourceLoader.init(this)
|
||||
startKoin {
|
||||
androidLogger()
|
||||
androidContext(this@MainApplication)
|
||||
modules(coreModule, dataModule, domainModule, preferenceModule)
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
inject<SeedDefaultCategories>().await()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register in `AndroidManifest.xml` as `android:name=".ui.base.MainApplication"`.
|
||||
|
||||
### AppDatabase seeding
|
||||
|
||||
After `startKoin` completes in `MainApplication.onCreate`, call `SeedDefaultCategories` via a `CoroutineScope(Dispatchers.IO)`:
|
||||
|
||||
```kotlin
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
inject<SeedDefaultCategories>().await()
|
||||
}
|
||||
```
|
||||
|
||||
`SeedDefaultCategories.await()` is a no-op if categories already exist, so this is safe to call on every launch. No Room Callback is used for seeding.
|
||||
|
||||
### Recurring processor trigger
|
||||
|
||||
`HomeScreen`'s ScreenModel calls `ProcessDueRecurringExpenses` in `init {}`. If any are added, a dismissable banner appears on the dashboard.
|
||||
|
||||
### SAF file pickers
|
||||
|
||||
- PDF import: `ActivityResultContracts.OpenDocument(arrayOf("application/pdf"))`
|
||||
- CSV export: `ActivityResultContracts.CreateDocument("text/csv")`
|
||||
|
||||
Launchers registered in the Screen composable; URIs passed to the ScreenModel.
|
||||
|
||||
---
|
||||
|
||||
## Build Order
|
||||
|
||||
1. Domain models (all features)
|
||||
2. Domain preference classes
|
||||
3. Data: entities, DAOs, `LocalDateConverter`, `AppDatabase`
|
||||
4. Mappers
|
||||
5. Domain interactors (category → expense → recurring → bank stubs → export)
|
||||
6. DI modules
|
||||
7. `MainApplication` + manifest
|
||||
8. Screens: `HomeScreen` → `ExpenseListScreen` → `AddEditExpenseScreen` → `CategoryScreen` → `ImportBankStatementScreen` → `SettingsScreen`
|
||||
9. UI components extracted as needed during screen work
|
||||
Reference in New Issue
Block a user