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)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".ui.base.MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_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,38 @@
|
||||
package dev.achmad.ledgerr.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
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?
|
||||
|
||||
@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<CategoryEntity>)
|
||||
|
||||
@Query("DELETE FROM categories WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM categories")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -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<List<ExpenseWithCategoryRow>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM expenses " +
|
||||
"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"
|
||||
)
|
||||
suspend fun getByDateRange(startDay: Long, endDay: Long): List<ExpenseEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(expense: ExpenseEntity): Long
|
||||
|
||||
@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,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<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")
|
||||
suspend fun getDue(today: Long): List<RecurringExpenseEntity>
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
single {
|
||||
androidContext().getSharedPreferences(
|
||||
"ledgerr_prefs",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
}
|
||||
single<PreferenceStore> { AndroidPreferenceStore(get()) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
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
|
||||
|
||||
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()) }
|
||||
|
||||
}
|
||||
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,
|
||||
)
|
||||
+11
@@ -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")
|
||||
}
|
||||
+56
@@ -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,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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+14
@@ -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,
|
||||
)
|
||||
+8
@@ -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,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<SeedDefaultCategories>().await()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
<resources>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user