From 8e7a6cfe3fb49d473223222922c74bba4eda7824 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 15:53:58 +0700 Subject: [PATCH 1/2] feat(#1): implement category feature and wire DI foundation - Add Room 2.7.1, PDFBox-Android 2.0.27.0, Vico 2.0.0 dependencies - Bump compileSdk 36 -> 37 (transitive deps require it) - Add LocalDateConverter + 3 entities + 3 DAOs with @Relation rows + mappers + AppDatabase v1 - Add all domain models (expense, category, recurring, bankstatement, preference) - Implement 4 category interactors: GetCategories, UpsertCategory, DeleteCategory, SeedDefaultCategories - Add minimal ReassignExpenseCategory (full expense interactors come in #2) - Wire CoreModule (SharedPreferences + AndroidPreferenceStore), DataModule (Room + 3 DAOs), PreferenceModule, DomainModule (category factories) - Create MainApplication with Koin start and SeedDefaultCategories trigger - Register MainApplication in AndroidManifest - Add missing string resources for pre-existing copied UI components (confirm, cancel, ok, general_selected, general_not_selected, disabled) --- app/build.gradle.kts | 12 +++- app/src/main/AndroidManifest.xml | 1 + .../achmad/ledgerr/data/local/AppDatabase.kt | 28 ++++++++ .../local/converter/LocalDateConverter.kt | 13 ++++ .../ledgerr/data/local/dao/CategoryDao.kt | 38 +++++++++++ .../ledgerr/data/local/dao/ExpenseDao.kt | 67 +++++++++++++++++++ .../data/local/dao/RecurringExpenseDao.kt | 41 ++++++++++++ .../data/local/entity/CategoryEntity.kt | 13 ++++ .../data/local/entity/ExpenseEntity.kt | 29 ++++++++ .../local/entity/RecurringExpenseEntity.kt | 30 +++++++++ .../data/local/mapper/CategoryMapper.kt | 20 ++++++ .../data/local/mapper/ExpenseMapper.kt | 24 +++++++ .../local/mapper/RecurringExpenseMapper.kt | 27 ++++++++ .../java/dev/achmad/ledgerr/di/CoreModule.kt | 14 +++- .../java/dev/achmad/ledgerr/di/DataModule.kt | 16 ++++- .../dev/achmad/ledgerr/di/DomainModule.kt | 12 +++- .../dev/achmad/ledgerr/di/PreferenceModule.kt | 10 +++ .../bankstatement/model/BNIStatementEntry.kt | 9 +++ .../bankstatement/model/BRIStatementEntry.kt | 9 +++ .../bankstatement/model/JagoStatementEntry.kt | 8 +++ .../model/PendingImportExpense.kt | 11 +++ .../category/interactor/DeleteCategory.kt | 24 +++++++ .../category/interactor/GetCategories.kt | 24 +++++++ .../interactor/SeedDefaultCategories.kt | 56 ++++++++++++++++ .../category/interactor/UpsertCategory.kt | 22 ++++++ .../ledgerr/domain/category/model/Category.kt | 20 ++++++ .../interactor/ReassignExpenseCategory.kt | 14 ++++ .../ledgerr/domain/expense/model/DateRange.kt | 27 ++++++++ .../ledgerr/domain/expense/model/Expense.kt | 13 ++++ .../domain/expense/model/ExpenseSummary.kt | 9 +++ .../expense/model/ExpenseWithCategory.kt | 8 +++ .../domain/preference/AppPreference.kt | 16 +++++ .../domain/preference/ExpensePreference.kt | 18 +++++ .../recurring/model/RecurringExpense.kt | 14 ++++ .../model/RecurringExpenseWithCategory.kt | 8 +++ .../recurring/model/RecurringInterval.kt | 17 +++++ .../achmad/ledgerr/ui/base/MainApplication.kt | 30 +++++++++ app/src/main/res/values/strings.xml | 8 ++- gradle/libs.versions.toml | 11 +++ 39 files changed, 762 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/AppDatabase.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/converter/LocalDateConverter.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/dao/CategoryDao.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/entity/CategoryEntity.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/entity/ExpenseEntity.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/entity/RecurringExpenseEntity.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/mapper/CategoryMapper.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/data/local/mapper/RecurringExpenseMapper.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/di/PreferenceModule.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BNIStatementEntry.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BRIStatementEntry.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/JagoStatementEntry.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/PendingImportExpense.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/DeleteCategory.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/GetCategories.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/SeedDefaultCategories.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/category/model/Category.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/ReassignExpenseCategory.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/model/DateRange.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/model/Expense.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseSummary.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseWithCategory.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/preference/AppPreference.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/preference/ExpensePreference.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpense.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpenseWithCategory.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringInterval.kt create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6823e2..c7dc2ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,9 +8,7 @@ plugins { android { namespace = "dev.achmad.ledgerr" compileSdk { - version = release(36) { - minorApiLevel = 1 - } + version = release(37) } defaultConfig { @@ -84,4 +82,12 @@ dependencies { api(libs.okio) api(libs.serialization.json) 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) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6982f83..10c79ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> > + + @Query("SELECT * FROM categories ORDER BY name ASC") + suspend fun getAll(): List + + @Query("SELECT * FROM categories WHERE id = :id") + suspend fun getById(id: Long): CategoryEntity? + + @Query("SELECT * FROM categories WHERE isDefault = 1 LIMIT 1") + suspend fun getDefault(): CategoryEntity? + + @Query("SELECT COUNT(*) FROM categories") + suspend fun count(): Int + + @Upsert + suspend fun upsert(category: CategoryEntity): Long + + @Upsert + suspend fun upsertAll(categories: List) + + @Query("DELETE FROM categories WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM categories") + suspend fun deleteAll() +} diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt new file mode 100644 index 0000000..fe2d863 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt @@ -0,0 +1,67 @@ +package dev.achmad.ledgerr.data.local.dao + +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Upsert +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 { + + @Query("SELECT * FROM expenses ORDER BY date DESC, createdAt DESC") + fun subscribeAll(): Flow> + + @Query( + "SELECT * FROM expenses " + + "WHERE date BETWEEN :startDay AND :endDay " + + "ORDER BY date DESC, createdAt DESC" + ) + fun subscribeByDateRange(startDay: Long, endDay: Long): Flow> + + @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 + + @Query( + "SELECT * FROM expenses " + + "WHERE date BETWEEN :startDay AND :endDay " + + "ORDER BY date DESC" + ) + suspend fun getByDateRange(startDay: Long, endDay: Long): List + + @Upsert + suspend fun upsert(expense: ExpenseEntity): Long + + @Insert + suspend fun insertAll(expenses: List) + + @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() +} diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt new file mode 100644 index 0000000..d82fb0a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt @@ -0,0 +1,41 @@ +package dev.achmad.ledgerr.data.local.dao + +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Upsert +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 { + + @Query("SELECT * FROM recurring_expenses ORDER BY nextDueDate ASC") + fun subscribeAll(): Flow> + + @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") + suspend fun getDue(today: Long): List + + @Upsert + suspend fun upsert(recurring: RecurringExpenseEntity): Long + + @Query("DELETE FROM recurring_expenses WHERE id = :id") + suspend fun deleteById(id: Long) + + @Query("DELETE FROM recurring_expenses") + suspend fun deleteAll() +} diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/entity/CategoryEntity.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/CategoryEntity.kt new file mode 100644 index 0000000..81f0feb --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/CategoryEntity.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/entity/ExpenseEntity.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/ExpenseEntity.kt new file mode 100644 index 0000000..28d2359 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/ExpenseEntity.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/entity/RecurringExpenseEntity.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/RecurringExpenseEntity.kt new file mode 100644 index 0000000..c23831f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/entity/RecurringExpenseEntity.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/CategoryMapper.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/CategoryMapper.kt new file mode 100644 index 0000000..2f12ff7 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/CategoryMapper.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt new file mode 100644 index 0000000..59cae18 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/ExpenseMapper.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/RecurringExpenseMapper.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/RecurringExpenseMapper.kt new file mode 100644 index 0000000..8307307 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/mapper/RecurringExpenseMapper.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt index 3ecb8b2..90b5107 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/CoreModule.kt @@ -1,7 +1,17 @@ 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 val coreModule = module { - -} \ No newline at end of file + single { + androidContext().getSharedPreferences( + "ledgerr_prefs", + Context.MODE_PRIVATE, + ) + } + single { AndroidPreferenceStore(get()) } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt index 83bbdd0..7f7db7b 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/DataModule.kt @@ -1,7 +1,19 @@ 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 val dataModule = module { - -} \ No newline at end of file + single { + Room.databaseBuilder( + androidApplication(), + AppDatabase::class.java, + "ledgerr.db", + ).build() + } + single { get().categoryDao() } + single { get().expenseDao() } + single { get().recurringExpenseDao() } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt index 669aa7a..56481b8 100644 --- a/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt +++ b/app/src/main/java/dev/achmad/ledgerr/di/DomainModule.kt @@ -1,7 +1,17 @@ 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 val domainModule = module { + factory { GetCategories(get()) } + factory { UpsertCategory(get()) } + factory { DeleteCategory(get(), get(), get()) } + factory { SeedDefaultCategories(get()) } -} \ No newline at end of file + factory { ReassignExpenseCategory(get()) } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/di/PreferenceModule.kt b/app/src/main/java/dev/achmad/ledgerr/di/PreferenceModule.kt new file mode 100644 index 0000000..3362926 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/di/PreferenceModule.kt @@ -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()) } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BNIStatementEntry.kt b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BNIStatementEntry.kt new file mode 100644 index 0000000..fb0f777 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BNIStatementEntry.kt @@ -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?, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BRIStatementEntry.kt b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BRIStatementEntry.kt new file mode 100644 index 0000000..1de373d --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/BRIStatementEntry.kt @@ -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?, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/JagoStatementEntry.kt b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/JagoStatementEntry.kt new file mode 100644 index 0000000..e4d2f17 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/JagoStatementEntry.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/PendingImportExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/PendingImportExpense.kt new file mode 100644 index 0000000..0ef6bc8 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/bankstatement/model/PendingImportExpense.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/DeleteCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/DeleteCategory.kt new file mode 100644 index 0000000..1867aa2 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/DeleteCategory.kt @@ -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) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/GetCategories.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/GetCategories.kt new file mode 100644 index 0000000..89e35d0 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/GetCategories.kt @@ -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> = + dao.subscribeAll().map { rows -> rows.map { it.toModel() } } + + suspend fun awaitOne(id: Long): Category? = + dao.getById(id)?.toModel() + + suspend fun awaitAll(): List = + dao.getAll().map { it.toModel() } + + suspend fun awaitDefault(): Category = + dao.getDefault()?.toModel() + ?: error("Default category not found — SeedDefaultCategories must run on app start") +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/SeedDefaultCategories.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/SeedDefaultCategories.kt new file mode 100644 index 0000000..83d4af6 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/SeedDefaultCategories.kt @@ -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() }) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt new file mode 100644 index 0000000..a3191b3 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt @@ -0,0 +1,22 @@ +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.upsert(category.toEntity()) + } + val existing = dao.getById(category.id) + val resolved = if (existing != null && category.isDefault && !existing.isDefault) { + category.copy(isDefault = false) + } else { + category + } + return dao.upsert(resolved.toEntity()) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/model/Category.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/model/Category.kt new file mode 100644 index 0000000..4106353 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/model/Category.kt @@ -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() + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/ReassignExpenseCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/ReassignExpenseCategory.kt new file mode 100644 index 0000000..9c30184 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/interactor/ReassignExpenseCategory.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/DateRange.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/DateRange.kt new file mode 100644 index 0000000..d0657a1 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/DateRange.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/Expense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/Expense.kt new file mode 100644 index 0000000..831ca87 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/Expense.kt @@ -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(), +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseSummary.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseSummary.kt new file mode 100644 index 0000000..fd18d55 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseSummary.kt @@ -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>, + val period: DateRange, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseWithCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseWithCategory.kt new file mode 100644 index 0000000..745242a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/expense/model/ExpenseWithCategory.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/preference/AppPreference.kt b/app/src/main/java/dev/achmad/ledgerr/domain/preference/AppPreference.kt new file mode 100644 index 0000000..d856078 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/preference/AppPreference.kt @@ -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) +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/preference/ExpensePreference.kt b/app/src/main/java/dev/achmad/ledgerr/domain/preference/ExpensePreference.kt new file mode 100644 index 0000000..138f8a3 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/preference/ExpensePreference.kt @@ -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, + ) +} diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpense.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpense.kt new file mode 100644 index 0000000..f712544 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpense.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpenseWithCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpenseWithCategory.kt new file mode 100644 index 0000000..f05f518 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringExpenseWithCategory.kt @@ -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, +) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringInterval.kt b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringInterval.kt new file mode 100644 index 0000000..0afb4ae --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/recurring/model/RecurringInterval.kt @@ -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) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt new file mode 100644 index 0000000..bcfc67c --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt @@ -0,0 +1,30 @@ +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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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) + } + CoroutineScope(Dispatchers.IO).launch { + inject().await() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b53c7f8..78e3372 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ Ledgerr - \ No newline at end of file + Confirm + Cancel + OK + Not selected + Selected + Disabled + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d414440..74a037f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,9 @@ serialization_version = "1.11.0" voyager = "2.2.21-1.10.3" appcompat = "1.7.1" material = "1.14.0" +room = "2.7.1" +pdfboxAndroid = "2.0.27.0" +vico = "2.0.0" [libraries] 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" } 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From 547343992a2938a683d64bf05b97f171fa17df60 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 16:09:15 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(#1):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20split=20upsert=20into=20insert/update,=20add=20@Tra?= =?UTF-8?q?nsaction,=20runBlocking=20seed,=20trailing=20newline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on PR #8 (https://git.achmad.dev/admin/ledgerr/pulls/8): - Split @Upsert into @Insert(OnConflictStrategy.REPLACE) + @Update in all 3 DAOs. @Upsert returns -1 on the update path, so callers wanting the row ID would get a junk value. Interactors now call insert vs update based on id == 0. UpsertCategory returns category.id explicitly for the id != 0 branch. - Add @Transaction to the 3 @Relation queries (ExpenseDao.subscribeAll, ExpenseDao.subscribeByDateRange, RecurringExpenseDao.subscribeAll). This silences the KSP warnings the PR body mentioned and makes the intent explicit. - Switch MainApplication seeding from a fire-and-forget CoroutineScope to runBlocking(Dispatchers.IO). A fast first-tap on HomeScreen could otherwise call GetCategories.awaitDefault() before seeding completed and crash. - Add documenting comment on CategoryDao.getDefault() noting that the 'only one isDefault = 1' invariant is maintained at the interactor layer (partial unique index would be a v2 migration). - Add trailing newline to app/build.gradle.kts. - File follow-up issue #9 for flipping exportSchema to true before any v2 migration lands. --- app/build.gradle.kts | 2 +- .../ledgerr/data/local/dao/CategoryDao.kt | 21 +++++++++++++++---- .../ledgerr/data/local/dao/ExpenseDao.kt | 13 +++++++++--- .../data/local/dao/RecurringExpenseDao.kt | 13 +++++++++--- .../category/interactor/UpsertCategory.kt | 5 +++-- .../achmad/ledgerr/ui/base/MainApplication.kt | 5 ++--- 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7dc2ea..ba755b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,4 +90,4 @@ dependencies { implementation(libs.vico.compose) implementation(libs.vico.compose.m3) implementation(libs.vico.core) -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/CategoryDao.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/CategoryDao.kt index 51f78c3..8a182c7 100644 --- a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/CategoryDao.kt +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/CategoryDao.kt @@ -1,8 +1,10 @@ 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.Upsert +import androidx.room.Update import dev.achmad.ledgerr.data.local.entity.CategoryEntity import kotlinx.coroutines.flow.Flow @@ -18,16 +20,27 @@ interface CategoryDao { @Query("SELECT * FROM categories WHERE id = :id") suspend fun getById(id: Long): CategoryEntity? + /** + * 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 - @Upsert - suspend fun upsert(category: CategoryEntity): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(category: CategoryEntity): Long - @Upsert + @Update + suspend fun update(category: CategoryEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(categories: List) @Query("DELETE FROM categories WHERE id = :id") diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt index fe2d863..7757b06 100644 --- a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt @@ -3,9 +3,11 @@ 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.Upsert +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 @@ -22,9 +24,11 @@ data class ExpenseWithCategoryRow( @Dao interface ExpenseDao { + @Transaction @Query("SELECT * FROM expenses ORDER BY date DESC, createdAt DESC") fun subscribeAll(): Flow> + @Transaction @Query( "SELECT * FROM expenses " + "WHERE date BETWEEN :startDay AND :endDay " + @@ -50,8 +54,11 @@ interface ExpenseDao { ) suspend fun getByDateRange(startDay: Long, endDay: Long): List - @Upsert - suspend fun upsert(expense: ExpenseEntity): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(expense: ExpenseEntity): Long + + @Update + suspend fun update(expense: ExpenseEntity) @Insert suspend fun insertAll(expenses: List) diff --git a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt index d82fb0a..421608f 100644 --- a/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt @@ -2,9 +2,12 @@ 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.Upsert +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 @@ -21,6 +24,7 @@ data class RecurringExpenseWithCategoryRow( @Dao interface RecurringExpenseDao { + @Transaction @Query("SELECT * FROM recurring_expenses ORDER BY nextDueDate ASC") fun subscribeAll(): Flow> @@ -30,8 +34,11 @@ interface RecurringExpenseDao { @Query("SELECT * FROM recurring_expenses WHERE isActive = 1 AND nextDueDate <= :today") suspend fun getDue(today: Long): List - @Upsert - suspend fun upsert(recurring: RecurringExpenseEntity): Long + @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) diff --git a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt index a3191b3..82ff60f 100644 --- a/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt @@ -9,7 +9,7 @@ class UpsertCategory( ) { suspend fun await(category: Category): Long { if (category.id == 0L) { - return dao.upsert(category.toEntity()) + return dao.insert(category.toEntity()) } val existing = dao.getById(category.id) val resolved = if (existing != null && category.isDefault && !existing.isDefault) { @@ -17,6 +17,7 @@ class UpsertCategory( } else { category } - return dao.upsert(resolved.toEntity()) + dao.update(resolved.toEntity()) + return category.id } } diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt index bcfc67c..6adc97a 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt @@ -7,9 +7,8 @@ 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.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin @@ -23,7 +22,7 @@ class MainApplication : Application() { androidContext(this@MainApplication) modules(coreModule, dataModule, domainModule, preferenceModule) } - CoroutineScope(Dispatchers.IO).launch { + runBlocking(Dispatchers.IO) { inject().await() } }