From 9b19b100e96191d4acdc33951b57ab843b33aea4 Mon Sep 17 00:00:00 2001 From: Rafal Wisniewski <2krafal.wisniewski@gmail.com> Date: Thu, 26 Mar 2026 22:38:51 +0100 Subject: [PATCH] init --- .../java/cc/n0th1ng/tripmoney/MainActivity.kt | 3 +- .../cc/n0th1ng/tripmoney/data/Converters.kt | 41 ++ .../cc/n0th1ng/tripmoney/data/TripDatabase.kt | 43 +- .../n0th1ng/tripmoney/data/dao/ExpenseDao.kt | 26 +- .../cc/n0th1ng/tripmoney/data/dao/TripDao.kt | 3 +- .../n0th1ng/tripmoney/data/entity/Expense.kt | 12 +- .../cc/n0th1ng/tripmoney/data/entity/Trip.kt | 13 +- .../data/repository/ExchangeRateRepository.kt | 1 + .../data/repository/ExpenseRepository.kt | 31 +- .../data/repository/TripRepository.kt | 12 +- .../addexpense/AddExpenseBottomSheet.kt | 77 +-- .../screens/listexpense/ListExpenseScreen.kt | 440 +++++++++--------- .../screens/settings/SettingsScreen.kt | 3 +- .../screens/statistics/StatisticsScreen.kt | 2 +- .../screens/trippicker/AddTripBottomSheet.kt | 6 +- .../screens/trippicker/TripPickerScreen.kt | 2 +- .../viewmodel/ExpenseAndCategoryViewModel.kt | 159 ++++--- .../tripmoney/viewmodel/TripViewModel.kt | 2 +- 18 files changed, 488 insertions(+), 388 deletions(-) create mode 100644 app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt b/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt index 6ae412f..7cc20c8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/MainActivity.kt @@ -20,6 +20,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.navigation.BottomNavigation import cc.n0th1ng.tripmoney.navigation.CustomNavigationDrawer import cc.n0th1ng.tripmoney.navigation.Screens @@ -59,7 +60,7 @@ fun NavigationDrawer() { val settingsViewModel: SettingsViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() val currentTripId by settingsViewModel.currentTrip.collectAsState() - val currentTrip = tripViewModel.getTrip(currentTripId) + val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val navController = rememberNavController() val navBackStack by navController.currentBackStackEntryAsState() val current = navBackStack?.destination?.route diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt new file mode 100644 index 0000000..81682db --- /dev/null +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt index 3cfaef2..bc99cb8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/TripDatabase.kt @@ -6,6 +6,7 @@ import androidx.annotation.RequiresApi 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 @@ -17,6 +18,7 @@ import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Trip 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 @@ -25,11 +27,13 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.time.LocalDate import java.time.LocalDateTime import javax.inject.Inject import javax.inject.Singleton @Database(entities = [Trip::class, Expense::class, Category::class, ExchangeRate::class], version = 1) +@TypeConverters(Converters::class) abstract class TripDatabase : RoomDatabase() { abstract fun tripDao(): TripDao abstract fun expenseDao(): ExpenseDao @@ -46,7 +50,8 @@ object DatabaseModule { @Provides @Singleton fun provideTripDatabase( - @ApplicationContext context: Context + @ApplicationContext context: Context, +// expenseAndCategoryViewModel: ExpenseAndCategoryViewModel ): TripDatabase { val db: TripDatabase = Room.inMemoryDatabaseBuilder( @@ -94,9 +99,9 @@ private class DatabasePrepopulator( ) { @RequiresApi(Build.VERSION_CODES.O) suspend fun prepopulate() { - tripDao.insert(Trip(name = "Włochy", startDate = "2026-03-01", currency = "PLN")) - tripDao.insert(Trip(name = "Szwajcaria", startDate = "2025-03-01", currency = "EUR")) - tripDao.insert(Trip(name = "Portugalia", startDate = "2025-03-01", currency = "USD")) + 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())) @@ -113,7 +118,7 @@ private class DatabasePrepopulator( amount = 120.50, currency = "PLN", note = "Hotel overnight", - datetime = now.minusDays(10).toString(), + datetime = now.minusDays(10), categoryId = 1, tripId = 1 ) @@ -123,7 +128,7 @@ private class DatabasePrepopulator( amount = 45.75, currency = "PLN", note = "Dinner", - datetime = now.minusDays(9).toString(), + datetime = now.minusDays(9), categoryId = 2, tripId = 1 ) @@ -133,7 +138,7 @@ private class DatabasePrepopulator( amount = 15.20, currency = "PLN", note = "Bus ticket", - datetime = now.minusDays(8).toString(), + datetime = now.minusDays(8), categoryId = 3, tripId = 1 ) @@ -143,7 +148,7 @@ private class DatabasePrepopulator( amount = 89.99, currency = "PLN", note = "Concert tickets", - datetime = now.minusDays(7).toString(), + datetime = now.minusDays(7), categoryId = 4, tripId = 1 ) @@ -153,7 +158,7 @@ private class DatabasePrepopulator( amount = 32.50, currency = "PLN", note = "Souvenirs", - datetime = now.minusDays(6).toString(), + datetime = now.minusDays(6), categoryId = 5, tripId = 1 ) @@ -163,7 +168,7 @@ private class DatabasePrepopulator( amount = 180.00, currency = "PLN", note = "Hotel 3 nights", - datetime = now.minusDays(5).toString(), + datetime = now.minusDays(5), categoryId = 1, tripId = 1 ) @@ -173,7 +178,7 @@ private class DatabasePrepopulator( amount = 67.30, currency = "PLN", note = "Lunch", - datetime = now.minusDays(4).toString(), + datetime = now.minusDays(4), categoryId = 2, tripId = 1 ) @@ -183,7 +188,7 @@ private class DatabasePrepopulator( amount = 22.00, currency = "PLN", note = "Train ticket", - datetime = now.minusDays(3).toString(), + datetime = now.minusDays(3), categoryId = 3, tripId = 1 ) @@ -193,7 +198,7 @@ private class DatabasePrepopulator( amount = 55.00, currency = "PLN", note = "Museum entry", - datetime = now.minusDays(2).toString(), + datetime = now.minusDays(2), categoryId = 4, tripId = 1 ) @@ -203,7 +208,7 @@ private class DatabasePrepopulator( amount = 12.99, currency = "PLN", note = "Snacks", - datetime = now.minusDays(1).toString(), + datetime = now.minusDays(1), categoryId = 2, tripId = 1 ) @@ -213,7 +218,7 @@ private class DatabasePrepopulator( amount = 210.00, currency = "PLN", note = "Hotel 5 nights", - datetime = now.toString(), + datetime = now, categoryId = 1, tripId = 1 ) @@ -223,7 +228,7 @@ private class DatabasePrepopulator( amount = 95.50, currency = "EUR", note = "Dinner for two", - datetime = now.minusHours(12).toString(), + datetime = now.minusHours(12), categoryId = 2, tripId = 1 ) @@ -233,7 +238,7 @@ private class DatabasePrepopulator( amount = 30.00, currency = "EUR", note = "Taxi", - datetime = now.minusHours(6).toString(), + datetime = now.minusHours(6), categoryId = 3, tripId = 1 ) @@ -243,7 +248,7 @@ private class DatabasePrepopulator( amount = 40.00, currency = "USD", note = "Gifts", - datetime = now.minusHours(3).toString(), + datetime = now.minusHours(3), categoryId = 5, tripId = 1 ) @@ -253,7 +258,7 @@ private class DatabasePrepopulator( amount = 75.00, currency = "PLN", note = "Sightseeing tour", - datetime = now.minusHours(1).toString(), + datetime = now.minusHours(1), categoryId = 4, tripId = 1 ) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt index f8d9a7a..1b803b8 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/ExpenseDao.kt @@ -19,7 +19,7 @@ interface ExpenseDao { @Query( """ SELECT * FROM expense WHERE trip_id = :tripId - ORDER BY DATETIME(expense.datetime) DESC + ORDER BY expense.datetime DESC """ ) fun expenseDtoPaged(tripId: Int): PagingSource @@ -28,33 +28,11 @@ interface ExpenseDao { @Query( """ SELECT * FROM expense WHERE trip_id = :tripId - ORDER BY DATETIME(expense.datetime) DESC + ORDER BY expense.datetime DESC """ ) fun expenseDto(tripId: Int): Flow> @Delete 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> - } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt index e07726b..cbd5c7e 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/dao/TripDao.kt @@ -7,6 +7,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Upsert import cc.n0th1ng.tripmoney.data.entity.Trip +import kotlinx.coroutines.flow.Flow @Dao interface TripDao { @@ -27,5 +28,5 @@ interface TripDao { @Query( "SELECT * FROM trip where trip.id = :tripId" ) - fun trip(tripId: Int): Trip? + fun trip(tripId: Int): Flow } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt index 3ce313c..e330fb4 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Expense.kt @@ -5,6 +5,7 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.Relation +import java.time.LocalDateTime @Entity(tableName = "expense") data class Expense( @@ -12,10 +13,15 @@ data class Expense( @ColumnInfo("amount") val amount: Double, @ColumnInfo("currency") val currency: String, @ColumnInfo("note") val note: String, - @ColumnInfo("datetime") val datetime: String, + @ColumnInfo("datetime") val datetime: LocalDateTime, @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( @Embedded val expense: Expense, diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt index 678b372..c211c5a 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/entity/Trip.kt @@ -1,13 +1,22 @@ package cc.n0th1ng.tripmoney.data.entity +import android.os.Build +import androidx.annotation.RequiresApi import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import cc.n0th1ng.tripmoney.utils.Currencies +import java.time.LocalDate @Entity(tableName = "trip") data class Trip( @PrimaryKey(autoGenerate = true) val id: Int = 0, @ColumnInfo("name") val name: String, - @ColumnInfo("start_date") val startDate: String, + @ColumnInfo("start_date") val startDate: LocalDate, @ColumnInfo("currency") val currency: String -) \ No newline at end of file +){ + companion object { + @RequiresApi(Build.VERSION_CODES.O) + val DUMMY = Trip(-1, "dummy", LocalDate.now(), Currencies.default().name) + } +} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt index 98c73e4..a4a62a0 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExchangeRateRepository.kt @@ -25,6 +25,7 @@ class ExchangeRateRepository @Inject constructor( @RequiresApi(Build.VERSION_CODES.O) 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 cachedRate = exchangeRateDao.getById(id) return if (cachedRate != null) { diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt index 74e48bf..2625cf1 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/ExpenseRepository.kt @@ -1,6 +1,9 @@ package cc.n0th1ng.tripmoney.data.repository +import android.os.Build +import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread +import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig 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.entity.Expense import cc.n0th1ng.tripmoney.data.entity.ExpenseDto +import cc.n0th1ng.tripmoney.utils.Currencies import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch 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 suspend fun save(expense: Expense) { @@ -23,18 +32,30 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao) expenseDao.delete(expense) } - fun getExpensesPaged(tripId: Int): Flow> { + fun getExpensesDtoPaged(tripId: Int): Flow> { return Pager( config = PagingConfig(pageSize = 50, enablePlaceholders = false), pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) } ).flow } - fun getExpenses(tripId: Int): Flow> { + fun getExpensesDto(tripId: Int): Flow> { return expenseDao.expenseDto(tripId) } - fun getSummaryPerCategory(tripId: Int): Flow> { - return expenseDao.summaryPerCategoryRaw(tripId) + @RequiresApi(Build.VERSION_CODES.O) + 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) + ) + } } + } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt index f2456bc..70b29ae 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/data/repository/TripRepository.kt @@ -1,18 +1,26 @@ package cc.n0th1ng.tripmoney.data.repository +import android.os.Build +import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.paging.Pager import androidx.paging.PagingConfig 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.flow.Flow 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 suspend fun save(trip: Trip) { + expenseRepository.recalculateTripExpenses(trip.id) tripDao.insert(trip) } @@ -23,7 +31,7 @@ class TripRepository @Inject constructor(private val tripDao: TripDao) { ).flow } - fun getTrip(tripId: Int): Trip? { + fun getTrip(tripId: Int): Flow { return tripDao.trip(tripId) } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt index c1802d6..c956796 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt @@ -1,43 +1,38 @@ package cc.n0th1ng.tripmoney.screens.addexpense import android.annotation.SuppressLint -import android.graphics.drawable.PaintDrawable import android.os.Build +import android.util.Log import androidx.annotation.RequiresApi -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DividerDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout -import androidx.compose.ui.modifier.modifierLocalMapOf -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.sensitiveContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType 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.sp import androidx.core.graphics.toColorInt @@ -83,10 +74,12 @@ import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import com.composables.icons.materialsymbols.outlined.R.drawable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import kotlin.collections.listOf @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.O) @@ -101,14 +94,15 @@ fun AddExpenseBottomSheet( val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel() 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()) AddExpenseBottomSheet( onSave = onSave, onDismiss = onDismiss, expenseDtoToEdit = expenseDtoToEdit, state = state, - currentTrip = currentTrip, + currentTrip = currentTrip!!, categories = categories ) } @@ -148,9 +142,7 @@ fun AddExpenseBottomSheet( var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) } var datetime by remember { mutableStateOf( - LocalDateTime.parse( - expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now().toString() - ) + expenseDtoToEdit?.expense?.datetime ?: LocalDateTime.now() ) } var note by remember { mutableStateOf(expenseDtoToEdit?.expense?.note ?: "") } @@ -192,14 +184,16 @@ fun AddExpenseBottomSheet( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Column{ + Column { Text( text = amount.ifEmpty { "0.00" }, fontSize = 25.sp, fontWeight = FontWeight.Bold ) Text( - text = if(amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(equationResult) else "", + text = if (amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format( + equationResult + ) else "", fontSize = 14.sp, ) } @@ -244,12 +238,11 @@ fun AddExpenseBottomSheet( NumberKeyboard( modifier = Modifier.fillMaxWidth(), onOperatorClick = { operator -> - if(amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) { + if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) { amount = evaluate(amount).toString() -// equationResult = 0.0 } val newText = amount + operator - if(newText.isDoubleTwoDigitsOrEquation()) { + if (newText.isDoubleTwoDigitsOrEquation()) { amount = newText enableSave = false } @@ -282,7 +275,7 @@ fun AddExpenseBottomSheet( amount = equationResult, currency = currency, note = note, - datetime = datetime.toString(), + datetime = datetime, categoryId = category.id, tripId = currentTripId ) @@ -330,7 +323,7 @@ fun AddExpenseBottomSheet( fun String.safeSubstring(start: Int, end: Int): String { return try { this.substring(start, end) - } catch (e: Exception) { + } catch (_: Exception) { "0.00" } } @@ -460,7 +453,8 @@ fun NumberKeyboard( "backspace" -> KeyboardButton( icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined), onClick = onBackspaceClick, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f), containerColor = MaterialTheme.colorScheme.primary ) @@ -488,19 +482,20 @@ fun NumberKeyboard( @Composable fun KeyboardButton( + modifier: Modifier = Modifier, text: String? = null, icon: Painter? = null, onClick: () -> Unit, - modifier: Modifier = Modifier, enabled: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.primary, contentColor: Color = MaterialTheme.colorScheme.onPrimary ) { + Button( onClick = onClick, shape = MaterialTheme.shapes.medium, modifier = modifier - .padding(4.dp) + .padding(2.dp) .aspectRatio(2.5f), enabled = enabled, colors = ButtonDefaults.buttonColors( @@ -545,7 +540,12 @@ fun PreviewAddExpenseDisabled() { onDismiss = {}, expenseDtoToEdit = null, 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 ) } @@ -572,13 +572,20 @@ fun PreviewAddExpenseEnabled() { amount = 10.31, currency = "PLN", note = "some note", - datetime = "2025-11-30T10:16:26.939", + datetime = LocalDateTime.now(), categoryId = 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, - 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 ) } @@ -611,4 +618,4 @@ val categoriesToPreview = listOf( icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, color = colors.random() ), -) \ No newline at end of file +) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt index d126310..a709b17 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -40,7 +41,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -53,43 +53,39 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey 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.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.Trip 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.ExpenseDtoWithConvertedAmount +import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel +import cc.n0th1ng.tripmoney.viewmodel.TripViewModel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneOffset import java.time.format.DateTimeFormatter -import kotlin.random.Random @RequiresApi(Build.VERSION_CODES.O) @Composable fun ListExpenseScreen() { 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 expensesWithConvertedFlow = expenseAndCategoryViewModel - .getExpensesWithConvertedAmountsPaged(currentTrip) + val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId) ListExpenseScreen( - expensesWithConvertedFlow = expensesWithConvertedFlow, - onSaveExpense = { expenseAndCategoryViewModel.save(it) }, - onDeleteExpense = { expenseAndCategoryViewModel.delete(it) }) + expensesFlow = expensesFlow, + onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) }, + onDeleteExpense = { expenseAndCategoryViewModel.delete(it) }, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -97,14 +93,15 @@ fun ListExpenseScreen() { @RequiresApi(Build.VERSION_CODES.O) @Composable fun ListExpenseScreen( - expensesWithConvertedFlow: Flow>, + expensesFlow: Flow>, onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit ) { - val expensesWithConverted = expensesWithConvertedFlow.collectAsLazyPagingItems() + + val items = expensesFlow.collectAsLazyPagingItems() val listState = rememberLazyListState() var showBottomSheet by remember { mutableStateOf(false) } - var expenseDtoToEdit: ExpenseDto? = null - val sumMap = remember { mutableStateMapOf() } + var expenseDtoToEdit by remember { mutableStateOf(null) } + var itemToDelete by remember { mutableStateOf(null) } Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { ExtendedFloatingActionButton( @@ -114,57 +111,71 @@ fun ListExpenseScreen( ) }) { - LaunchedEffect(expensesWithConverted.itemSnapshotList.items) { - val items = expensesWithConverted.itemSnapshotList.items - val newSums = items - .groupBy { LocalDateTime.parse(it.expenseDto.expense.datetime).toLocalDate() } - .mapValues { (_, expensesForDay) -> - expensesForDay.sumOf { it.convertedAmount } - } - sumMap.clear() - sumMap.putAll(newSums) + if (items.loadState.refresh == LoadState.Loading) { + // Show loading indicator + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - state = listState - ) { - items( - count = expensesWithConverted.itemCount, - key = { index -> expensesWithConverted[index]?.expenseDto?.expense?.id ?: index } - ) { index -> - val expenseDtoWithConverted = expensesWithConverted[index] - val expenseDto = expenseDtoWithConverted?.expenseDto - if (expenseDtoWithConverted != null && expenseDto != null) { - val previousExpense = - expensesWithConverted.itemSnapshotList.items.getOrNull(index - 1)?.expenseDto - val showDayDivider = - index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime) - .toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime) - .toLocalDate() - Spacer(Modifier - .height(5.dp) - .background(MaterialTheme.colorScheme.onBackground)) - if (showDayDivider) { - CustomDivider( - expenseDto, - sumMap.getOrDefault( - LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate(), 0.00 - ) - ) + else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState + ) { + items( + count = items.itemCount, + key = items.itemKey { item -> + when (item) { + is ExpenseListItemUi.Item -> item.expenseDto.expense.id + is ExpenseListItemUi.Header -> "header_${item.date}" + } + } + ) { index -> + + when (val item = items[index]) { + + is ExpenseListItemUi.Header -> { + CustomDivider( + date = item.date, + sum = item.sum, + currency = item.currency + ) + } + + 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) { AddExpenseBottomSheet( onSave = { expense -> @@ -186,7 +197,7 @@ fun ListExpenseScreen( @RequiresApi(Build.VERSION_CODES.O) @Composable -fun CustomDivider(expenseDto: ExpenseDto, sum: Double) { +fun CustomDivider(date: LocalDate, sum: Double, currency: String) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Absolute.Center, @@ -194,7 +205,7 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) { ) { HorizontalDivider(modifier = Modifier.weight(1f)) Text( - LocalDateTime.parse(expenseDto.expense.datetime).format( + date.format( DateTimeFormatter.ofPattern("dd EEEE") ).toString(), modifier = Modifier.background(Color.White.copy(alpha = 0f)), @@ -209,7 +220,7 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) { ) { HorizontalDivider(modifier = Modifier.weight(2f)) Text( - "%.2f %s".format(sum, expenseDto.trip.currency), + "%.2f %s".format(sum, currency), modifier = Modifier.background(Color.White.copy(alpha = 0f)), style = MaterialTheme.typography.bodyMedium ) @@ -222,55 +233,44 @@ fun CustomDivider(expenseDto: ExpenseDto, sum: Double) { @RequiresApi(Build.VERSION_CODES.O) @Composable fun SwipeToDeleteExpenseCard( - expenseDtoWithConverted: ExpenseDtoWithConvertedAmount, + expenseDto: ExpenseDto, onDelete: (Expense) -> Unit, onClick: (ExpenseDto) -> Unit ) { - var dismissed by remember { mutableStateOf(false) } - var showDialog by remember { mutableStateOf(false) } - - if (!dismissed) { - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { dismissValue -> - if (dismissValue == SwipeToDismissBoxValue.EndToStart - ) { - showDialog = true - false - } else { - false - } + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.EndToStart) { + onDelete(expenseDto.expense) + 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) @Composable fun ExpenseCard( - expenseDtoWithConverted: ExpenseDtoWithConvertedAmount, + expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit ) { - val expenseDto = expenseDtoWithConverted.expenseDto ElevatedCard( colors = CardDefaults.elevatedCardColors() .copy(containerColor = MaterialTheme.colorScheme.secondaryContainer), @@ -379,7 +378,7 @@ fun ExpenseCard( } Text( - text = LocalDateTime.parse(expenseDto.expense.datetime).format( + text = expenseDto.expense.datetime.format( DateTimeFormatter.ofPattern("dd MMM HH:mm") ), style = MaterialTheme.typography.labelSmall, @@ -397,7 +396,7 @@ fun ExpenseCard( ) if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) { Text( - text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDtoWithConverted.convertedAmount), + text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer ) @@ -408,106 +407,107 @@ fun ExpenseCard( } } -@RequiresApi(Build.VERSION_CODES.O) -@AllPreviews -@Composable -fun PreviewListExpenseScreen() { - TripMoneyTheme() { - val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) - ListExpenseScreen( - expensesWithConvertedFlow = MutableStateFlow(pagingData), - onSaveExpense = {}, - onDeleteExpense = {} - ) - - } -} - -@AllPreviews -@Composable -fun PreviewDeleteConfirmationDialog() { - TripMoneyTheme() { - DeleteConfirmationDialog( - onConfirm = {}, - onCancel = {}) - } -} - - -@RequiresApi(Build.VERSION_CODES.O) -private fun sampleExpenseDtoWithConvertedAmountList(): List { - val sampleCategories = listOf( - Category( - name = "Hotel", - icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, - color = colors.random() - ), - Category( - name = "Jedzenie", - icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, - color = colors.random() - ), - Category( - name = "Transport", - icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, - color = colors.random() - ), - Category( - name = "Rozrywka", - icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, - color = colors.random() - ), - Category( - name = "Zakupy", - icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, - color = colors.random() - ), - ) - - val trip = Trip( - id = 1, - name = "Vacation", - currency = "USD", - startDate = "2026-01-01" - ) - - val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() - val endLong = LocalDateTime.now().toEpochMilli() - - val result: MutableList = mutableListOf() - for (i in 0..15) { - val category = sampleCategories.random() - val datetime = if (i > 4) { - LocalDateTime.ofEpochSecond( - Random.nextLong(startLong, endLong), - 0, - ZoneOffset.UTC - ).toString() - } else LocalDateTime.now().toString() - - val expense = Expense( - id = i, - categoryId = category.id, - tripId = 1, - amount = Random.nextDouble(0.1, 300.0), - currency = Currencies.entries.random().name, - note = if (i % 3 == 0) "Some note" else "", - datetime = datetime - ) - val expenseDto = ExpenseDto( - expense = expense, - category = category, - trip = trip - ) - result.add( - ExpenseDtoWithConvertedAmount( - expenseDto, - convertedAmount = if (Random.nextBoolean()) Random.nextDouble( - 0.1, - 300.0 - ) else expense.amount - ) - ) - } - return result -} \ No newline at end of file +//@RequiresApi(Build.VERSION_CODES.O) +//@AllPreviews +//@Composable +//fun PreviewListExpenseScreen() { +// TripMoneyTheme() { +// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList()) +// ListExpenseScreen( +// expensesDtoFlow = MutableStateFlow(pagingData), +// onSaveExpense = {}, +// onDeleteExpense = {}, +// dailySums = emptyMap() +// ) +// +// } +//} +// +//@AllPreviews +//@Composable +//fun PreviewDeleteConfirmationDialog() { +// TripMoneyTheme() { +// DeleteConfirmationDialog( +// onConfirm = {}, +// onCancel = {}) +// } +//} +// +// +//@RequiresApi(Build.VERSION_CODES.O) +//private fun sampleExpenseDtoWithConvertedAmountList(): List { +// val sampleCategories = listOf( +// Category( +// name = "Hotel", +// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, +// color = colors.random() +// ), +// Category( +// name = "Jedzenie", +// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, +// color = colors.random() +// ), +// Category( +// name = "Transport", +// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, +// color = colors.random() +// ), +// Category( +// name = "Rozrywka", +// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, +// color = colors.random() +// ), +// Category( +// name = "Zakupy", +// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, +// color = colors.random() +// ), +// ) +// +// val trip = Trip( +// id = 1, +// name = "Vacation", +// currency = "USD", +// startDate = LocalDate.parse("2026-01-01") +// ) +// +// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli() +// val endLong = LocalDateTime.now().toEpochMilli() +// +// val result: MutableList = mutableListOf() +// for (i in 0..15) { +// val category = sampleCategories.random() +// val datetime = if (i > 4) { +// LocalDateTime.ofEpochSecond( +// Random.nextLong(startLong, endLong), +// 0, +// ZoneOffset.UTC +// ) +// } else LocalDateTime.now() +// +// val expense = Expense( +// id = i, +// categoryId = category.id, +// 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 +// ) +// +// +// val expenseDto = ExpenseDto( +// expense = expense, +// category = category, +// trip = trip +// ) +// result.add( +// expenseDto +// ) +// } +// return result +//} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt index 50aa428..2d73a2e 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/settings/SettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import cc.n0th1ng.tripmoney.R.* +import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.repository.AppTheme import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.theme.TripMoneyTheme @@ -66,7 +67,7 @@ fun SettingsScreen() { val currentTripId by settingsViewModel.currentTrip.collectAsState() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() - val currentTrip = tripViewModel.getTrip(currentTripId) + val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY) val context = LocalContext.current val tripName = currentTrip?.name ?: "" val scope = rememberCoroutineScope() diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt index cdca6da..44710d7 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt @@ -50,7 +50,7 @@ fun StatisticsScreen() { val settingsViewModel: SettingsViewModel = hiltViewModel() val tripViewModel: TripViewModel = hiltViewModel() 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) .collectAsState(emptyList()) val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt index e4474f1..c00c932 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt @@ -94,7 +94,7 @@ fun AddTripBottomSheet( var name by remember { mutableStateOf(tripToEdit?.name ?: "") } var startDate by remember { mutableStateOf( - LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString()) + tripToEdit?.startDate ?: LocalDate.now() ) } @@ -157,7 +157,7 @@ fun AddTripBottomSheet( shape = MaterialTheme.shapes.medium, onClick = { 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)) }) { @@ -231,7 +231,7 @@ fun PreviewAddTripBottomSheetEditTrip() { AddTripBottomSheet( {}, {}, - Trip(1, "Włochy", "2025-01-02", "PLN"), + Trip(1, "Włochy", LocalDate.parse("2025-01-02"), "PLN"), sheetState, defaultCurrency = Currencies.entries.random() ) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt index d9fe28a..e3e69f5 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/TripPickerScreen.kt @@ -206,7 +206,7 @@ fun TripCard( modifier = Modifier.padding(16.dp) ) { Text(fontSize = 25.sp, fontWeight = FontWeight.SemiBold, text = trip.name) - Text(trip.startDate) + Text(trip.startDate.toString()) } Text( trip.currency.uppercase(), diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt index 2ab88d2..ce01167 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt @@ -6,11 +6,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.insertSeparators import androidx.paging.map import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory import cc.n0th1ng.tripmoney.data.entity.Category 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.data.repository.CategoryRepository import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVPrinter import java.io.File -import java.time.LocalDateTime +import java.time.LocalDate import javax.inject.Inject +import kotlin.collections.mapValues @HiltViewModel @@ -37,15 +40,63 @@ open class ExpenseAndCategoryViewModel @Inject constructor( private val tripRepo: TripRepository ) : ViewModel() { - fun getExpenses(tripId: Int): Flow> = - expenseRepo.getExpensesPaged(tripId).cachedIn(viewModelScope) + fun getExpensesDtoPaged(tripId: Int): Flow> = + expenseRepo.getExpensesDtoPaged(tripId).cachedIn(viewModelScope) - fun save(expense: Expense) { + @RequiresApi(Build.VERSION_CODES.O) + fun getExpensesWithHeadersPaged( + tripId: Int + ): Flow> { + 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 { + 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> = + expenseRepo.getExpensesDto(tripId) + + @RequiresApi(Build.VERSION_CODES.O) + fun save(expense: Expense, trip: Trip) { 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) { viewModelScope.launch { expenseRepo.delete(expense) @@ -67,7 +118,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor( writer, CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount") ).use { printer -> - expenseRepo.getExpenses(tripId).first().forEach { expenseDto -> + expenseRepo.getExpensesDto(tripId).first().forEach { expenseDto -> printer.printRecord( expenseDto.expense.datetime, expenseDto.category.name, @@ -80,68 +131,45 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } } + @RequiresApi(Build.VERSION_CODES.O) + fun getDailySums(tripId: Int): Flow> { + 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) fun getSummaryAmount(tripId: Int): Flow { - return getExpensesWithConvertedAmounts(tripId).map { list -> - list.sumOf { it.convertedAmount } + return getExpensesDto(tripId).map { list -> + list.sumOf { it.expense.amount * it.expense.rate } } } @RequiresApi(Build.VERSION_CODES.O) fun getSummaryPerCategory(tripId: Int): Flow> { - val tripCurrency = tripRepo.getTrip(tripId)?.currency ?: Currencies.default().name - return getExpensesWithConvertedAmounts(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() - } - } + val tripFlow = tripRepo.getTrip(tripId) + val expensesFlow = getExpensesDto(tripId) + 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) - fun getExpensesWithConvertedAmounts(tripId: Int): Flow> { - return expenseRepo.getExpenses(tripId) - .map { list -> - list.map { expenseDto -> - val convertedAmount = - if (expenseDto.expense.currency != expenseDto.trip.currency) { - runBlocking { - expenseDto.convertedAmount() - } - } else { - expenseDto.expense.amount - } - ExpenseDtoWithConvertedAmount(expenseDto, convertedAmount) + expenses.groupBy { it.category } + .map { (category, expensesForCategory) -> + val total = expensesForCategory.sumOf { it.expense.convertedAmount() } + SummaryPerCategory( + category = category, + amount = total, + percent = (total / sumOfAll).toFloat(), + currency = Currencies.valueOf(tripCurrency) + ) } - } - } - - @RequiresApi(Build.VERSION_CODES.O) - fun getExpensesWithConvertedAmountsPaged(tripId: Int): Flow> { - 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) - } - } + .sortedByDescending { it.percent } + } } @RequiresApi(Build.VERSION_CODES.O) @@ -152,16 +180,9 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } @RequiresApi(Build.VERSION_CODES.O) - suspend fun ExpenseDto.convertedAmount(): Double { - return exchangeRateRepository.getRate( - Currencies.valueOf(this.expense.currency), - Currencies.valueOf(this.trip.currency), - LocalDateTime.parse(this.expense.datetime).toLocalDate() - ) * this.expense.amount + sealed class ExpenseListItemUi { + data class Item(val expenseDto: ExpenseDto) : ExpenseListItemUi() + data class Header(val date: LocalDate, val sum: Double, val currency: String) : ExpenseListItemUi() } +} - data class ExpenseDtoWithConvertedAmount( - val expenseDto: ExpenseDto, - val convertedAmount: Double - ) -} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt index fdf9ce6..766c7af 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/TripViewModel.kt @@ -18,7 +18,7 @@ class TripViewModel @Inject constructor(private val repository: TripRepository) fun getTrips(): Flow> = repository.getTrips().cachedIn(viewModelScope) - fun getTrip(tripId: Int): Trip? = repository.getTrip(tripId) + fun getTrip(tripId: Int): Flow = repository.getTrip(tripId) fun delete(trip: Trip) { viewModelScope.launch {