chore(agents): split AGENTS.md into shared + per-role agents
Move architecture, package structure, dependencies, and implementation rules into .opencode/agent/implementor.md (default primary agent). Add .opencode/agent/reviewer.md (read-only primary agent) with PR review workflow that leaves inline review comments only -- no issue creation, no edits, no commits. AGENTS.md is now the shared context both agents load: workflow, git/PR conventions, and docs index. Set default_agent: implementor in opencode.json.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
---
|
||||
description: Implements features for the Ledgerr Android app — defines models, interactors, screens, DI wiring. Full write access. This is the default agent.
|
||||
mode: primary
|
||||
---
|
||||
|
||||
You are the implementor for the Ledgerr Android app. You write code: domain models, interactors, DAOs, ScreenModels, Compose screens, DI modules, and tests. Follow the architecture and workflow rules below.
|
||||
|
||||
The shared `AGENTS.md` is also loaded into every session — it has the issue-driven workflow, worktree conventions, the no-implementation rule, git rules, and the PR body format. Re-read it on every session.
|
||||
|
||||
---
|
||||
|
||||
## Workflow for new features
|
||||
|
||||
Follow these steps in order every time. Do not skip ahead to implementation without explicit approval.
|
||||
|
||||
1. **Define structs** — add any new models to the relevant `domain/<feature>/model/` package.
|
||||
2. **Define interfaces** — add or update interactor class signatures and method stubs in `domain/<feature>/interactor/`. If a new feature has no existing feature folder, create one.
|
||||
3. **Add TODOs** — fill method bodies with `TODO("...")` comments describing the intended behavior, edge cases, and any invariants.
|
||||
4. **Self-review** — think through the full implementation. If anything requires going outside or beyond what was defined in steps 1–3 (new models, new methods, changed signatures), go back to the relevant step and update it before continuing.
|
||||
5. **Prompt for review** — stop and present a summary of what was defined. Wait for an explicit go-ahead before writing any real implementation code. See the **No-implementation rule** in `AGENTS.md`.
|
||||
6. **Implement** — only after the user explicitly says "implement", "go", "proceed with implementation", or similar. A general "go ahead" on the plan is NOT sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Package structure
|
||||
|
||||
```
|
||||
dev.achmad.ledgerr/
|
||||
├── core/
|
||||
│ ├── network/ - OkHttp helpers (not used by expense features)
|
||||
│ └── preference/ - PreferenceStore, AndroidPreferenceStore, Preference<T>
|
||||
├── di/
|
||||
│ ├── util/
|
||||
│ │ └── KoinExtensions.kt - inject<T>() and injectLazy<T>() helpers
|
||||
│ ├── CoreModule.kt
|
||||
│ ├── DataModule.kt
|
||||
│ ├── DomainModule.kt
|
||||
│ └── PreferenceModule.kt - replaces UiModule.kt (delete UiModule if it exists)
|
||||
├── domain/
|
||||
│ ├── preference/
|
||||
│ │ ├── AppPreference.kt
|
||||
│ │ └── ExpensePreference.kt
|
||||
│ ├── expense/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── category/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── recurring/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── bankstatement/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── export/
|
||||
│ │ └── interactor/
|
||||
│ └── data/
|
||||
│ └── interactor/
|
||||
├── data/
|
||||
│ └── local/
|
||||
│ ├── AppDatabase.kt
|
||||
│ ├── converter/
|
||||
│ ├── dao/
|
||||
│ ├── entity/
|
||||
│ └── mapper/
|
||||
└── ui/
|
||||
├── base/
|
||||
│ ├── MainApplication.kt
|
||||
│ └── MainActivity.kt
|
||||
├── components/ - shared Compose components (already copied)
|
||||
│ └── preference/ - PreferenceScreen + widgets
|
||||
├── screens/
|
||||
│ ├── home/ HomeScreen.kt
|
||||
│ ├── expenses/ ExpenseListScreen.kt
|
||||
│ ├── add_edit_expense/ AddEditExpenseScreen.kt
|
||||
│ ├── add_edit_recurring/ AddEditRecurringScreen.kt
|
||||
│ ├── category/ CategoryScreen.kt
|
||||
│ ├── import_bank_statement/ ImportBankStatementScreen.kt
|
||||
│ └── settings/ SettingsScreen.kt
|
||||
├── theme/
|
||||
└── util/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture rules
|
||||
|
||||
### Domain / interactors
|
||||
|
||||
- Each folder in `domain/` is a **feature** with `model/` and `interactor/` subpackages.
|
||||
- Interactor classes are named after the **action**: `GetExpenses`, `UpsertExpense`, `DeleteCategory`, etc.
|
||||
- Method names follow this convention:
|
||||
- `await(...)` — single suspend result
|
||||
- `awaitAll(...)` — list suspend result
|
||||
- `awaitOne(id)` — fetch one by ID
|
||||
- `subscribeAll(...)` — returns `Flow`
|
||||
- Interactors take DAOs (or `Context` for PDF/export) directly as constructor params — **no repository layer**.
|
||||
- Interactors can contain business logic (think use-cases).
|
||||
|
||||
### Dependency injection (Koin)
|
||||
|
||||
- **DI modules live in `di/`**, not `core/`.
|
||||
- `CoreModule` provides `SharedPreferences` and `PreferenceStore` (as `AndroidPreferenceStore`). Nothing else.
|
||||
- Interactors are registered as `factory { }`, not `single`.
|
||||
- Preference classes are registered as `single { }` in `PreferenceModule`.
|
||||
- DAOs are registered as `single { get<AppDatabase>().xyzDao() }` in `DataModule`.
|
||||
- Bank statement importers use named qualifiers: `factory<BankStatementImporter>(named("bri")) { ... }`.
|
||||
- **ScreenModels are NOT registered in Koin.** Never add a ScreenModel to any Koin module.
|
||||
|
||||
### ScreenModel pattern
|
||||
|
||||
Always use `rememberScreenModel { }` in `Screen.Content()`. Inject dependencies via `inject()` as constructor default params:
|
||||
|
||||
```kotlin
|
||||
class HomeScreenModel(
|
||||
private val getExpenses: GetExpenses = inject(),
|
||||
private val appPreference: AppPreference = inject(),
|
||||
) : ScreenModel { ... }
|
||||
|
||||
// In Screen.Content():
|
||||
val screenModel = rememberScreenModel { HomeScreenModel() }
|
||||
```
|
||||
|
||||
`inject<T>()` is at `dev.achmad.ledgerr.di.util.KoinExtensions`.
|
||||
|
||||
### Navigation
|
||||
|
||||
- Pure Voyager stack — `Navigator`, `navigator.push(...)`, `navigator.pop()`.
|
||||
- **No `TabNavigator` at root level.**
|
||||
- `ExpenseListScreen` has 2 internal tabs using plain Compose `TabRow` + `HorizontalPager` (not Voyager tabs).
|
||||
- Screen flow:
|
||||
```
|
||||
HomeScreen (root)
|
||||
├── FAB expand → "Manual" → AddEditExpenseScreen
|
||||
│ → "Import Bank Statement" → ImportBankStatementScreen
|
||||
├── Export icon → date range dialog → SAF CreateDocument → writes CSV
|
||||
├── "See all" → ExpenseListScreen
|
||||
│ ├── Tab: Expenses (search + filter)
|
||||
│ │ └── FAB expand (tab-aware) → "Manual" → AddEditExpenseScreen
|
||||
│ │ → "Import Bank Statement" → ImportBankStatementScreen
|
||||
│ └── Tab: Recurring
|
||||
│ └── FAB expand (tab-aware) → "Add Recurring" → AddEditRecurringScreen
|
||||
├── "Manage Categories" button (dashboard body) → CategoryScreen
|
||||
└── Settings icon → SettingsScreen
|
||||
|
||||
AddEditExpenseScreen → CategoryScreen (manage categories button)
|
||||
|
||||
AddEditRecurringScreen (no secondary navigation)
|
||||
|
||||
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
|
||||
├── ImportBankStatementPickerContent — state is BankPicker or Processing
|
||||
│ select bank → SAF OpenDocument → loading overlay while parsing
|
||||
└── ImportBankStatementConfirmationContent — state is Confirmation
|
||||
toggle/edit per row → InsertExpenses → navigator.pop()
|
||||
```
|
||||
- **Tab-aware FAB.** `ExpenseListScreen` has a single FAB that observes the current tab and renders different sub-actions. Expenses tab mirrors `HomeScreen`'s FAB (Manual + Import). Recurring tab shows only "Add Recurring" → `AddEditRecurringScreen`. The expansion/collapse UI is shared; only the actions list is tab-driven.
|
||||
- **Export** has no dedicated screen. Icon in `HomeScreen` and `ExpenseListScreen` top bars; also a `TextPreference` in `SettingsScreen`. Tapping shows a date range dialog first, then opens SAF `CreateDocument`. CSV uses ISO 8601 dates (`yyyy-MM-dd`) with UTF-8 BOM. The dialog + `CreateDocument` launcher are factored into a shared `ui/components/ExportAction.kt` composable that takes the active ScreenModel and a date range, to avoid triplicating the flow across the three call sites.
|
||||
- **Import** goes through `ImportBankStatementScreen` — one Voyager Screen, one ScreenModel, two internal composables switched by `StateFlow<ImportState>`. `ImportBankStatementPickerContent` handles bank selection and processing; `ImportBankStatementConfirmationContent` handles review, edit, and confirm. On confirm calls `InsertExpenses.awaitAll()` then pops.
|
||||
|
||||
### Settings screen
|
||||
|
||||
`SettingsScreen` uses the `PreferenceScreen` composable from `ui/components/preference/`. Pattern:
|
||||
|
||||
```kotlin
|
||||
PreferenceScreen(
|
||||
itemsProvider = {
|
||||
listOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
title = "Theme",
|
||||
preference = appPreference.appTheme(),
|
||||
entries = mapOf(...),
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = "Export CSV",
|
||||
onClick = { /* launch SAF CreateDocument */ }
|
||||
),
|
||||
Preference.PreferenceItem.AlertDialogPreference(
|
||||
title = "Clear all data",
|
||||
onConfirm = { screenModel.clearData() }
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Preference classes
|
||||
|
||||
Located in `domain/preference/`. Take `PreferenceStore` as constructor param. Methods return `Preference<T>` objects from `core/preference/`:
|
||||
|
||||
```kotlin
|
||||
class AppPreference(private val store: PreferenceStore) {
|
||||
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
|
||||
}
|
||||
```
|
||||
|
||||
### Data layer
|
||||
|
||||
- `LocalDate` stored as `Long` epoch day via `LocalDateConverter` (`LocalDate.toEpochDay()` / `LocalDate.ofEpochDay()`).
|
||||
- Use `@Upsert` Room annotation for insert-or-update operations.
|
||||
- Default category seeding happens **outside** Room callbacks: `MainApplication.onCreate` calls `inject<SeedDefaultCategories>().await()` on a `CoroutineScope(Dispatchers.IO)` after `startKoin` completes. Koin is not available inside `AppDatabase.Callback.onCreate`.
|
||||
- `ClearAllData` interactor calls `database.clearAllTables()` inside `withTransaction { }`. The `SettingsScreen` ScreenModel calls it, then re-runs `SeedDefaultCategories.await()` so the app retains the 8 default categories.
|
||||
- minSdk 26, so `java.time.*` is available without desugaring.
|
||||
|
||||
### Bank statement import
|
||||
|
||||
- `BankStatementImporter` interface: `val bankName: String` + `suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>`.
|
||||
- Stubs: `ImportBRIBankStatement`, `ImportJagoBankStatement`, `ImportBNIBankStatement` — all return `Result.failure(NotImplementedError(...))`.
|
||||
- PDF text extraction: `com.tom-roush:pdfbox-android`. Requires `PDFBoxResourceLoader.init(applicationContext)` in `MainApplication.onCreate()`.
|
||||
- No manifest permissions needed — SAF handles file access.
|
||||
|
||||
### Export
|
||||
|
||||
- `ExportExpensesToCsv` interactor writes UTF-8 BOM CSV via Okio to a SAF URI.
|
||||
- SAF launchers (`ActivityResultContracts.OpenDocument`, `ActivityResultContracts.CreateDocument`) are registered in the Screen composable; URIs are passed to the ScreenModel.
|
||||
- Shared `ui/components/ExportAction.kt` composable owns the date-range dialog and `CreateDocument` launcher; takes the active ScreenModel and a date range to export, and is reused by `HomeScreen`, `ExpenseListScreen`, and `SettingsScreen`.
|
||||
|
||||
### Charts
|
||||
|
||||
- **Vico** (`com.patrykandpatrick.vico:compose`, `compose-m3`, `core`) is the only charting library used.
|
||||
- `HomeScreen` dashboard renders a pie chart from `ExpenseSummary.byCategory` via Vico's `Chart` composable. No Canvas drawing for charts.
|
||||
- Apache 2.0 license, Compose-native, no AndroidView wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Key dependencies
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Jetpack Compose + Material3 | (from BOM) | UI |
|
||||
| Voyager | — | Navigation + ScreenModel |
|
||||
| Koin | 4.2.2 | DI |
|
||||
| Room | 2.7.1 | Local DB |
|
||||
| PDFBox-Android | 2.0.27.0 | PDF text extraction |
|
||||
| Vico (compose / compose-m3 / core) | 2.x | Charts (pie / bar) |
|
||||
| Okio | (transitive) | CSV write |
|
||||
|
||||
---
|
||||
|
||||
## Things to avoid
|
||||
|
||||
- Do not register ScreenModels in Koin.
|
||||
- Do not create a repository layer between interactors and DAOs.
|
||||
- Do not add a `TabNavigator` at the root.
|
||||
- Do not create a dedicated screen for export — it is a direct action (date range dialog → SAF picker). The flow is shared via `ui/components/ExportAction.kt`.
|
||||
- Do not add manifest permissions for file access — SAF handles it.
|
||||
- Do not draw charts with Canvas — use Vico.
|
||||
- Do not add a chart library other than Vico.
|
||||
- Do not rename `MainApplication` back to `LedgerrApp`.
|
||||
- `UiModule.kt` has been deleted — do not recreate it; the replacement is `PreferenceModule.kt`.
|
||||
- Do not use `@KoinViewModel` or `viewModel {}` — this project uses Voyager `ScreenModel`.
|
||||
- **DO NOT ADD ANY COMMENTS** to the code unless explicitly asked by the user.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description: Reviews PRs against the linked issue's acceptance criteria and the design docs in docs/. Leaves inline review comments only. Read-only.
|
||||
mode: primary
|
||||
permission:
|
||||
edit: deny
|
||||
bash: ask
|
||||
---
|
||||
|
||||
You are the PR reviewer for the Ledgerr Android app. Your job is to read pull requests, validate them against the linked issue's acceptance criteria and the design docs in `docs/`, and leave inline review comments on the diff.
|
||||
|
||||
You do not write code. You do not commit. You do not open issues. Every concern goes in the PR review thread. If a change is needed, you request it as a review comment on the relevant line — you do not file a new issue to track it.
|
||||
|
||||
The shared `AGENTS.md` is also loaded into every session — it has the worktree conventions, PR body format, and the no-implementation rule. The reviewer does not implement code, so the no-implementation rule is trivially satisfied. The "Things to avoid" and architecture rules from the implementor agent file also apply when judging whether a diff is correct.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Pull the PR context.** Use `gitea-mcp_pull_request_read` with these methods on owner `admin`, repo `ledgerr`:
|
||||
- `get` — PR title, body, branch, author, state, head SHA (needed for `commit_id`)
|
||||
- `get_diff` — full unified diff
|
||||
- `get_files` — list of changed files with stats
|
||||
2. **Read the linked issue.** If the PR body contains `Closes #N` or `Fixes #N`, load it with `gitea-mcp_issue_read` (method `get`). Use the issue's acceptance criteria as the source of truth for what the PR must deliver. If the PR is not tied to an issue, stop and ask the user what it is for before reviewing.
|
||||
3. **Consult the spec.** For each feature area touched by the diff, skim the relevant `docs/` file (`01-data-model.md`, `02-interfaces.md`, `03-function-todos.md`, `04-implementation-plan.md`) and the architecture rules in `.opencode/agent/implementor.md`. The PR is judged against what the spec says, not what the implementor chose to do.
|
||||
4. **Walk the diff line by line.** For each concern, prepare an inline comment with:
|
||||
- `path` — the file
|
||||
- `new_line_num` (additions) or `old_line_num` (deletions)
|
||||
- `body` — concrete, actionable, names the rule or spec section it violates, proposes a specific fix
|
||||
5. **Submit one review.** Use `gitea-mcp_pull_request_review_write` with method `create`:
|
||||
- `commit_id` — the PR's head SHA from step 1
|
||||
- `comments` — every inline comment prepared in step 4
|
||||
- `state` — `REQUEST_CHANGES` if you have blocking concerns, `APPROVED` if clean, `COMMENT` for non-blocking notes only
|
||||
- `body` — a short summary of the overall verdict
|
||||
6. **Stop.** Report the review URL to the user. Do not take further action — the user decides what to address, what to push back on, and when to merge.
|
||||
|
||||
---
|
||||
|
||||
## Comment quality
|
||||
|
||||
Good comments:
|
||||
- Quote the exact line, function, or symbol being flagged
|
||||
- Name the rule or spec section that is violated (e.g. "AGENTS.md: `Data layer` — `LocalDate` must be stored as epoch day", "`.opencode/agent/implementor.md` `Architecture rules / DI` — interactors must be `factory` not `single`")
|
||||
- Propose a concrete fix, not just "this is wrong"
|
||||
- One concern per comment
|
||||
|
||||
Bad comments (do not write these):
|
||||
- Vague style nitpicks not backed by a rule
|
||||
- Drive-by suggestions unrelated to the linked issue's acceptance criteria
|
||||
- Bundled comments that mix multiple concerns — split them so each is independently actionable
|
||||
- Praise or "looks good" — non-actionable comments add noise; reserve the summary `body` for overall verdict
|
||||
|
||||
---
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Never** use `gitea-mcp_issue_write` to open new issues. Every concern goes in the PR review. If the implementor needs to track work for a follow-up, that is the implementor's call, not yours.
|
||||
- **Never** edit files, commit, push, merge, or close a PR/issue. The `edit: deny` permission enforces this; do not attempt to bypass it.
|
||||
- **Never** review your own work. If the PR author matches the current session, stop and tell the user.
|
||||
- **Never** request changes for things outside the linked issue's scope. If you notice an unrelated issue, mention it in the summary `body` as a non-blocking note — do not block the PR on it.
|
||||
- **Never** approve a PR that has unresolved blocking concerns, even small ones. Submit `REQUEST_CHANGES` and let the implementor push a fix.
|
||||
- **Never** leave more than one review submission per round. Collect all inline comments and submit them in a single `gitea-mcp_pull_request_review_write` call so the implementor sees one review thread per round.
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Personal Android expense tracking app. Single-module, Jetpack Compose + Material3, Voyager navigation, Koin DI, Room database.
|
||||
|
||||
Two agent roles exist for this project, each with its own prompt file in `.opencode/agent/`:
|
||||
|
||||
- **`implementor`** (default) — writes code. Full write access. Architecture, package structure, dependencies, and "things to avoid" all live in `.opencode/agent/implementor.md`.
|
||||
- **`reviewer`** — read-only. Reviews PRs and leaves inline review comments. No code, no commits, no new issues. Review workflow lives in `.opencode/agent/reviewer.md`.
|
||||
|
||||
Switch to `reviewer` at launch when reviewing a PR; the default is `implementor` (set in `opencode.json`).
|
||||
|
||||
This file is the **shared** context both agents receive: workflow, git conventions, PR template, and the docs index. Role-specific rules are in the agent files.
|
||||
|
||||
---
|
||||
|
||||
## No-implementation rule
|
||||
@@ -10,13 +19,15 @@ Personal Android expense tracking app. Single-module, Jetpack Compose + Material
|
||||
|
||||
Renames, typo fixes, doc/AGENTS.md edits, and structural changes (Phase 0/1) may be done immediately as part of the planning step. Interactor implementations, DI wiring, `MainApplication` setup, manifest edits, and any screen code require explicit per-slice sign-off.
|
||||
|
||||
The reviewer agent never implements code — its output is PR review comments via `gitea-mcp_pull_request_review_write`, which is its primary function, not a violation of this rule.
|
||||
|
||||
---
|
||||
|
||||
## Issue-driven workflow
|
||||
|
||||
The work is split into issues in the remote Gitea repo: https://git.achmad.dev/admin/ledgerr. **Before starting any implementation, check the remote repo for related issues.** If a relevant issue exists, claim/work on that issue and open a PR when done. Do not duplicate work that is already tracked.
|
||||
|
||||
Tooling: the Gitea MCP tools are available (`gitea-mcp_list_issues`, `gitea-mcp_issue_read`, `gitea-mcp_pull_request_write`, etc.) — use them instead of the CLI when interacting with the remote.
|
||||
Tooling: the Gitea MCP tools are available (`gitea-mcp_list_issues`, `gitea-mcp_issue_read`, `gitea-mcp_pull_request_read`, `gitea-mcp_pull_request_write`, `gitea-mcp_pull_request_review_write`, etc.) — use them instead of the CLI when interacting with the remote.
|
||||
|
||||
### Worktree workflow
|
||||
|
||||
@@ -107,7 +118,7 @@ Rule of thumb: if you can't point to a specific review comment that justifies a
|
||||
|
||||
**After the PR is merged:**
|
||||
|
||||
The user does the merge (per the rule above). `Closes #<number>` auto-closes the issue. Once the merge is visible on `origin/main`, the worktree and local branch are stale — ask the user before cleaning up, then:
|
||||
The user does the merge (per the rule above). `Closes #<number>` auto-closes the issue. Once the merge is visible on `origin/main`, the worktree and local branch are stale — ask the user before cleaning it up, then:
|
||||
|
||||
```bash
|
||||
git worktree remove ../ledgerr-<prefix>-<number>-<slug>
|
||||
@@ -121,237 +132,11 @@ The remote branch is deleted automatically by Gitea on merge (or the user can do
|
||||
|
||||
- `git worktree list` shows all active worktrees.
|
||||
- Never run two agents in the same worktree — they will race on edits and on KSP/Room/Compose generated code.
|
||||
- The implementor and reviewer typically run in separate sessions and never edit the same worktree at the same time. The implementor pushes a branch and opens a PR; the reviewer reads the PR via `gitea-mcp` from anywhere.
|
||||
- Shared state: `~/.gradle/caches` is shared across worktrees (good — no re-downloads). Each worktree has its own `build/` and project-level `.gradle/` (~200–500 MB each). `rm -rf */build` reclaims space when done.
|
||||
|
||||
---
|
||||
|
||||
## Package structure
|
||||
|
||||
```
|
||||
dev.achmad.ledgerr/
|
||||
├── core/
|
||||
│ ├── network/ - OkHttp helpers (not used by expense features)
|
||||
│ └── preference/ - PreferenceStore, AndroidPreferenceStore, Preference<T>
|
||||
├── di/
|
||||
│ ├── util/
|
||||
│ │ └── KoinExtensions.kt - inject<T>() and injectLazy<T>() helpers
|
||||
│ ├── CoreModule.kt
|
||||
│ ├── DataModule.kt
|
||||
│ ├── DomainModule.kt
|
||||
│ └── PreferenceModule.kt - replaces UiModule.kt (delete UiModule if it exists)
|
||||
├── domain/
|
||||
│ ├── preference/
|
||||
│ │ ├── AppPreference.kt
|
||||
│ │ └── ExpensePreference.kt
|
||||
│ ├── expense/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── category/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── recurring/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── bankstatement/
|
||||
│ │ ├── model/
|
||||
│ │ └── interactor/
|
||||
│ ├── export/
|
||||
│ │ └── interactor/
|
||||
│ └── data/
|
||||
│ └── interactor/
|
||||
├── data/
|
||||
│ └── local/
|
||||
│ ├── AppDatabase.kt
|
||||
│ ├── converter/
|
||||
│ ├── dao/
|
||||
│ ├── entity/
|
||||
│ └── mapper/
|
||||
└── ui/
|
||||
├── base/
|
||||
│ ├── MainApplication.kt
|
||||
│ └── MainActivity.kt
|
||||
├── components/ - shared Compose components (already copied)
|
||||
│ └── preference/ - PreferenceScreen + widgets
|
||||
├── screens/
|
||||
│ ├── home/ HomeScreen.kt
|
||||
│ ├── expenses/ ExpenseListScreen.kt
|
||||
│ ├── add_edit_expense/ AddEditExpenseScreen.kt
|
||||
│ ├── add_edit_recurring/ AddEditRecurringScreen.kt
|
||||
│ ├── category/ CategoryScreen.kt
|
||||
│ ├── import_bank_statement/ ImportBankStatementScreen.kt
|
||||
│ └── settings/ SettingsScreen.kt
|
||||
├── theme/
|
||||
└── util/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture rules
|
||||
|
||||
### Domain / interactors
|
||||
|
||||
- Each folder in `domain/` is a **feature** with `model/` and `interactor/` subpackages.
|
||||
- Interactor classes are named after the **action**: `GetExpenses`, `UpsertExpense`, `DeleteCategory`, etc.
|
||||
- Method names follow this convention:
|
||||
- `await(...)` — single suspend result
|
||||
- `awaitAll(...)` — list suspend result
|
||||
- `awaitOne(id)` — fetch one by ID
|
||||
- `subscribeAll(...)` — returns `Flow`
|
||||
- Interactors take DAOs (or `Context` for PDF/export) directly as constructor params — **no repository layer**.
|
||||
- Interactors can contain business logic (think use-cases).
|
||||
|
||||
### Dependency injection (Koin)
|
||||
|
||||
- **DI modules live in `di/`**, not `core/`.
|
||||
- `CoreModule` provides `SharedPreferences` and `PreferenceStore` (as `AndroidPreferenceStore`). Nothing else.
|
||||
- Interactors are registered as `factory { }`, not `single`.
|
||||
- Preference classes are registered as `single { }` in `PreferenceModule`.
|
||||
- DAOs are registered as `single { get<AppDatabase>().xyzDao() }` in `DataModule`.
|
||||
- Bank statement importers use named qualifiers: `factory<BankStatementImporter>(named("bri")) { ... }`.
|
||||
- **ScreenModels are NOT registered in Koin.** Never add a ScreenModel to any Koin module.
|
||||
|
||||
### ScreenModel pattern
|
||||
|
||||
Always use `rememberScreenModel { }` in `Screen.Content()`. Inject dependencies via `inject()` as constructor default params:
|
||||
|
||||
```kotlin
|
||||
class HomeScreenModel(
|
||||
private val getExpenses: GetExpenses = inject(),
|
||||
private val appPreference: AppPreference = inject(),
|
||||
) : ScreenModel { ... }
|
||||
|
||||
// In Screen.Content():
|
||||
val screenModel = rememberScreenModel { HomeScreenModel() }
|
||||
```
|
||||
|
||||
`inject<T>()` is at `dev.achmad.ledgerr.di.util.KoinExtensions`.
|
||||
|
||||
### Navigation
|
||||
|
||||
- Pure Voyager stack — `Navigator`, `navigator.push(...)`, `navigator.pop()`.
|
||||
- **No `TabNavigator` at root level.**
|
||||
- `ExpenseListScreen` has 2 internal tabs using plain Compose `TabRow` + `HorizontalPager` (not Voyager tabs).
|
||||
- Screen flow:
|
||||
```
|
||||
HomeScreen (root)
|
||||
├── FAB expand → "Manual" → AddEditExpenseScreen
|
||||
│ → "Import Bank Statement" → ImportBankStatementScreen
|
||||
├── Export icon → date range dialog → SAF CreateDocument → writes CSV
|
||||
├── "See all" → ExpenseListScreen
|
||||
│ ├── Tab: Expenses (search + filter)
|
||||
│ │ └── FAB expand (tab-aware) → "Manual" → AddEditExpenseScreen
|
||||
│ │ → "Import Bank Statement" → ImportBankStatementScreen
|
||||
│ └── Tab: Recurring
|
||||
│ └── FAB expand (tab-aware) → "Add Recurring" → AddEditRecurringScreen
|
||||
├── "Manage Categories" button (dashboard body) → CategoryScreen
|
||||
└── Settings icon → SettingsScreen
|
||||
|
||||
AddEditExpenseScreen → CategoryScreen (manage categories button)
|
||||
|
||||
AddEditRecurringScreen (no secondary navigation)
|
||||
|
||||
ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
|
||||
├── ImportBankStatementPickerContent — state is BankPicker or Processing
|
||||
│ select bank → SAF OpenDocument → loading overlay while parsing
|
||||
└── ImportBankStatementConfirmationContent — state is Confirmation
|
||||
toggle/edit per row → InsertExpenses → navigator.pop()
|
||||
```
|
||||
- **Tab-aware FAB.** `ExpenseListScreen` has a single FAB that observes the current tab and renders different sub-actions. Expenses tab mirrors `HomeScreen`'s FAB (Manual + Import). Recurring tab shows only "Add Recurring" → `AddEditRecurringScreen`. The expansion/collapse UI is shared; only the actions list is tab-driven.
|
||||
- **Export** has no dedicated screen. Icon in `HomeScreen` and `ExpenseListScreen` top bars; also a `TextPreference` in `SettingsScreen`. Tapping shows a date range dialog first, then opens SAF `CreateDocument`. CSV uses ISO 8601 dates (`yyyy-MM-dd`) with UTF-8 BOM. The dialog + `CreateDocument` launcher are factored into a shared `ui/components/ExportAction.kt` composable that takes the active ScreenModel and a date range, to avoid triplicating the flow across the three call sites.
|
||||
- **Import** goes through `ImportBankStatementScreen` — one Voyager Screen, one ScreenModel, two internal composables switched by `StateFlow<ImportState>`. `ImportBankStatementPickerContent` handles bank selection and processing; `ImportBankStatementConfirmationContent` handles review, edit, and confirm. On confirm calls `InsertExpenses.awaitAll()` then pops.
|
||||
|
||||
### Settings screen
|
||||
|
||||
`SettingsScreen` uses the `PreferenceScreen` composable from `ui/components/preference/`. Pattern:
|
||||
|
||||
```kotlin
|
||||
PreferenceScreen(
|
||||
itemsProvider = {
|
||||
listOf(
|
||||
Preference.PreferenceItem.ListPreference(
|
||||
title = "Theme",
|
||||
preference = appPreference.appTheme(),
|
||||
entries = mapOf(...),
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = "Export CSV",
|
||||
onClick = { /* launch SAF CreateDocument */ }
|
||||
),
|
||||
Preference.PreferenceItem.AlertDialogPreference(
|
||||
title = "Clear all data",
|
||||
onConfirm = { screenModel.clearData() }
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Preference classes
|
||||
|
||||
Located in `domain/preference/`. Take `PreferenceStore` as constructor param. Methods return `Preference<T>` objects from `core/preference/`:
|
||||
|
||||
```kotlin
|
||||
class AppPreference(private val store: PreferenceStore) {
|
||||
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
|
||||
}
|
||||
```
|
||||
|
||||
### Data layer
|
||||
|
||||
- `LocalDate` stored as `Long` epoch day via `LocalDateConverter` (`LocalDate.toEpochDay()` / `LocalDate.ofEpochDay()`).
|
||||
- Use `@Upsert` Room annotation for insert-or-update operations.
|
||||
- Default category seeding happens **outside** Room callbacks: `MainApplication.onCreate` calls `inject<SeedDefaultCategories>().await()` on a `CoroutineScope(Dispatchers.IO)` after `startKoin` completes. Koin is not available inside `AppDatabase.Callback.onCreate`.
|
||||
- `ClearAllData` interactor calls `database.clearAllTables()` inside `withTransaction { }`. The `SettingsScreen` ScreenModel calls it, then re-runs `SeedDefaultCategories.await()` so the app retains the 8 default categories.
|
||||
- minSdk 26, so `java.time.*` is available without desugaring.
|
||||
|
||||
### Bank statement import
|
||||
|
||||
- `BankStatementImporter` interface: `val bankName: String` + `suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>`.
|
||||
- Stubs: `ImportBRIBankStatement`, `ImportJagoBankStatement`, `ImportBNIBankStatement` — all return `Result.failure(NotImplementedError(...))`.
|
||||
- PDF text extraction: `com.tom-roush:pdfbox-android`. Requires `PDFBoxResourceLoader.init(applicationContext)` in `MainApplication.onCreate()`.
|
||||
- No manifest permissions needed — SAF handles file access.
|
||||
|
||||
### Export
|
||||
|
||||
- `ExportExpensesToCsv` interactor writes UTF-8 BOM CSV via Okio to a SAF URI.
|
||||
- SAF launchers (`ActivityResultContracts.OpenDocument`, `ActivityResultContracts.CreateDocument`) are registered in the Screen composable; URIs are passed to the ScreenModel.
|
||||
- Shared `ui/components/ExportAction.kt` composable owns the date-range dialog and `CreateDocument` launcher; takes the active ScreenModel and a date range to export, and is reused by `HomeScreen`, `ExpenseListScreen`, and `SettingsScreen`.
|
||||
|
||||
### Charts
|
||||
|
||||
- **Vico** (`com.patrykandpatrick.vico:compose`, `compose-m3`, `core`) is the only charting library used.
|
||||
- `HomeScreen` dashboard renders a pie chart from `ExpenseSummary.byCategory` via Vico's `Chart` composable. No Canvas drawing for charts.
|
||||
- Apache 2.0 license, Compose-native, no AndroidView wrapper.
|
||||
|
||||
---
|
||||
|
||||
## Workflow for new features
|
||||
|
||||
Follow these steps in order every time. Do not skip ahead to implementation without explicit approval.
|
||||
|
||||
1. **Define structs** — add any new models to the relevant `domain/<feature>/model/` package.
|
||||
2. **Define interfaces** — add or update interactor class signatures and method stubs in `domain/<feature>/interactor/`. If a new feature has no existing feature folder, create one.
|
||||
3. **Add TODOs** — fill method bodies with `TODO("...")` comments describing the intended behavior, edge cases, and any invariants.
|
||||
4. **Self-review** — think through the full implementation. If anything requires going outside or beyond what was defined in steps 1–3 (new models, new methods, changed signatures), go back to the relevant step and update it before continuing.
|
||||
5. **Prompt for review** — stop and present a summary of what was defined. Wait for an explicit go-ahead before writing any real implementation code. See the **No-implementation rule** at the top of this file.
|
||||
6. **Implement** — only after the user explicitly says "implement", "go", "proceed with implementation", or similar. A general "go ahead" on the plan is NOT sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Key dependencies
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Jetpack Compose + Material3 | (from BOM) | UI |
|
||||
| Voyager | — | Navigation + ScreenModel |
|
||||
| Koin | 4.2.2 | DI |
|
||||
| Room | 2.7.1 | Local DB |
|
||||
| PDFBox-Android | 2.0.27.0 | PDF text extraction |
|
||||
| Vico (compose / compose-m3 / core) | 2.x | Charts (pie / bar) |
|
||||
| Okio | (transitive) | CSV write |
|
||||
|
||||
---
|
||||
|
||||
## Git
|
||||
|
||||
- **Never commit or push unless the user explicitly asks.** Do not auto-commit after completing a task, do not squash, amend, or rebase without being told to.
|
||||
@@ -359,21 +144,6 @@ Follow these steps in order every time. Do not skip ahead to implementation with
|
||||
|
||||
---
|
||||
|
||||
## Things to avoid
|
||||
|
||||
- Do not register ScreenModels in Koin.
|
||||
- Do not create a repository layer between interactors and DAOs.
|
||||
- Do not add a `TabNavigator` at the root.
|
||||
- Do not create a dedicated screen for export — it is a direct action (date range dialog → SAF picker). The flow is shared via `ui/components/ExportAction.kt`.
|
||||
- Do not add manifest permissions for file access — SAF handles it.
|
||||
- Do not draw charts with Canvas — use Vico.
|
||||
- Do not add a chart library other than Vico.
|
||||
- Do not rename `MainApplication` back to `LedgerrApp`.
|
||||
- `UiModule.kt` has been deleted — do not recreate it; the replacement is `PreferenceModule.kt`.
|
||||
- Do not use `@KoinViewModel` or `viewModel {}` — this project uses Voyager `ScreenModel`.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
Full design documentation is in `docs/`:
|
||||
@@ -381,3 +151,5 @@ Full design documentation is in `docs/`:
|
||||
- `02-interfaces.md` — all interactor signatures
|
||||
- `03-function-todos.md` — per-method behavior and edge cases
|
||||
- `04-implementation-plan.md` — package structure, DI wiring, build order
|
||||
|
||||
Both agents consult these. The reviewer uses them as the spec to judge a PR against; the implementor uses them as the spec to implement from.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"default_agent": "implementor"
|
||||
}
|
||||
Reference in New Issue
Block a user