Implement category feature and wire DI foundation #1

Closed
opened 2026-06-28 08:43:42 +00:00 by admin · 0 comments
Owner

Overview

Foundation slice. Implements the category feature end-to-end and wires up DI + MainApplication. Everything else in the app depends on this.

Working tree state (already done — do NOT redo)

The planning step for this slice is already complete in the working tree (uncommitted):

  • Phase 0: Vico, Room, PDFBox-Android added to gradle/libs.versions.toml and app/build.gradle.kts. example_feature, data/repository, data/remote removed.
  • Phase 1: All domain models created for expense, category, recurring, bankstatement, and preference (incl. AppPreference, ExpensePreference).
  • Phase 2: Data layer — LocalDateConverter, 3 entities, 3 DAOs (with @Relation rows), 3 mappers, AppDatabase (v1).
  • Phase 3: 4 category interactor files exist with TODO(...) bodies.

The default fallback category is named "Uncategorized" (not "Other") — this was confirmed after the planning step.

What to implement

1. Implement 4 category interactors (replace TODOs)

GetCategories.kt

  • subscribeAll()dao.subscribeAll().map { rows -> rows.map { it.toModel() } }
  • awaitOne(id)dao.getById(id)?.toModel()
  • awaitAll()dao.getAll().map { it.toModel() }
  • awaitDefault()dao.getDefault()?.toModel() ?: error("Default category not found — SeedDefaultCategories must run on app start")

UpsertCategory.kt

  • await(category):
    • If id == 0L: dao.upsert(category.toEntity()) and return the returned Long.
    • If id != 0L: look up the existing row. If the existing row has isDefault = false and the incoming has isDefault = true, force the incoming to use the existing isDefault = false before upserting (DB wins for the isDefault flag). Otherwise upsert normally. Return the id.

DeleteCategory.kt

  • await(id):
    1. Look up category by id via dao.getById(id)?.toModel(). If null, return (nothing to delete).
    2. If category.isDefault == true, throw IllegalArgumentException("Cannot delete a default category").
    3. Get fallback: getCategories.awaitDefault().id.
    4. reassignExpenseCategory.await(fromCategoryId = id, toCategoryId = fallbackId) — note ReassignExpenseCategory is defined in domain/expense/interactor; if you haven't implemented the expense interactor yet, define a minimal ReassignExpenseCategory(expenseDao) here that just runs the SQL UPDATE, OR import the full one (preferred). Either works as long as the interactor exists by the time DeleteCategory is wired in DI.
    5. dao.deleteById(id).

SeedDefaultCategories.kt

  • await():
    • If dao.count() > 0, return (no-op).
    • Otherwise, build the 8 defaults (Food & Drink, Transport, Housing, Health, Entertainment, Shopping, Education, Uncategorized) with the colors from Category.Companion.DEFAULT_COLOR_* constants and isDefault = true only for Uncategorized. All iconName = null.
    • dao.upsertAll(defaults.map { it.toEntity() }).

2. Wire DI

di/DataModule.kt

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() }
}

di/CoreModule.kt

val coreModule = module {
    single {
        get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE)
    }
    single<PreferenceStore> { AndroidPreferenceStore(get()) }
}

di/PreferenceModule.kt (create new)

val preferenceModule = module {
    single { AppPreference(get()) }
    single { ExpensePreference(get()) }
}

di/DomainModule.kt (category factories only — leave rest empty for now)

val domainModule = module {
    factory { GetCategories(get()) }
    factory { UpsertCategory(get()) }
    factory { DeleteCategory(get(), get(), get()) }
    factory { SeedDefaultCategories(get()) }
}

3. Create MainApplication and register in manifest

ui/base/MainApplication.kt

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@MainApplication)
            modules(coreModule, dataModule, domainModule, preferenceModule)
        }
        CoroutineScope(Dispatchers.IO).launch {
            inject<SeedDefaultCategories>().await()
        }
    }
}

Note: PDFBoxResourceLoader.init(this) should be added once the bank-statement slice (#4) is implemented. For this slice, do not call it.

AndroidManifest.xml — add android:name=".ui.base.MainApplication" to the <application> tag.

4. Verify compilation

Run ./gradlew assembleDebug. Must succeed before this issue is done.

Out of scope (belongs to other issues)

  • Expense interactors (#2)
  • Recurring interactors (#3)
  • Bank statement / export / ClearAllData (#4)
  • Any UI screen work (#5, #6, #7)

Acceptance

  • 4 category interactor TODO bodies are replaced with real code
  • DataModule, CoreModule, PreferenceModule, DomainModule wired
  • MainApplication exists and seeds default categories on first launch
  • Manifest references MainApplication
  • ./gradlew assembleDebug succeeds

Implementation rule

Per AGENTS.md — do not start implementation without explicit user sign-off on this issue. When working, check for related issues in the remote repo first.

## Overview Foundation slice. Implements the `category` feature end-to-end and wires up DI + `MainApplication`. Everything else in the app depends on this. ## Working tree state (already done — do NOT redo) The planning step for this slice is already complete in the working tree (uncommitted): - Phase 0: `Vico`, `Room`, `PDFBox-Android` added to `gradle/libs.versions.toml` and `app/build.gradle.kts`. `example_feature`, `data/repository`, `data/remote` removed. - Phase 1: All domain models created for `expense`, `category`, `recurring`, `bankstatement`, and `preference` (incl. `AppPreference`, `ExpensePreference`). - Phase 2: Data layer — `LocalDateConverter`, 3 entities, 3 DAOs (with `@Relation` rows), 3 mappers, `AppDatabase` (v1). - Phase 3: 4 category interactor files exist with `TODO(...)` bodies. The default fallback category is named **"Uncategorized"** (not "Other") — this was confirmed after the planning step. ## What to implement ### 1. Implement 4 category interactors (replace TODOs) **`GetCategories.kt`** - `subscribeAll()` → `dao.subscribeAll().map { rows -> rows.map { it.toModel() } }` - `awaitOne(id)` → `dao.getById(id)?.toModel()` - `awaitAll()` → `dao.getAll().map { it.toModel() }` - `awaitDefault()` → `dao.getDefault()?.toModel() ?: error("Default category not found — SeedDefaultCategories must run on app start")` **`UpsertCategory.kt`** - `await(category)`: - If `id == 0L`: `dao.upsert(category.toEntity())` and return the returned `Long`. - If `id != 0L`: look up the existing row. If the existing row has `isDefault = false` and the incoming has `isDefault = true`, force the incoming to use the existing `isDefault = false` before upserting (DB wins for the `isDefault` flag). Otherwise upsert normally. Return the id. **`DeleteCategory.kt`** - `await(id)`: 1. Look up category by `id` via `dao.getById(id)?.toModel()`. If null, return (nothing to delete). 2. If `category.isDefault == true`, throw `IllegalArgumentException("Cannot delete a default category")`. 3. Get fallback: `getCategories.awaitDefault().id`. 4. `reassignExpenseCategory.await(fromCategoryId = id, toCategoryId = fallbackId)` — note `ReassignExpenseCategory` is defined in `domain/expense/interactor`; if you haven't implemented the expense interactor yet, define a minimal `ReassignExpenseCategory(expenseDao)` here that just runs the SQL `UPDATE`, OR import the full one (preferred). Either works as long as the interactor exists by the time `DeleteCategory` is wired in DI. 5. `dao.deleteById(id)`. **`SeedDefaultCategories.kt`** - `await()`: - If `dao.count() > 0`, return (no-op). - Otherwise, build the 8 defaults (Food & Drink, Transport, Housing, Health, Entertainment, Shopping, Education, **Uncategorized**) with the colors from `Category.Companion.DEFAULT_COLOR_*` constants and `isDefault = true` only for Uncategorized. All `iconName = null`. - `dao.upsertAll(defaults.map { it.toEntity() })`. ### 2. Wire DI **`di/DataModule.kt`** ```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() } } ``` **`di/CoreModule.kt`** ```kotlin val coreModule = module { single { get<Context>().getSharedPreferences("ledgerr_prefs", Context.MODE_PRIVATE) } single<PreferenceStore> { AndroidPreferenceStore(get()) } } ``` **`di/PreferenceModule.kt`** (create new) ```kotlin val preferenceModule = module { single { AppPreference(get()) } single { ExpensePreference(get()) } } ``` **`di/DomainModule.kt`** (category factories only — leave rest empty for now) ```kotlin val domainModule = module { factory { GetCategories(get()) } factory { UpsertCategory(get()) } factory { DeleteCategory(get(), get(), get()) } factory { SeedDefaultCategories(get()) } } ``` ### 3. Create `MainApplication` and register in manifest **`ui/base/MainApplication.kt`** ```kotlin class MainApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidLogger() androidContext(this@MainApplication) modules(coreModule, dataModule, domainModule, preferenceModule) } CoroutineScope(Dispatchers.IO).launch { inject<SeedDefaultCategories>().await() } } } ``` Note: `PDFBoxResourceLoader.init(this)` should be added once the bank-statement slice (#4) is implemented. For this slice, do not call it. **`AndroidManifest.xml`** — add `android:name=".ui.base.MainApplication"` to the `<application>` tag. ### 4. Verify compilation Run `./gradlew assembleDebug`. Must succeed before this issue is done. ## Out of scope (belongs to other issues) - Expense interactors (#2) - Recurring interactors (#3) - Bank statement / export / ClearAllData (#4) - Any UI screen work (#5, #6, #7) ## Acceptance - [ ] 4 category interactor TODO bodies are replaced with real code - [ ] `DataModule`, `CoreModule`, `PreferenceModule`, `DomainModule` wired - [ ] `MainApplication` exists and seeds default categories on first launch - [ ] Manifest references `MainApplication` - [ ] `./gradlew assembleDebug` succeeds ## Implementation rule Per `AGENTS.md` — do not start implementation without explicit user sign-off on this issue. When working, check for related issues in the remote repo first.
admin closed this issue 2026-06-28 09:17:22 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/ledgerr#1