Implement category feature and wire DI foundation (#1) #8

Merged
admin merged 2 commits from feat/1-category-foundation into main 2026-06-28 09:17:22 +00:00
39 changed files with 790 additions and 10 deletions
+10 -4
View File
@@ -8,9 +8,7 @@ plugins {
android { android {
namespace = "dev.achmad.ledgerr" namespace = "dev.achmad.ledgerr"
compileSdk { compileSdk {
version = release(36) { version = release(37)
minorApiLevel = 1
}
} }
defaultConfig { defaultConfig {
@@ -84,4 +82,12 @@ dependencies {
api(libs.okio) api(libs.okio)
api(libs.serialization.json) api(libs.serialization.json)
api(libs.serialization.json.okio) api(libs.serialization.json.okio)
}
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.pdfbox.android)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m3)
implementation(libs.vico.core)
}
+1
View File
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<application <application
android:name=".ui.base.MainApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -0,0 +1,28 @@
package dev.achmad.ledgerr.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.achmad.ledgerr.data.local.converter.LocalDateConverter
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
import dev.achmad.ledgerr.data.local.dao.RecurringExpenseDao
import dev.achmad.ledgerr.data.local.entity.CategoryEntity
import dev.achmad.ledgerr.data.local.entity.ExpenseEntity
import dev.achmad.ledgerr.data.local.entity.RecurringExpenseEntity
@Database(
entities = [
CategoryEntity::class,
ExpenseEntity::class,
RecurringExpenseEntity::class,
],
version = 1,
exportSchema = false,
Review

exportSchema = false is OK for v1, but you should flip it to true and add room.schemaLocation to app/build.gradle.kts before any future migration. Without exported schemas, migrations can't be auto-tested and you lose the safety net androidx.room:room-testing provides. At minimum, open a follow-up issue so it doesn't get forgotten when #2 lands a v2 migration.

`exportSchema = false` is OK for v1, but you should flip it to `true` and add `room.schemaLocation` to `app/build.gradle.kts` before any future migration. Without exported schemas, migrations can't be auto-tested and you lose the safety net `androidx.room:room-testing` provides. At minimum, open a follow-up issue so it doesn't get forgotten when #2 lands a v2 migration.
)
@TypeConverters(LocalDateConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun categoryDao(): CategoryDao
abstract fun expenseDao(): ExpenseDao
abstract fun recurringExpenseDao(): RecurringExpenseDao
}
@@ -0,0 +1,13 @@
package dev.achmad.ledgerr.data.local.converter
import androidx.room.TypeConverter
import java.time.LocalDate
class LocalDateConverter {
@TypeConverter
fun toLocalDate(epochDay: Long): LocalDate = LocalDate.ofEpochDay(epochDay)
@TypeConverter
fun fromLocalDate(date: LocalDate): Long = date.toEpochDay()
}
@@ -0,0 +1,51 @@
package dev.achmad.ledgerr.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import dev.achmad.ledgerr.data.local.entity.CategoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CategoryDao {
@Query("SELECT * FROM categories ORDER BY name ASC")
fun subscribeAll(): Flow<List<CategoryEntity>>
@Query("SELECT * FROM categories ORDER BY name ASC")
suspend fun getAll(): List<CategoryEntity>
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun getById(id: Long): CategoryEntity?
Review

No DB-level enforcement that only one row has isDefault = 1. SeedDefaultCategories enforces it at app start, and UpsertCategory blocks promoting an existing non-default to default, but if anything ever violates the invariant (manual DB edit, future bug, two seeds racing on first launch) getDefault() will return an arbitrary one.

A partial unique index would make this an invariant of the schema:

CREATE UNIQUE INDEX idx_categories_only_one_default
    ON categories(isDefault) WHERE isDefault = 1

This is androidx.room.Index(value = ["isDefault"], unique = true) won't quite work because isDefault is a non-unique column with a 0 value for most rows. Use the @Index annotation plus a CREATE INDEX query in a Room migration, or just add a check via a @Query integrity check. Worth at least leaving a comment on getDefault() documenting the assumption.

No DB-level enforcement that only one row has `isDefault = 1`. `SeedDefaultCategories` enforces it at app start, and `UpsertCategory` blocks promoting an existing non-default to default, but if anything ever violates the invariant (manual DB edit, future bug, two seeds racing on first launch) `getDefault()` will return an arbitrary one. A partial unique index would make this an invariant of the schema: ```sql CREATE UNIQUE INDEX idx_categories_only_one_default ON categories(isDefault) WHERE isDefault = 1 ``` This is `androidx.room.Index(value = ["isDefault"], unique = true)` won't quite work because `isDefault` is a non-unique column with a `0` value for most rows. Use the `@Index` annotation plus a `CREATE INDEX` query in a Room migration, or just add a check via a `@Query` integrity check. Worth at least leaving a comment on `getDefault()` documenting the assumption.
/**
* Returns the single category marked as the system default. The "Uncategorized"
* category is seeded as the only default on first launch. There is no
* DB-level uniqueness constraint on `isDefault = 1` (a partial unique index
* would be a v2 migration); the invariant is maintained by `SeedDefaultCategories`
* and `UpsertCategory` at the interactor layer. If multiple defaults ever
* sneak in, this returns an arbitrary one.
*/
@Query("SELECT * FROM categories WHERE isDefault = 1 LIMIT 1")
suspend fun getDefault(): CategoryEntity?
@Query("SELECT COUNT(*) FROM categories")
suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(category: CategoryEntity): Long
@Update
suspend fun update(category: CategoryEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(categories: List<CategoryEntity>)
@Query("DELETE FROM categories WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM categories")
suspend fun deleteAll()
}
@@ -0,0 +1,74 @@
package dev.achmad.ledgerr.data.local.dao
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
import androidx.room.Update
import dev.achmad.ledgerr.data.local.entity.CategoryEntity
import dev.achmad.ledgerr.data.local.entity.ExpenseEntity
import kotlinx.coroutines.flow.Flow
data class ExpenseWithCategoryRow(
@Embedded val expense: ExpenseEntity,
@Relation(
parentColumn = "categoryId",
entityColumn = "id",
)
val category: CategoryEntity,
)
@Dao
interface ExpenseDao {
Review

One of the 3 KSP warnings the PR body mentions. @Relation queries need to run inside a transaction; for Flow returns Room will technically auto-wrap, but KSP still warns unless you annotate the DAO method with @Transaction. Add @Transaction to silence the warning and make the intent explicit.

One of the 3 KSP warnings the PR body mentions. `@Relation` queries need to run inside a transaction; for `Flow` returns Room will technically auto-wrap, but KSP still warns unless you annotate the DAO method with `@Transaction`. Add `@Transaction` to silence the warning and make the intent explicit.
@Transaction
@Query("SELECT * FROM expenses ORDER BY date DESC, createdAt DESC")
fun subscribeAll(): Flow<List<ExpenseWithCategoryRow>>
@Transaction
@Query(
"SELECT * FROM expenses " +
Review

Same as above — missing @Transaction on the second @Relation flow. Add it.

Same as above — missing `@Transaction` on the second `@Relation` flow. Add it.
"WHERE date BETWEEN :startDay AND :endDay " +
"ORDER BY date DESC, createdAt DESC"
)
fun subscribeByDateRange(startDay: Long, endDay: Long): Flow<List<ExpenseWithCategoryRow>>
@Query("SELECT * FROM expenses WHERE id = :id")
suspend fun getById(id: Long): ExpenseEntity?
@Query(
"SELECT * FROM expenses " +
"WHERE (:rangeStart IS NULL OR date >= :rangeStart) " +
"AND (:rangeEnd IS NULL OR date <= :rangeEnd) " +
"ORDER BY date DESC"
)
suspend fun search(rangeStart: Long?, rangeEnd: Long?): List<ExpenseEntity>
@Query(
"SELECT * FROM expenses " +
"WHERE date BETWEEN :startDay AND :endDay " +
"ORDER BY date DESC"
)
Review

Same @Upsert return-value issue as CategoryDao.upsert. ReassignExpenseCategory is fine because it doesn't read the return, but GetExpenses/UpsertExpense (in #2) will want the new row ID — flagging this now so the fix is in one place.

Same `@Upsert` return-value issue as `CategoryDao.upsert`. `ReassignExpenseCategory` is fine because it doesn't read the return, but `GetExpenses`/`UpsertExpense` (in #2) will want the new row ID — flagging this now so the fix is in one place.
suspend fun getByDateRange(startDay: Long, endDay: Long): List<ExpenseEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(expense: ExpenseEntity): Long
@Update
suspend fun update(expense: ExpenseEntity)
@Insert
suspend fun insertAll(expenses: List<ExpenseEntity>)
@Query("DELETE FROM expenses WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("UPDATE expenses SET categoryId = :toCategoryId WHERE categoryId = :fromCategoryId")
suspend fun reassignCategory(fromCategoryId: Long, toCategoryId: Long)
@Query("DELETE FROM expenses")
suspend fun deleteAll()
}
@@ -0,0 +1,48 @@
package dev.achmad.ledgerr.data.local.dao
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
import androidx.room.Update
import dev.achmad.ledgerr.data.local.entity.CategoryEntity
import dev.achmad.ledgerr.data.local.entity.RecurringExpenseEntity
import kotlinx.coroutines.flow.Flow
data class RecurringExpenseWithCategoryRow(
@Embedded val recurring: RecurringExpenseEntity,
@Relation(
parentColumn = "categoryId",
entityColumn = "id",
)
val category: CategoryEntity,
)
@Dao
interface RecurringExpenseDao {
Review

Third KSP warning. Missing @Transaction on this @Relation flow. Add it.

Third KSP warning. Missing `@Transaction` on this `@Relation` flow. Add it.
@Transaction
@Query("SELECT * FROM recurring_expenses ORDER BY nextDueDate ASC")
fun subscribeAll(): Flow<List<RecurringExpenseWithCategoryRow>>
@Query("SELECT * FROM recurring_expenses WHERE id = :id")
suspend fun getById(id: Long): RecurringExpenseEntity?
@Query("SELECT * FROM recurring_expenses WHERE isActive = 1 AND nextDueDate <= :today")
Review

Same @Upsert return-value issue as CategoryDao.upsert. Flagging for consistency before #3 lands.

Same `@Upsert` return-value issue as `CategoryDao.upsert`. Flagging for consistency before #3 lands.
suspend fun getDue(today: Long): List<RecurringExpenseEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(recurring: RecurringExpenseEntity): Long
@Update
suspend fun update(recurring: RecurringExpenseEntity)
@Query("DELETE FROM recurring_expenses WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM recurring_expenses")
suspend fun deleteAll()
}
@@ -0,0 +1,13 @@
package dev.achmad.ledgerr.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val color: Int,
val iconName: String?,
val isDefault: Boolean,
)
@@ -0,0 +1,29 @@
package dev.achmad.ledgerr.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(
tableName = "expenses",
foreignKeys = [
ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["id"],
childColumns = ["categoryId"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [Index("categoryId")],
)
data class ExpenseEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val amount: Double,
val categoryId: Long,
val date: LocalDate,
val note: String?,
val recurringExpenseId: Long?,
val createdAt: Long,
)
@@ -0,0 +1,30 @@
package dev.achmad.ledgerr.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.LocalDate
@Entity(
tableName = "recurring_expenses",
foreignKeys = [
ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["id"],
childColumns = ["categoryId"],
onDelete = ForeignKey.RESTRICT,
),
],
indices = [Index("categoryId")],
)
data class RecurringExpenseEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val amount: Double,
val categoryId: Long,
val note: String?,
val interval: String,
val startDate: LocalDate,
val nextDueDate: LocalDate,
val isActive: Boolean,
)
@@ -0,0 +1,20 @@
package dev.achmad.ledgerr.data.local.mapper
import dev.achmad.ledgerr.data.local.entity.CategoryEntity
import dev.achmad.ledgerr.domain.category.model.Category
fun CategoryEntity.toModel(): Category = Category(
id = id,
name = name,
color = color,
iconName = iconName,
isDefault = isDefault,
)
fun Category.toEntity(): CategoryEntity = CategoryEntity(
id = id,
name = name,
color = color,
iconName = iconName,
isDefault = isDefault,
)
@@ -0,0 +1,24 @@
package dev.achmad.ledgerr.data.local.mapper
import dev.achmad.ledgerr.data.local.entity.ExpenseEntity
import dev.achmad.ledgerr.domain.expense.model.Expense
fun ExpenseEntity.toModel(): Expense = Expense(
id = id,
amount = amount,
categoryId = categoryId,
date = date,
note = note,
recurringExpenseId = recurringExpenseId,
createdAt = createdAt,
)
fun Expense.toEntity(): ExpenseEntity = ExpenseEntity(
id = id,
amount = amount,
categoryId = categoryId,
date = date,
note = note,
recurringExpenseId = recurringExpenseId,
createdAt = createdAt,
)
@@ -0,0 +1,27 @@
package dev.achmad.ledgerr.data.local.mapper
import dev.achmad.ledgerr.data.local.entity.RecurringExpenseEntity
import dev.achmad.ledgerr.domain.recurring.model.RecurringExpense
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
fun RecurringExpenseEntity.toModel(): RecurringExpense = RecurringExpense(
id = id,
amount = amount,
categoryId = categoryId,
note = note,
interval = RecurringInterval.valueOf(interval),
startDate = startDate,
nextDueDate = nextDueDate,
isActive = isActive,
)
fun RecurringExpense.toEntity(): RecurringExpenseEntity = RecurringExpenseEntity(
id = id,
amount = amount,
categoryId = categoryId,
note = note,
interval = interval.name,
startDate = startDate,
nextDueDate = nextDueDate,
isActive = isActive,
)
@@ -1,7 +1,17 @@
package dev.achmad.ledgerr.di package dev.achmad.ledgerr.di
import android.content.Context
import dev.achmad.ledgerr.core.preference.AndroidPreferenceStore
import dev.achmad.ledgerr.core.preference.PreferenceStore
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val coreModule = module { val coreModule = module {
single {
} androidContext().getSharedPreferences(
"ledgerr_prefs",
Context.MODE_PRIVATE,
)
}
single<PreferenceStore> { AndroidPreferenceStore(get()) }
}
@@ -1,7 +1,19 @@
package dev.achmad.ledgerr.di package dev.achmad.ledgerr.di
import androidx.room.Room
import dev.achmad.ledgerr.data.local.AppDatabase
import org.koin.android.ext.koin.androidApplication
import org.koin.dsl.module import org.koin.dsl.module
val dataModule = module { 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() }
}
@@ -1,7 +1,17 @@
package dev.achmad.ledgerr.di package dev.achmad.ledgerr.di
import dev.achmad.ledgerr.domain.category.interactor.DeleteCategory
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
import dev.achmad.ledgerr.domain.expense.interactor.ReassignExpenseCategory
import org.koin.dsl.module import org.koin.dsl.module
val domainModule = module { val domainModule = module {
factory { GetCategories(get()) }
factory { UpsertCategory(get()) }
factory { DeleteCategory(get(), get(), get()) }
factory { SeedDefaultCategories(get()) }
} factory { ReassignExpenseCategory(get()) }
}
@@ -0,0 +1,10 @@
package dev.achmad.ledgerr.di
import dev.achmad.ledgerr.domain.preference.AppPreference
import dev.achmad.ledgerr.domain.preference.ExpensePreference
import org.koin.dsl.module
val preferenceModule = module {
single { AppPreference(get()) }
single { ExpensePreference(get()) }
}
@@ -0,0 +1,9 @@
package dev.achmad.ledgerr.domain.bankstatement.model
data class BNIStatementEntry(
val date: String,
val description: String,
val debit: String?,
val credit: String?,
val balance: String?,
)
@@ -0,0 +1,9 @@
package dev.achmad.ledgerr.domain.bankstatement.model
data class BRIStatementEntry(
val date: String,
val description: String,
val debit: String?,
val credit: String?,
val balance: String?,
)
@@ -0,0 +1,8 @@
package dev.achmad.ledgerr.domain.bankstatement.model
data class JagoStatementEntry(
val date: String,
val description: String,
val amount: String,
val type: String,
)
@@ -0,0 +1,11 @@
package dev.achmad.ledgerr.domain.bankstatement.model
import java.time.LocalDate
data class PendingImportExpense(
val amount: Double,
val date: LocalDate,
val description: String,
val suggestedCategoryId: Long? = null,
val isSelected: Boolean = true,
)
@@ -0,0 +1,24 @@
package dev.achmad.ledgerr.domain.category.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.mapper.toModel
import dev.achmad.ledgerr.domain.expense.interactor.ReassignExpenseCategory
class DeleteCategory(
private val dao: CategoryDao,
private val reassignExpenseCategory: ReassignExpenseCategory,
private val getCategories: GetCategories,
) {
suspend fun await(id: Long) {
val category = dao.getById(id)?.toModel() ?: return
if (category.isDefault) {
throw IllegalArgumentException("Cannot delete a default category")
}
val fallbackId = getCategories.awaitDefault().id
reassignExpenseCategory.await(
fromCategoryId = id,
toCategoryId = fallbackId,
)
dao.deleteById(id)
}
}
@@ -0,0 +1,24 @@
package dev.achmad.ledgerr.domain.category.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.mapper.toModel
import dev.achmad.ledgerr.domain.category.model.Category
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetCategories(
private val dao: CategoryDao,
) {
fun subscribeAll(): Flow<List<Category>> =
dao.subscribeAll().map { rows -> rows.map { it.toModel() } }
suspend fun awaitOne(id: Long): Category? =
dao.getById(id)?.toModel()
suspend fun awaitAll(): List<Category> =
dao.getAll().map { it.toModel() }
suspend fun awaitDefault(): Category =
dao.getDefault()?.toModel()
?: error("Default category not found — SeedDefaultCategories must run on app start")
}
@@ -0,0 +1,56 @@
package dev.achmad.ledgerr.domain.category.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.mapper.toEntity
import dev.achmad.ledgerr.domain.category.model.Category
class SeedDefaultCategories(
private val dao: CategoryDao,
) {
suspend fun await() {
if (dao.count() > 0) return
val defaults = listOf(
Category(
name = "Food & Drink",
color = Category.DEFAULT_COLOR_FOOD,
isDefault = false,
),
Category(
name = "Transport",
color = Category.DEFAULT_COLOR_TRANSPORT,
isDefault = false,
),
Category(
name = "Housing",
color = Category.DEFAULT_COLOR_HOUSING,
isDefault = false,
),
Category(
name = "Health",
color = Category.DEFAULT_COLOR_HEALTH,
isDefault = false,
),
Category(
name = "Entertainment",
color = Category.DEFAULT_COLOR_ENTERTAINMENT,
isDefault = false,
),
Category(
name = "Shopping",
color = Category.DEFAULT_COLOR_SHOPPING,
isDefault = false,
),
Category(
name = "Education",
color = Category.DEFAULT_COLOR_EDUCATION,
isDefault = false,
),
Category(
name = "Uncategorized",
color = Category.DEFAULT_COLOR_OTHER,
isDefault = true,
),
)
dao.upsertAll(defaults.map { it.toEntity() })
}
}
@@ -0,0 +1,23 @@
package dev.achmad.ledgerr.domain.category.interactor
import dev.achmad.ledgerr.data.local.dao.CategoryDao
import dev.achmad.ledgerr.data.local.mapper.toEntity
import dev.achmad.ledgerr.domain.category.model.Category
class UpsertCategory(
private val dao: CategoryDao,
) {
suspend fun await(category: Category): Long {
if (category.id == 0L) {
return dao.insert(category.toEntity())
}
val existing = dao.getById(category.id)
val resolved = if (existing != null && category.isDefault && !existing.isDefault) {
category.copy(isDefault = false)
} else {
category
}
dao.update(resolved.toEntity())
return category.id
}
}
@@ -0,0 +1,20 @@
package dev.achmad.ledgerr.domain.category.model
data class Category(
val id: Long = 0,
val name: String,
val color: Int,
val iconName: String? = null,
val isDefault: Boolean = false,
) {
companion object {
const val DEFAULT_COLOR_FOOD = 0xFFFF9800.toInt()
const val DEFAULT_COLOR_TRANSPORT = 0xFF2196F3.toInt()
const val DEFAULT_COLOR_HOUSING = 0xFF795548.toInt()
const val DEFAULT_COLOR_HEALTH = 0xFFF44336.toInt()
const val DEFAULT_COLOR_ENTERTAINMENT = 0xFF9C27B0.toInt()
const val DEFAULT_COLOR_SHOPPING = 0xFFE91E63.toInt()
const val DEFAULT_COLOR_EDUCATION = 0xFF4CAF50.toInt()
const val DEFAULT_COLOR_OTHER = 0xFF9E9E9E.toInt()
}
}
@@ -0,0 +1,14 @@
package dev.achmad.ledgerr.domain.expense.interactor
import dev.achmad.ledgerr.data.local.dao.ExpenseDao
class ReassignExpenseCategory(
private val dao: ExpenseDao,
) {
suspend fun await(fromCategoryId: Long, toCategoryId: Long) {
dao.reassignCategory(
fromCategoryId = fromCategoryId,
toCategoryId = toCategoryId,
)
}
}
@@ -0,0 +1,27 @@
package dev.achmad.ledgerr.domain.expense.model
import java.time.DayOfWeek
import java.time.LocalDate
data class DateRange(
val start: LocalDate,
val end: LocalDate,
) {
companion object {
fun thisMonth(): DateRange {
val now = LocalDate.now()
return DateRange(
start = now.withDayOfMonth(1),
end = now.withDayOfMonth(now.lengthOfMonth()),
)
}
fun thisWeek(): DateRange {
val now = LocalDate.now()
return DateRange(
start = now.with(DayOfWeek.MONDAY),
end = now.with(DayOfWeek.SUNDAY),
)
}
}
}
@@ -0,0 +1,13 @@
package dev.achmad.ledgerr.domain.expense.model
import java.time.LocalDate
data class Expense(
val id: Long = 0,
val amount: Double,
val categoryId: Long,
val date: LocalDate,
val note: String? = null,
val recurringExpenseId: Long? = null,
val createdAt: Long = System.currentTimeMillis(),
)
@@ -0,0 +1,9 @@
package dev.achmad.ledgerr.domain.expense.model
import dev.achmad.ledgerr.domain.category.model.Category
data class ExpenseSummary(
val totalAmount: Double,
val byCategory: List<Pair<Category, Double>>,
val period: DateRange,
)
@@ -0,0 +1,8 @@
package dev.achmad.ledgerr.domain.expense.model
import dev.achmad.ledgerr.domain.category.model.Category
data class ExpenseWithCategory(
val expense: Expense,
val category: Category,
)
@@ -0,0 +1,16 @@
package dev.achmad.ledgerr.domain.preference
import dev.achmad.ledgerr.core.preference.PreferenceStore
import dev.achmad.ledgerr.core.preference.getEnum
enum class AppTheme {
LIGHT,
DARK,
SYSTEM,
}
class AppPreference(
private val store: PreferenceStore,
) {
fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
}
@@ -0,0 +1,18 @@
package dev.achmad.ledgerr.domain.preference
import dev.achmad.ledgerr.core.preference.PreferenceStore
import dev.achmad.ledgerr.core.preference.getEnum
enum class DateRangeOption {
THIS_WEEK,
THIS_MONTH,
}
class ExpensePreference(
private val store: PreferenceStore,
) {
fun defaultDateRange() = store.getEnum(
key = "expense_default_date_range",
defaultValue = DateRangeOption.THIS_MONTH,
)
}
@@ -0,0 +1,14 @@
package dev.achmad.ledgerr.domain.recurring.model
import java.time.LocalDate
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,
val isActive: Boolean = true,
)
@@ -0,0 +1,8 @@
package dev.achmad.ledgerr.domain.recurring.model
import dev.achmad.ledgerr.domain.category.model.Category
data class RecurringExpenseWithCategory(
val recurring: RecurringExpense,
val category: Category,
)
@@ -0,0 +1,17 @@
package dev.achmad.ledgerr.domain.recurring.model
import java.time.LocalDate
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)
}
}
@@ -0,0 +1,29 @@
package dev.achmad.ledgerr.ui.base
import android.app.Application
import dev.achmad.ledgerr.di.coreModule
import dev.achmad.ledgerr.di.dataModule
import dev.achmad.ledgerr.di.domainModule
import dev.achmad.ledgerr.di.preferenceModule
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@MainApplication)
modules(coreModule, dataModule, domainModule, preferenceModule)
}
runBlocking(Dispatchers.IO) {
inject<SeedDefaultCategories>().await()
Review

This CoroutineScope(Dispatchers.IO) is fire-and-forget with no lifecycle binding. The seed itself is fast (8 row inserts), but it is asynchronous relative to the first frame, so a fast tap on the home screen can land on HomeScreen calling GetCategories.awaitDefault() (directly or via the dashboard) before the seed completes. GetCategories.awaitDefault() throws error("Default category not found — ...") in that case, crashing the app.

Options:

  • Use runBlocking(Dispatchers.IO) { inject<SeedDefaultCategories>().await() } — fine for a first-launch one-shot, and the 8 inserts are sub-millisecond.
  • Or expose a categoriesReady: StateFlow<Boolean> from a startup interactor and have screens observe it.
  • Also: store the Job somewhere and cancel it in onTerminate() for testability (cosmetic).

The simplest correct fix is runBlocking here.

This `CoroutineScope(Dispatchers.IO)` is fire-and-forget with no lifecycle binding. The seed itself is fast (8 row inserts), but it is asynchronous relative to the first frame, so a fast tap on the home screen can land on `HomeScreen` calling `GetCategories.awaitDefault()` (directly or via the dashboard) before the seed completes. `GetCategories.awaitDefault()` throws `error("Default category not found — ...")` in that case, crashing the app. Options: - Use `runBlocking(Dispatchers.IO) { inject<SeedDefaultCategories>().await() }` — fine for a first-launch one-shot, and the 8 inserts are sub-millisecond. - Or expose a `categoriesReady: StateFlow<Boolean>` from a startup interactor and have screens observe it. - Also: store the `Job` somewhere and cancel it in `onTerminate()` for testability (cosmetic). The simplest correct fix is `runBlocking` here.
}
}
}
+7 -1
View File
@@ -1,3 +1,9 @@
<resources> <resources>
<string name="app_name">Ledgerr</string> <string name="app_name">Ledgerr</string>
</resources> <string name="confirm">Confirm</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<string name="general_not_selected">Not selected</string>
<string name="general_selected">Selected</string>
<string name="disabled">Disabled</string>
</resources>
+11
View File
@@ -19,6 +19,9 @@ serialization_version = "1.11.0"
voyager = "2.2.21-1.10.3" voyager = "2.2.21-1.10.3"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.14.0" material = "1.14.0"
room = "2.7.1"
pdfboxAndroid = "2.0.27.0"
vico = "2.0.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -58,6 +61,14 @@ voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", vers
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
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 = "pdfboxAndroid" }
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
vico-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }