Implement category feature and wire DI foundation (#1) #8
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
|||||||
|
)
|
||||||
|
@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?
|
||||||
|
|
||||||
|
admin
commented
No DB-level enforcement that only one row has A partial unique index would make this an invariant of the schema: This is 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 {
|
||||||
|
|
||||||
|
admin
commented
One of the 3 KSP warnings the PR body mentions. 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 " +
|
||||||
|
admin
commented
Same as above — missing 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"
|
||||||
|
)
|
||||||
|
admin
commented
Same 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 {
|
||||||
|
admin
commented
Third KSP warning. Missing 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")
|
||||||
|
admin
commented
Same 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()
|
||||||
|
admin
commented
This Options:
The simplest correct fix is 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.
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
exportSchema = falseis OK for v1, but you should flip it totrueand addroom.schemaLocationtoapp/build.gradle.ktsbefore any future migration. Without exported schemas, migrations can't be auto-tested and you lose the safety netandroidx.room:room-testingprovides. At minimum, open a follow-up issue so it doesn't get forgotten when #2 lands a v2 migration.