This commit is contained in:
Rafal Wisniewski
2026-03-31 15:31:01 +02:00
parent 9b19b100e9
commit c4c9868698
37 changed files with 1539 additions and 475 deletions

View File

@@ -7,7 +7,6 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
@@ -15,24 +14,34 @@ import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.listexpense.toEpochMilli
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
import java.time.ZoneOffset
import javax.inject.Singleton
import kotlin.random.Random
import kotlin.random.nextInt
@Database(entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1)
@Database(
entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class TripDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao
@@ -46,21 +55,28 @@ abstract class TripDatabase : RoomDatabase() {
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@RequiresApi(Build.VERSION_CODES.O)
@Provides
@Singleton
fun provideTripDatabase(
@ApplicationContext context: Context,
// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel
@ApplicationContext context: Context
): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder(
context, TripDatabase::class.java
).allowMainThreadQueries().build()
// val db: TripDatabase = Room.databaseBuilder(
// name = "tripmoney_db",
context = context,
klass = TripDatabase::class.java,
)
.allowMainThreadQueries() // TODO Remove in production!
.fallbackToDestructiveMigration() // TODO Handle schema changes during dev
.build()
CoroutineScope(Dispatchers.IO).launch {
DatabasePrepopulator(
tripDao = db.tripDao(), categoryDao = db.categoryDao(), expenseDao = db.expenseDao()
tripDao = db.tripDao(),
categoryDao = db.categoryDao(),
expenseDao = db.expenseDao()
).prepopulate()
}
return db
@@ -99,169 +115,92 @@ private class DatabasePrepopulator(
) {
@RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() {
tripDao.insert(Trip(name = "Włochy", startDate = LocalDate.parse("2026-03-01"), currency = "PLN"))
tripDao.insert(Trip(name = "Szwajcaria", startDate =LocalDate.parse("2025-03-01"), currency = "EUR"))
tripDao.insert(Trip(name = "Portugalia", startDate = LocalDate.parse("2025-03-01"), currency = "USD"))
categoryDao.insert(Category(name = "Accomodation", icon = Icons.HOTEL, color = colors.random()))
categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, color = colors.random()))
categoryDao.insert(Category(name = "Flight", icon = Icons.FLIGHT, color = colors.random()))
categoryDao.insert(Category(name = "Restaurants", icon = Icons.RESTAURANT, color = colors.random()))
categoryDao.insert(Category(name = "Groceries", icon = Icons.GROCERIES, color = colors.random()))
categoryDao.insert(Category(name = "Coffee", icon = Icons.COFFEE,color = colors.random()))
categoryDao.insert(Category(name = "Entertainment", icon = Icons.ENTERTAINMENT,color = colors.random()))
categoryDao.insert(Category(name = "Laundry", icon = Icons.LAUNDRY,color = colors.random()))
tripDao.insert(
Trip(
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
currency = "PLN"
)
)
tripDao.insert(
Trip(
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
currency = "EUR"
)
)
tripDao.insert(
Trip(
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
currency = "USD"
)
)
for (category in sampleCategories) {
categoryDao.insert(category)
}
for (expense in sampleExpenses) {
expenseDao.insert(expense)
}
val now = LocalDateTime.now()
expenseDao.insert(
Expense(
amount = 120.50,
currency = "PLN",
note = "Hotel overnight",
datetime = now.minusDays(10),
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 45.75,
currency = "PLN",
note = "Dinner",
datetime = now.minusDays(9),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 15.20,
currency = "PLN",
note = "Bus ticket",
datetime = now.minusDays(8),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 89.99,
currency = "PLN",
note = "Concert tickets",
datetime = now.minusDays(7),
categoryId = 4,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 32.50,
currency = "PLN",
note = "Souvenirs",
datetime = now.minusDays(6),
categoryId = 5,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 180.00,
currency = "PLN",
note = "Hotel 3 nights",
datetime = now.minusDays(5),
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 67.30,
currency = "PLN",
note = "Lunch",
datetime = now.minusDays(4),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 22.00,
currency = "PLN",
note = "Train ticket",
datetime = now.minusDays(3),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 55.00,
currency = "PLN",
note = "Museum entry",
datetime = now.minusDays(2),
categoryId = 4,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 12.99,
currency = "PLN",
note = "Snacks",
datetime = now.minusDays(1),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 210.00,
currency = "PLN",
note = "Hotel 5 nights",
datetime = now,
categoryId = 1,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 95.50,
currency = "EUR",
note = "Dinner for two",
datetime = now.minusHours(12),
categoryId = 2,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 30.00,
currency = "EUR",
note = "Taxi",
datetime = now.minusHours(6),
categoryId = 3,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 40.00,
currency = "USD",
note = "Gifts",
datetime = now.minusHours(3),
categoryId = 5,
tripId = 1
)
)
expenseDao.insert(
Expense(
amount = 75.00,
currency = "PLN",
note = "Sightseeing tour",
datetime = now.minusHours(1),
categoryId = 4,
tripId = 1
)
)
}
}
val sampleCategories = listOf(
Category(
name = "Hotel",
icon = Icons.HOTEL,
color = colors.random()
),
Category(
name = "Jedzenie",
icon = Icons.RESTAURANT,
color = colors.random()
),
Category(
name = "Transport",
icon = Icons.FLIGHT,
color = colors.random()
),
Category(
name = "Rozrywka",
icon = Icons.ATTRACTION,
color = colors.random()
),
Category(
name = "Zakupy",
icon = Icons.GROCERIES,
color = colors.random()
),
)
@RequiresApi(Build.VERSION_CODES.O)
val sampleExpenses = (0..150).map { i ->
val datetime = if (i > 4) {
val now = LocalDateTime.now()
val min = now.minusDays(10).toInstant(ZoneOffset.UTC).toEpochMilli()
val max = now.toInstant(ZoneOffset.UTC).toEpochMilli()
val randomMillis = Random.nextLong(min, max)
LocalDateTime.ofInstant(Instant.ofEpochMilli(randomMillis), ZoneOffset.UTC)
} else {
LocalDateTime.now()
}
val expense = Expense(
categoryId = Random.nextInt(1, 5),
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
) else 1.0
)
expense
}
}

View File

@@ -1,6 +1,7 @@
package cc.n0th1ng.tripmoney.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
@@ -14,12 +15,23 @@ interface CategoryDao {
suspend fun insert(category: Category)
@Delete
suspend fun delete(category: Category)
@Transaction
@Query(
"""
SELECT * FROM category
SELECT * FROM category WHERE archived is 0
"""
)
fun categories(): Flow<List<Category>>
@Transaction
@Query(
"""
SELECT * FROM category WHERE archived is 1
"""
)
fun archivedCategories(): Flow<List<Category>>
}

View File

@@ -16,22 +16,38 @@ interface ExpenseDao {
@Upsert
suspend fun insert(expense: Expense)
@Query(
"""
SELECT * FROM expense WHERE trip_id = :tripId
SELECT expense.*, category.*
FROM expense
JOIN category ON expense.category_id = category.id
WHERE expense.trip_id = :tripId
AND
(
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
)
ORDER BY expense.datetime DESC
"""
"""
)
fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto>
fun expenseDtoPaged(tripId: Int, filter: String): PagingSource<Int, ExpenseDto>
@Transaction
@Query(
"""
SELECT * FROM expense WHERE trip_id = :tripId
SELECT * FROM expense
JOIN category ON expense.category_id = category.id
WHERE trip_id = :tripId
AND
(
(:filter IS NULL OR category.name LIKE '%' || :filter || '%')
OR (:filter IS NULL OR expense.note LIKE '%' || :filter || '%')
)
ORDER BY expense.datetime DESC
"""
)
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>>
fun expenseDto(tripId: Int, filter: String): Flow<List<ExpenseDto>>
@Delete
suspend fun delete(expense: Expense)

View File

@@ -17,7 +17,7 @@ interface TripDao {
@Query(
"""
SELECT * FROM trip
ORDER BY DATE(trip.start_date) DESC
ORDER BY trip.start_date DESC
"""
)
fun tripsPaged(): PagingSource<Int, Trip>

View File

@@ -1,14 +1,17 @@
package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Icons
@Entity(tableName = "category")
@Immutable
data class Category(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String,
@ColumnInfo("icon") val icon: Icons,
@ColumnInfo("color") val color: String
@ColumnInfo("color") val color: String,
@ColumnInfo("archived") val archived: Boolean = false
)

View File

@@ -1,13 +1,25 @@
package cc.n0th1ng.tripmoney.data.entity
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import androidx.room.Relation
import java.time.LocalDateTime
@Entity(tableName = "expense")
@Entity(
tableName = "expense",
foreignKeys = [ForeignKey(
entity = Category::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("category_id"),
onUpdate = ForeignKey.CASCADE,
onDelete = ForeignKey.CASCADE
)]
)
@Immutable
data class Expense(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("amount") val amount: Double,

View File

@@ -2,6 +2,7 @@ package cc.n0th1ng.tripmoney.data.entity
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -9,14 +10,16 @@ import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.LocalDate
@Entity(tableName = "trip")
@Immutable
data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("currency") val currency: String
@ColumnInfo("currency") val currency: String,
@ColumnInfo("budget") val budget: Double
){
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(-1, "dummy", LocalDate.now(), Currencies.default().name)
val DUMMY = Trip(-1, "", LocalDate.now(), Currencies.default().name, budget = 0.0)
}
}

View File

@@ -13,7 +13,16 @@ class CategoryRepository @Inject constructor(private val categoryDao: CategoryDa
categoryDao.insert(category)
}
@WorkerThread
suspend fun delete(category: Category) {
categoryDao.delete(category)
}
fun getCategories(): Flow<List<Category>> {
return categoryDao.categories()
}
fun getArchivedCategories(): Flow<List<Category>> {
return categoryDao.archivedCategories()
}
}

View File

@@ -45,7 +45,6 @@ class ExchangeRateRepository @Inject constructor(
}
}
@RequiresApi(Build.VERSION_CODES.O)
suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()

View File

@@ -12,8 +12,11 @@ import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -32,15 +35,15 @@ class ExpenseRepository @Inject constructor(
expenseDao.delete(expense)
}
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
fun getExpensesDtoPaged(tripId: Int, filter: String): Flow<PagingData<ExpenseDto>> {
return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId, filter) }
).flow
}
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId)
fun getExpensesDto(tripId: Int, filter: String = ""): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId, filter)
}
@RequiresApi(Build.VERSION_CODES.O)

View File

@@ -9,7 +9,10 @@ import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class TripRepository @Inject constructor(
@@ -18,9 +21,7 @@ class TripRepository @Inject constructor(
) {
@RequiresApi(Build.VERSION_CODES.O)
@WorkerThread
suspend fun save(trip: Trip) {
expenseRepository.recalculateTripExpenses(trip.id)
tripDao.insert(trip)
}