init
This commit is contained in:
@@ -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
|
||||
|
||||
41
app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt
Normal file
41
app/src/main/java/cc/n0th1ng/tripmoney/data/Converters.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
}
|
||||
|
||||
@@ -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?>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?: "") }
|
||||
@@ -199,7 +191,9 @@ fun AddExpenseBottomSheet(
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -246,7 +240,6 @@ fun AddExpenseBottomSheet(
|
||||
onOperatorClick = { operator ->
|
||||
if (amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) {
|
||||
amount = evaluate(amount).toString()
|
||||
// equationResult = 0.0
|
||||
}
|
||||
val newText = amount + operator
|
||||
if (newText.isDoubleTwoDigitsOrEquation()) {
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
if (items.loadState.refresh == LoadState.Loading) {
|
||||
// Show loading indicator
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
sumMap.clear()
|
||||
sumMap.putAll(newSums)
|
||||
}
|
||||
else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = listState
|
||||
) {
|
||||
items(
|
||||
count = expensesWithConverted.itemCount,
|
||||
key = { index -> expensesWithConverted[index]?.expenseDto?.expense?.id ?: index }
|
||||
count = items.itemCount,
|
||||
key = items.itemKey { item ->
|
||||
when (item) {
|
||||
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
|
||||
is ExpenseListItemUi.Header -> "header_${item.date}"
|
||||
}
|
||||
}
|
||||
) { 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) {
|
||||
|
||||
when (val item = items[index]) {
|
||||
|
||||
is ExpenseListItemUi.Header -> {
|
||||
CustomDivider(
|
||||
expenseDto,
|
||||
sumMap.getOrDefault(
|
||||
LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate(), 0.00
|
||||
)
|
||||
date = item.date,
|
||||
sum = item.sum,
|
||||
currency = item.currency
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
|
||||
is ExpenseListItemUi.Item -> {
|
||||
SwipeToDeleteExpenseCard(
|
||||
expenseDtoWithConverted = expenseDtoWithConverted,
|
||||
onDelete = { expense -> onDeleteExpense(expense) },
|
||||
expenseDto = item.expenseDto,
|
||||
onDelete = { expense -> itemToDelete = expense },
|
||||
onClick = { expenseDto ->
|
||||
expenseDtoToEdit = expenseDto
|
||||
showBottomSheet = true
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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,38 +233,22 @@ 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
|
||||
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
|
||||
onDelete(expenseDto.expense)
|
||||
false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
if (showDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
onConfirm = {
|
||||
showDialog = false
|
||||
dismissed = true
|
||||
onDelete(expenseDtoWithConverted.expenseDto.expense)
|
||||
},
|
||||
onCancel = { showDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
SwipeToDismissBox(
|
||||
modifier = Modifier,
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = {
|
||||
@@ -265,12 +260,17 @@ fun SwipeToDeleteExpenseCard(
|
||||
.padding(horizontal = 20.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(string.delete))
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
ExpenseCard(expenseDtoWithConverted, onClick = onClick)
|
||||
}
|
||||
ExpenseCard(
|
||||
expenseDto = expenseDto,
|
||||
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
|
||||
//}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,67 +131,44 @@ 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 }
|
||||
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() }
|
||||
|
||||
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)
|
||||
)
|
||||
}.sortedBy { it.percent }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user