This commit is contained in:
Rafal Wisniewski
2026-03-26 22:38:51 +01:00
parent af7c926060
commit 9b19b100e9
18 changed files with 488 additions and 388 deletions

View File

@@ -20,6 +20,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.BottomNavigation import cc.n0th1ng.tripmoney.navigation.BottomNavigation
import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer
import cc.n0th1ng.tripmoney.navigation.Screens import cc.n0th1ng.tripmoney.navigation.Screens
@@ -59,7 +60,7 @@ fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val navController = rememberNavController() val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route val current = navBackStack?.destination?.route

View File

@@ -0,0 +1,41 @@
package cc.n0th1ng.tripmoney.data
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.TypeConverter
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
class Converters {
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun fromLocalDatetime(value: LocalDateTime): Long {
return value
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun toLocalDateTime(value: Long): LocalDateTime {
return Instant.ofEpochMilli(value)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun fromLocalDate(value: LocalDate): Long {
return value.toEpochDay()
}
@RequiresApi(Build.VERSION_CODES.O)
@TypeConverter
fun toLocalDate(value: Long): LocalDate {
return LocalDate.ofEpochDay(value)
}
}

View File

@@ -6,6 +6,7 @@ import androidx.annotation.RequiresApi
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import cc.n0th1ng.tripmoney.data.dao.CategoryDao import cc.n0th1ng.tripmoney.data.dao.CategoryDao
import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao import cc.n0th1ng.tripmoney.data.dao.ExchangeRateDao
@@ -17,6 +18,7 @@ import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -25,11 +27,13 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@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 class TripDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao abstract fun tripDao(): TripDao
abstract fun expenseDao(): ExpenseDao abstract fun expenseDao(): ExpenseDao
@@ -46,7 +50,8 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideTripDatabase( fun provideTripDatabase(
@ApplicationContext context: Context @ApplicationContext context: Context,
// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel
): TripDatabase { ): TripDatabase {
val db: TripDatabase = Room.inMemoryDatabaseBuilder( val db: TripDatabase = Room.inMemoryDatabaseBuilder(
@@ -94,9 +99,9 @@ private class DatabasePrepopulator(
) { ) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
suspend fun prepopulate() { suspend fun prepopulate() {
tripDao.insert(Trip(name = "Włochy", startDate = "2026-03-01", currency = "PLN")) tripDao.insert(Trip(name = "Włochy", startDate = LocalDate.parse("2026-03-01"), currency = "PLN"))
tripDao.insert(Trip(name = "Szwajcaria", startDate = "2025-03-01", currency = "EUR")) tripDao.insert(Trip(name = "Szwajcaria", startDate =LocalDate.parse("2025-03-01"), currency = "EUR"))
tripDao.insert(Trip(name = "Portugalia", startDate = "2025-03-01", currency = "USD")) 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 = "Accomodation", icon = Icons.HOTEL, color = colors.random()))
categoryDao.insert(Category(name = "Transport", icon = Icons.TRANSPORT, 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 = "Flight", icon = Icons.FLIGHT, color = colors.random()))
@@ -113,7 +118,7 @@ private class DatabasePrepopulator(
amount = 120.50, amount = 120.50,
currency = "PLN", currency = "PLN",
note = "Hotel overnight", note = "Hotel overnight",
datetime = now.minusDays(10).toString(), datetime = now.minusDays(10),
categoryId = 1, categoryId = 1,
tripId = 1 tripId = 1
) )
@@ -123,7 +128,7 @@ private class DatabasePrepopulator(
amount = 45.75, amount = 45.75,
currency = "PLN", currency = "PLN",
note = "Dinner", note = "Dinner",
datetime = now.minusDays(9).toString(), datetime = now.minusDays(9),
categoryId = 2, categoryId = 2,
tripId = 1 tripId = 1
) )
@@ -133,7 +138,7 @@ private class DatabasePrepopulator(
amount = 15.20, amount = 15.20,
currency = "PLN", currency = "PLN",
note = "Bus ticket", note = "Bus ticket",
datetime = now.minusDays(8).toString(), datetime = now.minusDays(8),
categoryId = 3, categoryId = 3,
tripId = 1 tripId = 1
) )
@@ -143,7 +148,7 @@ private class DatabasePrepopulator(
amount = 89.99, amount = 89.99,
currency = "PLN", currency = "PLN",
note = "Concert tickets", note = "Concert tickets",
datetime = now.minusDays(7).toString(), datetime = now.minusDays(7),
categoryId = 4, categoryId = 4,
tripId = 1 tripId = 1
) )
@@ -153,7 +158,7 @@ private class DatabasePrepopulator(
amount = 32.50, amount = 32.50,
currency = "PLN", currency = "PLN",
note = "Souvenirs", note = "Souvenirs",
datetime = now.minusDays(6).toString(), datetime = now.minusDays(6),
categoryId = 5, categoryId = 5,
tripId = 1 tripId = 1
) )
@@ -163,7 +168,7 @@ private class DatabasePrepopulator(
amount = 180.00, amount = 180.00,
currency = "PLN", currency = "PLN",
note = "Hotel 3 nights", note = "Hotel 3 nights",
datetime = now.minusDays(5).toString(), datetime = now.minusDays(5),
categoryId = 1, categoryId = 1,
tripId = 1 tripId = 1
) )
@@ -173,7 +178,7 @@ private class DatabasePrepopulator(
amount = 67.30, amount = 67.30,
currency = "PLN", currency = "PLN",
note = "Lunch", note = "Lunch",
datetime = now.minusDays(4).toString(), datetime = now.minusDays(4),
categoryId = 2, categoryId = 2,
tripId = 1 tripId = 1
) )
@@ -183,7 +188,7 @@ private class DatabasePrepopulator(
amount = 22.00, amount = 22.00,
currency = "PLN", currency = "PLN",
note = "Train ticket", note = "Train ticket",
datetime = now.minusDays(3).toString(), datetime = now.minusDays(3),
categoryId = 3, categoryId = 3,
tripId = 1 tripId = 1
) )
@@ -193,7 +198,7 @@ private class DatabasePrepopulator(
amount = 55.00, amount = 55.00,
currency = "PLN", currency = "PLN",
note = "Museum entry", note = "Museum entry",
datetime = now.minusDays(2).toString(), datetime = now.minusDays(2),
categoryId = 4, categoryId = 4,
tripId = 1 tripId = 1
) )
@@ -203,7 +208,7 @@ private class DatabasePrepopulator(
amount = 12.99, amount = 12.99,
currency = "PLN", currency = "PLN",
note = "Snacks", note = "Snacks",
datetime = now.minusDays(1).toString(), datetime = now.minusDays(1),
categoryId = 2, categoryId = 2,
tripId = 1 tripId = 1
) )
@@ -213,7 +218,7 @@ private class DatabasePrepopulator(
amount = 210.00, amount = 210.00,
currency = "PLN", currency = "PLN",
note = "Hotel 5 nights", note = "Hotel 5 nights",
datetime = now.toString(), datetime = now,
categoryId = 1, categoryId = 1,
tripId = 1 tripId = 1
) )
@@ -223,7 +228,7 @@ private class DatabasePrepopulator(
amount = 95.50, amount = 95.50,
currency = "EUR", currency = "EUR",
note = "Dinner for two", note = "Dinner for two",
datetime = now.minusHours(12).toString(), datetime = now.minusHours(12),
categoryId = 2, categoryId = 2,
tripId = 1 tripId = 1
) )
@@ -233,7 +238,7 @@ private class DatabasePrepopulator(
amount = 30.00, amount = 30.00,
currency = "EUR", currency = "EUR",
note = "Taxi", note = "Taxi",
datetime = now.minusHours(6).toString(), datetime = now.minusHours(6),
categoryId = 3, categoryId = 3,
tripId = 1 tripId = 1
) )
@@ -243,7 +248,7 @@ private class DatabasePrepopulator(
amount = 40.00, amount = 40.00,
currency = "USD", currency = "USD",
note = "Gifts", note = "Gifts",
datetime = now.minusHours(3).toString(), datetime = now.minusHours(3),
categoryId = 5, categoryId = 5,
tripId = 1 tripId = 1
) )
@@ -253,7 +258,7 @@ private class DatabasePrepopulator(
amount = 75.00, amount = 75.00,
currency = "PLN", currency = "PLN",
note = "Sightseeing tour", note = "Sightseeing tour",
datetime = now.minusHours(1).toString(), datetime = now.minusHours(1),
categoryId = 4, categoryId = 4,
tripId = 1 tripId = 1
) )

View File

@@ -19,7 +19,7 @@ interface ExpenseDao {
@Query( @Query(
""" """
SELECT * FROM expense WHERE trip_id = :tripId SELECT * FROM expense WHERE trip_id = :tripId
ORDER BY DATETIME(expense.datetime) DESC ORDER BY expense.datetime DESC
""" """
) )
fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto> fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto>
@@ -28,33 +28,11 @@ interface ExpenseDao {
@Query( @Query(
""" """
SELECT * FROM expense WHERE trip_id = :tripId SELECT * FROM expense WHERE trip_id = :tripId
ORDER BY DATETIME(expense.datetime) DESC ORDER BY expense.datetime DESC
""" """
) )
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>> fun expenseDto(tripId: Int): Flow<List<ExpenseDto>>
@Delete @Delete
suspend fun delete(expense: Expense) suspend fun delete(expense: Expense)
@Query(
"""
SELECT
c.id as categoryId,
c.name as categoryName,
c.icon as icon,
c.color as color,
SUM(e.amount) as amount,
e.currency as currency
FROM
expense e
JOIN
category c ON e.category_id = c.id
WHERE
e.trip_id = :tripId
GROUP BY
c.id, c.name, c.icon, c.color, e.currency
"""
)
fun summaryPerCategoryRaw(tripId: Int): Flow<List<SummaryPerCategoryRaw>>
} }

View File

@@ -7,6 +7,7 @@ import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TripDao { interface TripDao {
@@ -27,5 +28,5 @@ interface TripDao {
@Query( @Query(
"SELECT * FROM trip where trip.id = :tripId" "SELECT * FROM trip where trip.id = :tripId"
) )
fun trip(tripId: Int): Trip? fun trip(tripId: Int): Flow<Trip?>
} }

View File

@@ -5,6 +5,7 @@ import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Relation import androidx.room.Relation
import java.time.LocalDateTime
@Entity(tableName = "expense") @Entity(tableName = "expense")
data class Expense( data class Expense(
@@ -12,10 +13,15 @@ data class Expense(
@ColumnInfo("amount") val amount: Double, @ColumnInfo("amount") val amount: Double,
@ColumnInfo("currency") val currency: String, @ColumnInfo("currency") val currency: String,
@ColumnInfo("note") val note: String, @ColumnInfo("note") val note: String,
@ColumnInfo("datetime") val datetime: String, @ColumnInfo("datetime") val datetime: LocalDateTime,
@ColumnInfo("category_id") val categoryId: Int, @ColumnInfo("category_id") val categoryId: Int,
@ColumnInfo("trip_id") val tripId: Int @ColumnInfo("trip_id") val tripId: Int,
) @ColumnInfo("rate") val rate: Double = 1.0
) {
fun convertedAmount(): Double {
return this.amount * this.rate
}
}
data class ExpenseDto( data class ExpenseDto(
@Embedded val expense: Expense, @Embedded val expense: Expense,

View File

@@ -1,13 +1,22 @@
package cc.n0th1ng.tripmoney.data.entity package cc.n0th1ng.tripmoney.data.entity
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import cc.n0th1ng.tripmoney.utils.Currencies
import java.time.LocalDate
@Entity(tableName = "trip") @Entity(tableName = "trip")
data class Trip( data class Trip(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo("name") val name: String, @ColumnInfo("name") val name: String,
@ColumnInfo("start_date") val startDate: String, @ColumnInfo("start_date") val startDate: LocalDate,
@ColumnInfo("currency") val currency: String @ColumnInfo("currency") val currency: String
) ){
companion object {
@RequiresApi(Build.VERSION_CODES.O)
val DUMMY = Trip(-1, "dummy", LocalDate.now(), Currencies.default().name)
}
}

View File

@@ -25,6 +25,7 @@ class ExchangeRateRepository @Inject constructor(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double { suspend fun getRate(base: Currencies, target: Currencies, date: LocalDate): Double {
if(base == target) return 1.0
val id = ExchangeRate.buildKey(base.name, target.name, date.toString()) val id = ExchangeRate.buildKey(base.name, target.name, date.toString())
val cachedRate = exchangeRateDao.getById(id) val cachedRate = exchangeRateDao.getById(id)
return if (cachedRate != null) { return if (cachedRate != null) {

View File

@@ -1,6 +1,9 @@
package cc.n0th1ng.tripmoney.data.repository package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -8,10 +11,16 @@ import cc.n0th1ng.tripmoney.data.dao.ExpenseDao
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.utils.Currencies
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao) { class ExpenseRepository @Inject constructor(
private val expenseDao: ExpenseDao,
private val exchangeRateRepository: ExchangeRateRepository
) {
@WorkerThread @WorkerThread
suspend fun save(expense: Expense) { suspend fun save(expense: Expense) {
@@ -23,18 +32,30 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao)
expenseDao.delete(expense) expenseDao.delete(expense)
} }
fun getExpensesPaged(tripId: Int): Flow<PagingData<ExpenseDto>> { fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false), config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) } pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
).flow ).flow
} }
fun getExpenses(tripId: Int): Flow<List<ExpenseDto>> { fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId) return expenseDao.expenseDto(tripId)
} }
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategoryRaw>> { @RequiresApi(Build.VERSION_CODES.O)
return expenseDao.summaryPerCategoryRaw(tripId) suspend fun recalculateTripExpenses(tripId: Int) {
val expenses = getExpensesDto(tripId).first()
expenses.forEach { expenseDto ->
val newRate = exchangeRateRepository.getRate(
Currencies.valueOf(expenseDto.expense.currency),
Currencies.valueOf(expenseDto.trip.currency),
expenseDto.expense.datetime.toLocalDate()
)
save(
expenseDto.expense.copy(rate = newRate)
)
}
} }
} }

View File

@@ -1,18 +1,26 @@
package cc.n0th1ng.tripmoney.data.repository package cc.n0th1ng.tripmoney.data.repository
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.TripDao import cc.n0th1ng.tripmoney.data.dao.TripDao
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class TripRepository @Inject constructor(private val tripDao: TripDao) { class TripRepository @Inject constructor(
private val tripDao: TripDao,
private val expenseRepository: ExpenseRepository
) {
@RequiresApi(Build.VERSION_CODES.O)
@WorkerThread @WorkerThread
suspend fun save(trip: Trip) { suspend fun save(trip: Trip) {
expenseRepository.recalculateTripExpenses(trip.id)
tripDao.insert(trip) tripDao.insert(trip)
} }
@@ -23,7 +31,7 @@ class TripRepository @Inject constructor(private val tripDao: TripDao) {
).flow ).flow
} }
fun getTrip(tripId: Int): Trip? { fun getTrip(tripId: Int): Flow<Trip?> {
return tripDao.trip(tripId) return tripDao.trip(tripId)
} }

View File

@@ -1,43 +1,38 @@
package cc.n0th1ng.tripmoney.screens.addexpense package cc.n0th1ng.tripmoney.screens.addexpense
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.drawable.PaintDrawable
import android.os.Build import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableDoubleStateOf
@@ -50,17 +45,13 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.modifier.modifierLocalMapOf
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.sensitiveContent
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
@@ -83,10 +74,12 @@ import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import com.composables.icons.materialsymbols.outlined.R.drawable import com.composables.icons.materialsymbols.outlined.R.drawable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.collections.listOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@@ -101,14 +94,15 @@ fun AddExpenseBottomSheet(
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId)!! val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList()) val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
AddExpenseBottomSheet( AddExpenseBottomSheet(
onSave = onSave, onSave = onSave,
onDismiss = onDismiss, onDismiss = onDismiss,
expenseDtoToEdit = expenseDtoToEdit, expenseDtoToEdit = expenseDtoToEdit,
state = state, state = state,
currentTrip = currentTrip, currentTrip = currentTrip!!,
categories = categories categories = categories
) )
} }
@@ -148,9 +142,7 @@ fun AddExpenseBottomSheet(
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) } var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }
var datetime by remember { var datetime by remember {
mutableStateOf( mutableStateOf(
LocalDateTime.parse( expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now()
expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now().toString()
)
) )
} }
var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") } var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") }
@@ -192,14 +184,16 @@ fun AddExpenseBottomSheet(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column{ Column {
Text( Text(
text = amount.ifEmpty { "0.00" }, text = amount.ifEmpty { "0.00" },
fontSize = 25.sp, fontSize = 25.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = if(amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(equationResult) else "", text = if (amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(
equationResult
) else "",
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -244,12 +238,11 @@ fun AddExpenseBottomSheet(
NumberKeyboard( NumberKeyboard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onOperatorClick = { operator -> onOperatorClick = { operator ->
if(amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) { if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) {
amount = evaluate(amount).toString() amount = evaluate(amount).toString()
// equationResult = 0.0
} }
val newText = amount + operator val newText = amount + operator
if(newText.isDoubleTwoDigitsOrEquation()) { if (newText.isDoubleTwoDigitsOrEquation()) {
amount = newText amount = newText
enableSave = false enableSave = false
} }
@@ -282,7 +275,7 @@ fun AddExpenseBottomSheet(
amount = equationResult, amount = equationResult,
currency = currency, currency = currency,
note = note, note = note,
datetime = datetime.toString(), datetime = datetime,
categoryId = category.id, categoryId = category.id,
tripId = currentTripId tripId = currentTripId
) )
@@ -330,7 +323,7 @@ fun AddExpenseBottomSheet(
fun String.safeSubstring(start: Int, end: Int): String { fun String.safeSubstring(start: Int, end: Int): String {
return try { return try {
this.substring(start, end) this.substring(start, end)
} catch (e: Exception) { } catch (_: Exception) {
"0.00" "0.00"
} }
} }
@@ -460,7 +453,8 @@ fun NumberKeyboard(
"backspace" -> KeyboardButton( "backspace" -> KeyboardButton(
icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined), icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined),
onClick = onBackspaceClick, onClick = onBackspaceClick,
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f),
containerColor = MaterialTheme.colorScheme.primary containerColor = MaterialTheme.colorScheme.primary
) )
@@ -488,19 +482,20 @@ fun NumberKeyboard(
@Composable @Composable
fun KeyboardButton( fun KeyboardButton(
modifier: Modifier = Modifier,
text: String? = null, text: String? = null,
icon: Painter? = null, icon: Painter? = null,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primary, containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary contentColor: Color = MaterialTheme.colorScheme.onPrimary
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
modifier = modifier modifier = modifier
.padding(4.dp) .padding(2.dp)
.aspectRatio(2.5f), .aspectRatio(2.5f),
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -545,7 +540,12 @@ fun PreviewAddExpenseDisabled() {
onDismiss = {}, onDismiss = {},
expenseDtoToEdit = null, expenseDtoToEdit = null,
state = sheetState, state = sheetState,
currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name), currentTrip = Trip(
1,
"Trip",
LocalDate.parse("2020-01-01"),
Currencies.entries.random().name
),
categories = categoriesToPreview categories = categoriesToPreview
) )
} }
@@ -572,13 +572,20 @@ fun PreviewAddExpenseEnabled() {
amount = 10.31, amount = 10.31,
currency = "PLN", currency = "PLN",
note = "some note", note = "some note",
datetime = "2025-11-30T10:16:26.939", datetime = LocalDateTime.now(),
categoryId = 1, categoryId = 1,
tripId = 1 tripId = 1
), category = categoriesToPreview.get(0), Trip(1, "Włochy", "2025-01-02", "PLN") ),
category = categoriesToPreview[0],
Trip(1, "Włochy", LocalDate.parse("2025-01-02"), "PLN")
), ),
state = sheetState, state = sheetState,
currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name), currentTrip = Trip(
1,
"Trip",
LocalDate.parse("2020-01-01"),
Currencies.entries.random().name
),
categories = categoriesToPreview categories = categoriesToPreview
) )
} }

View File

@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -40,7 +41,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -53,43 +53,39 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseDtoWithConvertedAmount import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen() { fun ListExpenseScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState() val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val expensesWithConvertedFlow = expenseAndCategoryViewModel val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
.getExpensesWithConvertedAmountsPaged(currentTrip)
ListExpenseScreen( ListExpenseScreen(
expensesWithConvertedFlow = expensesWithConvertedFlow, expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it) }, onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) }) onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -97,14 +93,15 @@ fun ListExpenseScreen() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen( fun ListExpenseScreen(
expensesWithConvertedFlow: Flow<PagingData<ExpenseDtoWithConvertedAmount>>, expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
) { ) {
val expensesWithConverted = expensesWithConvertedFlow.collectAsLazyPagingItems()
val items = expensesFlow.collectAsLazyPagingItems()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var expenseDtoToEdit: ExpenseDto? = null var expenseDtoToEdit by remember { mutableStateOf<ExpenseDto?>(null) }
val sumMap = remember { mutableStateMapOf<LocalDate, Double>() } var itemToDelete by remember { mutableStateOf<Expense?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
@@ -114,57 +111,71 @@ fun ListExpenseScreen(
) )
}) })
{ {
LaunchedEffect(expensesWithConverted.itemSnapshotList.items) { if (items.loadState.refresh == LoadState.Loading) {
val items = expensesWithConverted.itemSnapshotList.items // Show loading indicator
val newSums = items Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
.groupBy { LocalDateTime.parse(it.expenseDto.expense.datetime).toLocalDate() } CircularProgressIndicator()
.mapValues { (_, expensesForDay) -> }
expensesForDay.sumOf { it.convertedAmount }
}
sumMap.clear()
sumMap.putAll(newSums)
} }
LazyColumn( else {
modifier = Modifier.fillMaxSize(), LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(),
state = listState horizontalAlignment = Alignment.CenterHorizontally,
) { state = listState
items( ) {
count = expensesWithConverted.itemCount, items(
key = { index -> expensesWithConverted[index]?.expenseDto?.expense?.id ?: index } count = items.itemCount,
) { index -> key = items.itemKey { item ->
val expenseDtoWithConverted = expensesWithConverted[index] when (item) {
val expenseDto = expenseDtoWithConverted?.expenseDto is ExpenseListItemUi.Item -> item.expenseDto.expense.id
if (expenseDtoWithConverted != null && expenseDto != null) { is ExpenseListItemUi.Header -> "header_${item.date}"
val previousExpense = }
expensesWithConverted.itemSnapshotList.items.getOrNull(index - 1)?.expenseDto }
val showDayDivider = ) { index ->
index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime)
.toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime) when (val item = items[index]) {
.toLocalDate()
Spacer(Modifier is ExpenseListItemUi.Header -> {
.height(5.dp) CustomDivider(
.background(MaterialTheme.colorScheme.onBackground)) date = item.date,
if (showDayDivider) { sum = item.sum,
CustomDivider( currency = item.currency
expenseDto, )
sumMap.getOrDefault( }
LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate(), 0.00
) is ExpenseListItemUi.Item -> {
) SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
}
Spacer(Modifier.height(10.dp))
} }
Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard(
expenseDtoWithConverted = expenseDtoWithConverted,
onDelete = { expense -> onDeleteExpense(expense) },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
})
} }
} }
if (itemToDelete != null) {
DeleteConfirmationDialog(
onConfirm = {
onDeleteExpense(itemToDelete!!)
itemToDelete = null
},
onCancel = {
itemToDelete = null
}
)
} }
if (showBottomSheet) { if (showBottomSheet) {
AddExpenseBottomSheet( AddExpenseBottomSheet(
onSave = { expense -> onSave = { expense ->
@@ -186,7 +197,7 @@ fun ListExpenseScreen(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun CustomDivider(expenseDto: ExpenseDto, sum: Double) { fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center, horizontalArrangement = Arrangement.Absolute.Center,
@@ -194,7 +205,7 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) {
) { ) {
HorizontalDivider(modifier = Modifier.weight(1f)) HorizontalDivider(modifier = Modifier.weight(1f))
Text( Text(
LocalDateTime.parse(expenseDto.expense.datetime).format( date.format(
DateTimeFormatter.ofPattern("dd EEEE") DateTimeFormatter.ofPattern("dd EEEE")
).toString(), ).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f)), modifier = Modifier.background(Color.White.copy(alpha = 0f)),
@@ -209,7 +220,7 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) {
) { ) {
HorizontalDivider(modifier = Modifier.weight(2f)) HorizontalDivider(modifier = Modifier.weight(2f))
Text( Text(
"%.2f %s".format(sum, expenseDto.trip.currency), "%.2f %s".format(sum, currency),
modifier = Modifier.background(Color.White.copy(alpha = 0f)), modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@@ -222,55 +233,44 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun SwipeToDeleteExpenseCard( fun SwipeToDeleteExpenseCard(
expenseDtoWithConverted: ExpenseDtoWithConvertedAmount, expenseDto: ExpenseDto,
onDelete: (Expense) -> Unit, onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit onClick: (ExpenseDto) -> Unit
) { ) {
var dismissed by remember { mutableStateOf(false) } val dismissState = rememberSwipeToDismissBoxState(
var showDialog by remember { mutableStateOf(false) } confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
if (!dismissed) { onDelete(expenseDto.expense)
val dismissState = rememberSwipeToDismissBoxState( false
confirmValueChange = { dismissValue -> } else {
if (dismissValue == SwipeToDismissBoxValue.EndToStart false
) {
showDialog = true
false
} else {
false
}
} }
}
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(string.delete)
)
}
}
) {
ExpenseCard(
expenseDto = expenseDto,
onClick = onClick
) )
if (showDialog) {
DeleteConfirmationDialog(
onConfirm = {
showDialog = false
dismissed = true
onDelete(expenseDtoWithConverted.expenseDto.expense)
},
onCancel = { showDialog = false }
)
}
SwipeToDismissBox(
modifier = Modifier,
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
}
}
) {
ExpenseCard(expenseDtoWithConverted, onClick = onClick)
}
} }
} }
@@ -324,10 +324,9 @@ fun DeleteConfirmationDialog(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ExpenseCard( fun ExpenseCard(
expenseDtoWithConverted: ExpenseDtoWithConvertedAmount, expenseDto: ExpenseDto,
onClick: (ExpenseDto) -> Unit onClick: (ExpenseDto) -> Unit
) { ) {
val expenseDto = expenseDtoWithConverted.expenseDto
ElevatedCard( ElevatedCard(
colors = CardDefaults.elevatedCardColors() colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer), .copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
@@ -379,7 +378,7 @@ fun ExpenseCard(
} }
Text( Text(
text = LocalDateTime.parse(expenseDto.expense.datetime).format( text = expenseDto.expense.datetime.format(
DateTimeFormatter.ofPattern("dd MMM HH:mm") DateTimeFormatter.ofPattern("dd MMM HH:mm")
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -397,7 +396,7 @@ fun ExpenseCard(
) )
if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) { if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
Text( Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDtoWithConverted.convertedAmount), text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
@@ -408,106 +407,107 @@ fun ExpenseCard(
} }
} }
@RequiresApi(Build.VERSION_CODES.O) //@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews //@AllPreviews
@Composable //@Composable
fun PreviewListExpenseScreen() { //fun PreviewListExpenseScreen() {
TripMoneyTheme() { // TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) // val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen( // ListExpenseScreen(
expensesWithConvertedFlow = MutableStateFlow(pagingData), // expensesDtoFlow = MutableStateFlow(pagingData),
onSaveExpense = {}, // onSaveExpense = {},
onDeleteExpense = {} // onDeleteExpense = {},
) // dailySums = emptyMap()
// )
} //
} // }
//}
@AllPreviews //
@Composable //@AllPreviews
fun PreviewDeleteConfirmationDialog() { //@Composable
TripMoneyTheme() { //fun PreviewDeleteConfirmationDialog() {
DeleteConfirmationDialog( // TripMoneyTheme() {
onConfirm = {}, // DeleteConfirmationDialog(
onCancel = {}) // onConfirm = {},
} // onCancel = {})
} // }
//}
//
@RequiresApi(Build.VERSION_CODES.O) //
private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDtoWithConvertedAmount> { //@RequiresApi(Build.VERSION_CODES.O)
val sampleCategories = listOf( //private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDto> {
Category( // val sampleCategories = listOf(
name = "Hotel", // Category(
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, // name = "Hotel",
color = colors.random() // icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
), // color = colors.random()
Category( // ),
name = "Jedzenie", // Category(
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, // name = "Jedzenie",
color = colors.random() // icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
), // color = colors.random()
Category( // ),
name = "Transport", // Category(
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, // name = "Transport",
color = colors.random() // icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
), // color = colors.random()
Category( // ),
name = "Rozrywka", // Category(
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, // name = "Rozrywka",
color = colors.random() // icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
), // color = colors.random()
Category( // ),
name = "Zakupy", // Category(
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, // name = "Zakupy",
color = colors.random() // icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
), // color = colors.random()
) // ),
// )
val trip = Trip( //
id = 1, // val trip = Trip(
name = "Vacation", // id = 1,
currency = "USD", // name = "Vacation",
startDate = "2026-01-01" // currency = "USD",
) // startDate = LocalDate.parse("2026-01-01")
// )
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() //
val endLong = LocalDateTime.now().toEpochMilli() // val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
// val endLong = LocalDateTime.now().toEpochMilli()
val result: MutableList<ExpenseDtoWithConvertedAmount> = mutableListOf() //
for (i in 0..15) { // val result: MutableList<ExpenseDto> = mutableListOf()
val category = sampleCategories.random() // for (i in 0..15) {
val datetime = if (i > 4) { // val category = sampleCategories.random()
LocalDateTime.ofEpochSecond( // val datetime = if (i > 4) {
Random.nextLong(startLong, endLong), // LocalDateTime.ofEpochSecond(
0, // Random.nextLong(startLong, endLong),
ZoneOffset.UTC // 0,
).toString() // ZoneOffset.UTC
} else LocalDateTime.now().toString() // )
// } else LocalDateTime.now()
val expense = Expense( //
id = i, // val expense = Expense(
categoryId = category.id, // id = i,
tripId = 1, // categoryId = category.id,
amount = Random.nextDouble(0.1, 300.0), // tripId = 1,
currency = Currencies.entries.random().name, // amount = Random.nextDouble(0.1, 300.0),
note = if (i % 3 == 0) "Some note" else "", // currency = Currencies.entries.random().name,
datetime = datetime // note = if (i % 3 == 0) "Some note" else "",
) // datetime = datetime,
val expenseDto = ExpenseDto( // rate = if (Random.nextBoolean()) Random.nextDouble(
expense = expense, // 0.1,
category = category, // 5.0
trip = trip // ) else 1.0
) // )
result.add( //
ExpenseDtoWithConvertedAmount( //
expenseDto, // val expenseDto = ExpenseDto(
convertedAmount = if (Random.nextBoolean()) Random.nextDouble( // expense = expense,
0.1, // category = category,
300.0 // trip = trip
) else expense.amount // )
) // result.add(
) // expenseDto
} // )
return result // }
} // return result
//}

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.* import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.AppTheme import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
@@ -66,7 +67,7 @@ fun SettingsScreen() {
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip = tripViewModel.getTrip(currentTripId) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val context = LocalContext.current val context = LocalContext.current
val tripName = currentTrip?.name ?: "" val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()

View File

@@ -50,7 +50,7 @@ fun StatisticsScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId) val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId) val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId)
.collectAsState(emptyList()) .collectAsState(emptyList())
val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId) val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId)

View File

@@ -94,7 +94,7 @@ fun AddTripBottomSheet(
var name by remember { mutableStateOf(tripToEdit?.name ?: "") } var name by remember { mutableStateOf(tripToEdit?.name ?: "") }
var startDate by remember { var startDate by remember {
mutableStateOf( mutableStateOf(
LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString()) tripToEdit?.startDate ?: LocalDate.now()
) )
} }
@@ -157,7 +157,7 @@ fun AddTripBottomSheet(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
onClick = { onClick = {
val trip = val trip =
Trip(name = name, startDate = startDate.toString(), currency = currency) Trip(name = name, startDate = startDate, currency = currency)
onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id)) onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id))
}) { }) {
@@ -231,7 +231,7 @@ fun PreviewAddTripBottomSheetEditTrip() {
AddTripBottomSheet( AddTripBottomSheet(
{}, {},
{}, {},
Trip(1, "Włochy", "2025-01-02", "PLN"), Trip(1, "Włochy", LocalDate.parse("2025-01-02"), "PLN"),
sheetState, sheetState,
defaultCurrency = Currencies.entries.random() defaultCurrency = Currencies.entries.random()
) )

View File

@@ -206,7 +206,7 @@ fun TripCard(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) { ) {
Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name) Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name)
Text(trip.startDate) Text(trip.startDate.toString())
} }
Text( Text(
trip.currency.uppercase(), trip.currency.uppercase(),

View File

@@ -6,11 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.insertSeparators
import androidx.paging.map import androidx.paging.map
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.CategoryRepository import cc.n0th1ng.tripmoney.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
@@ -18,15 +20,16 @@ import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVPrinter import org.apache.commons.csv.CSVPrinter
import java.io.File import java.io.File
import java.time.LocalDateTime import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.mapValues
@HiltViewModel @HiltViewModel
@@ -37,15 +40,63 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
private val tripRepo: TripRepository private val tripRepo: TripRepository
) : ViewModel() { ) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> = fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpensesPaged(tripId).cachedIn(viewModelScope) expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope)
fun save(expense: Expense) { @RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithHeadersPaged(
tripId: Int
): Flow<PagingData<ExpenseListItemUi>> {
val pagingFlow = getExpensesDtoPaged(tripId)
val sumsFlow = getDailySums(tripId)
val tripFlow = tripRepo.getTrip(tripId)
return combine(pagingFlow, sumsFlow, tripFlow) { pagingData, sums, trip ->
val currency = trip?.currency ?: ""
pagingData
.map<ExpenseDto, ExpenseListItemUi> {
ExpenseListItemUi.Item(it)
}
.insertSeparators { before, after ->
if (after == null) return@insertSeparators null
val afterItem = after as ExpenseListItemUi.Item
val afterDate = afterItem.expenseDto.expense.datetime.toLocalDate()
val beforeDate = (before as? ExpenseListItemUi.Item)
?.expenseDto
?.expense
?.datetime
?.toLocalDate()
if (before == null || beforeDate != afterDate) {
ExpenseListItemUi.Header(
date = afterDate,
sum = sums[afterDate] ?: 0.0,
currency = currency
)
} else {
null
}
}
}.cachedIn(viewModelScope)
}
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> =
expenseRepo.getExpensesDto(tripId)
@RequiresApi(Build.VERSION_CODES.O)
fun save(expense: Expense, trip: Trip) {
viewModelScope.launch { viewModelScope.launch {
expenseRepo.save(expense) val rate = exchangeRateRepository.getRate(
Currencies.valueOf(expense.currency),
Currencies.valueOf(trip.currency),
expense.datetime.toLocalDate()
)
expenseRepo.save(expense.copy(rate = rate))
} }
} }
fun delete(expense: Expense) { fun delete(expense: Expense) {
viewModelScope.launch { viewModelScope.launch {
expenseRepo.delete(expense) expenseRepo.delete(expense)
@@ -67,7 +118,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
writer, writer,
CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount") CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount")
).use { printer -> ).use { printer ->
expenseRepo.getExpenses(tripId).first().forEach { expenseDto -> expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto ->
printer.printRecord( printer.printRecord(
expenseDto.expense.datetime, expenseDto.expense.datetime,
expenseDto.category.name, expenseDto.category.name,
@@ -80,68 +131,45 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
} }
} }
@RequiresApi(Build.VERSION_CODES.O)
fun getDailySums(tripId: Int): Flow<Map<LocalDate, Double>> {
return getExpensesDto(tripId)
.map { expenses ->
expenses.groupBy { it.expense.datetime.toLocalDate() }
.mapValues { (_, list) ->
list.sumOf { it.expense.amount * it.expense.rate }
}
}
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getSummaryAmount(tripId: Int): Flow<Double> { fun getSummaryAmount(tripId: Int): Flow<Double> {
return getExpensesWithConvertedAmounts(tripId).map { list -> return getExpensesDto(tripId).map { list ->
list.sumOf { it.convertedAmount } list.sumOf { it.expense.amount * it.expense.rate }
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> { fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> {
val tripCurrency = tripRepo.getTrip(tripId)?.currency ?: Currencies.default().name val tripFlow = tripRepo.getTrip(tripId)
return getExpensesWithConvertedAmounts(tripId) val expensesFlow = getExpensesDto(tripId)
.map { list ->
val sumOfAll = list.sumOf { it.convertedAmount }
list.groupBy { it.expenseDto.category }
.map { (category, expenses) ->
val total = expenses.sumOf { it.convertedAmount }
SummaryPerCategory(
category = category,
amount = total,
percent = (total / sumOfAll).toFloat(),
currency = Currencies.valueOf(tripCurrency)
)
}.sortedBy { it.percent }.reversed()
}
}
return tripFlow.combine(expensesFlow) { trip, expenses ->
val tripCurrency = trip?.currency ?: Currencies.default().name
val sumOfAll = expenses.sumOf { it.expense.convertedAmount() }
@RequiresApi(Build.VERSION_CODES.O) expenses.groupBy { it.category }
fun getExpensesWithConvertedAmounts(tripId: Int): Flow<List<ExpenseDtoWithConvertedAmount>> { .map { (category, expensesForCategory) ->
return expenseRepo.getExpenses(tripId) val total = expensesForCategory.sumOf { it.expense.convertedAmount() }
.map { list -> SummaryPerCategory(
list.map { expenseDto -> category = category,
val convertedAmount = amount = total,
if (expenseDto.expense.currency != expenseDto.trip.currency) { percent = (total / sumOfAll).toFloat(),
runBlocking { currency = Currencies.valueOf(tripCurrency)
expenseDto.convertedAmount() )
}
} else {
expenseDto.expense.amount
}
ExpenseDtoWithConvertedAmount(expenseDto, convertedAmount)
} }
} .sortedByDescending { it.percent }
} }
@RequiresApi(Build.VERSION_CODES.O)
fun getExpensesWithConvertedAmountsPaged(tripId: Int): Flow<PagingData<ExpenseDtoWithConvertedAmount>> {
return expenseRepo.getExpensesPaged(tripId)
.map { pagingData ->
pagingData.map { expenseDto ->
val convertedAmount =
if (expenseDto.expense.currency != expenseDto.trip.currency) {
runBlocking {
expenseDto.convertedAmount()
}
} else {
expenseDto.expense.amount
}
ExpenseDtoWithConvertedAmount(expenseDto, convertedAmount)
}
}
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@@ -152,16 +180,9 @@ open class ExpenseAndCategoryViewModel @Inject constructor(
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
suspend fun ExpenseDto.convertedAmount(): Double { sealed class ExpenseListItemUi {
return exchangeRateRepository.getRate( data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi()
Currencies.valueOf(this.expense.currency), data class Header(val date: LocalDate, val sum: Double, val currency: String) : ExpenseListItemUi()
Currencies.valueOf(this.trip.currency),
LocalDateTime.parse(this.expense.datetime).toLocalDate()
) * this.expense.amount
} }
data class ExpenseDtoWithConvertedAmount(
val expenseDto: ExpenseDto,
val convertedAmount: Double
)
} }

View File

@@ -18,7 +18,7 @@ class TripViewModel @Inject constructor(private val repository: TripRepository)
fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope) fun getTrips(): Flow<PagingData<Trip>> = repository.getTrips().cachedIn(viewModelScope)
fun getTrip(tripId: Int): Trip? = repository.getTrip(tripId) fun getTrip(tripId: Int): Flow<Trip?> = repository.getTrip(tripId)
fun delete(trip: Trip) { fun delete(trip: Trip) {
viewModelScope.launch { viewModelScope.launch {