diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6823e2..ba755b0 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) -} \ No newline at end of file + + 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) +} 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? + + /** + * 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) + + @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..7757b06 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/ExpenseDao.kt @@ -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 { + + @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 " + + "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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(expense: ExpenseEntity): Long + + @Update + suspend fun update(expense: ExpenseEntity) + + @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..421608f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/data/local/dao/RecurringExpenseDao.kt @@ -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 { + + @Transaction + @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 + + @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() +} 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..82ff60f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/domain/category/interactor/UpsertCategory.kt @@ -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 + } +} 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..6adc97a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/base/MainApplication.kt @@ -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().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" }