init #48

Merged
admin merged 18 commits from develop into main 2026-04-30 10:35:58 +02:00
18 changed files with 488 additions and 388 deletions
Showing only changes of commit 9b19b100e9 - Show all commits

View File

@@ -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

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.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
)

View File

@@ -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<Int, ExpenseDto>
@@ -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<List<ExpenseDto>>
@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<List<SummaryPerCategoryRaw>>
}

View File

@@ -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<Trip?>
}

View File

@@ -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,

View File

@@ -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
)
){
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)
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) {

View File

@@ -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<PagingData<ExpenseDto>> {
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
).flow
}
fun getExpenses(tripId: Int): Flow<List<ExpenseDto>> {
fun getExpensesDto(tripId: Int): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId)
}
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategoryRaw>> {
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)
)
}
}
}

View File

@@ -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<Trip?> {
return tripDao.trip(tripId)
}

View File

@@ -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()
),
)
)

View File

@@ -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<PagingData<ExpenseDtoWithConvertedAmount>>,
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
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<LocalDate, Double>() }
var expenseDtoToEdit by remember { mutableStateOf<ExpenseDto?>(null) }
var itemToDelete by remember { mutableStateOf<Expense?>(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<ExpenseDtoWithConvertedAmount> {
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<ExpenseDtoWithConvertedAmount> = 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
}
//@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<ExpenseDto> {
// 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<ExpenseDto> = 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
//}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()
)

View File

@@ -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(),

View File

@@ -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<PagingData<ExpenseDto>> =
expenseRepo.getExpensesPaged(tripId).cachedIn(viewModelScope)
fun getExpensesDtoPaged(tripId: Int): Flow<PagingData<ExpenseDto>> =
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 {
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<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)
fun getSummaryAmount(tripId: Int): Flow<Double> {
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<List<SummaryPerCategory>> {
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<List<ExpenseDtoWithConvertedAmount>> {
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<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)
}
}
.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
)
}

View File

@@ -18,7 +18,7 @@ class TripViewModel @Inject constructor(private val repository: TripRepository)
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) {
viewModelScope.launch {