This commit is contained in:
Rafal Wisniewski
2026-03-23 20:14:13 +01:00
parent 96cdd056a0
commit 916481e4e3
27 changed files with 960 additions and 154 deletions

View File

@@ -30,7 +30,9 @@ import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen
import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen import cc.n0th1ng.tripmoney.screens.statistics.StatisticsScreen
import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen import cc.n0th1ng.tripmoney.screens.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -43,6 +45,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TripMoneyTheme { TripMoneyTheme {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
expenseAndCategoryViewModel.clearOldRates()
NavigationDrawer() NavigationDrawer()
} }
} }
@@ -53,7 +57,9 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun NavigationDrawer() { fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId)
val navController = rememberNavController() val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route val current = navBackStack?.destination?.route
@@ -65,7 +71,9 @@ fun NavigationDrawer() {
topBar = { topBar = {
if (current == Screens.SETTINGS) TopBarSettings( if (current == Screens.SETTINGS) TopBarSettings(
navController navController
) else TopBar(onClick = { ) else TopBar(
title = currentTrip?.name ?: "",
onClick = {
scope.launch { scope.launch {
if (drawerState.isClosed) { if (drawerState.isClosed) {
drawerState.open() drawerState.open()

View File

@@ -16,6 +16,7 @@ import cc.n0th1ng.tripmoney.data.entity.ExchangeRate
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -96,19 +97,12 @@ private class DatabasePrepopulator(
tripDao.insert(Trip(name = "Włochy", startDate = "2025-01-01", currency = "PLN")) tripDao.insert(Trip(name = "Włochy", startDate = "2025-01-01", currency = "PLN"))
tripDao.insert(Trip(name = "Szwajcaria", startDate = "2025-03-01", currency = "EUR")) tripDao.insert(Trip(name = "Szwajcaria", startDate = "2025-03-01", currency = "EUR"))
tripDao.insert(Trip(name = "Portugalia", startDate = "2026-03-01", currency = "USD")) tripDao.insert(Trip(name = "Portugalia", startDate = "2026-03-01", currency = "USD"))
categoryDao.insert(Category(name = "Hotel", icon = Icons.HOTEL, color = "#B3E5FC")) categoryDao.insert(Category(name = "Hotel", icon = Icons.HOTEL, color = colors.random()))
categoryDao.insert(Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = "#C8E6C9")) categoryDao.insert(Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()))
categoryDao.insert(Category(name = "Transport", icon = Icons.FLIGHT, color = "#FFCDD2")) categoryDao.insert(Category(name = "Transport", icon = Icons.FLIGHT, color = colors.random()))
categoryDao.insert(Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = "#FFF9C4")) categoryDao.insert(Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = colors.random()))
categoryDao.insert(Category(name = "Zakupy", icon = Icons.GROCERIES, color = "#E1BEE7")) categoryDao.insert(Category(name = "Zakupy", icon = Icons.GROCERIES,color = colors.random()))
categoryDao.insert(Category(name = "Zakupy1", icon = Icons.GROCERIES, color = "#D7CCC8"))
categoryDao.insert(Category(name = "Zakupy2", icon = Icons.GROCERIES, color = "#BBDEFB"))
categoryDao.insert(Category(name = "Zakupy3", icon = Icons.GROCERIES, color = "#D1C4E9"))
categoryDao.insert(Category(name = "Zakupy4", icon = Icons.GROCERIES, color = "#DCEDC8"))
categoryDao.insert(Category(name = "Zakupy5", icon = Icons.GROCERIES, color = "#F0F4C3"))
categoryDao.insert(Category(name = "Zakupy6", icon = Icons.GROCERIES, color = "#FFE0B2"))
categoryDao.insert(Category(name = "Zakupy7", icon = Icons.GROCERIES, color = "#D7CCC8"))
categoryDao.insert(Category(name = "Zakupy8", icon = Icons.GROCERIES, color = "#CFD8DC"))
val now = LocalDateTime.now() val now = LocalDateTime.now()
expenseDao.insert( expenseDao.insert(

View File

@@ -3,27 +3,58 @@ package cc.n0th1ng.tripmoney.data.dao
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategoryRaw
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ExpenseDao { interface ExpenseDao {
@Upsert @Upsert
suspend fun insert(expense: Expense) suspend fun insert(expense: Expense)
@Transaction
@Query( @Query(
""" """
SELECT * FROM expense WHERE trip_id = :tripId SELECT * FROM expense WHERE trip_id = :tripId
ORDER BY DATETIME(expense.datetime) DESC ORDER BY DATETIME(expense.datetime) DESC
""" """
) )
fun expenseDto(tripId: Int): PagingSource<Int, ExpenseDto> fun expenseDtoPaged(tripId: Int): PagingSource<Int, ExpenseDto>
@Transaction
@Query(
"""
SELECT * FROM expense WHERE trip_id = :tripId
ORDER BY DATETIME(expense.datetime) DESC
"""
)
fun expenseDto(tripId: Int): Flow<List<ExpenseDto>>
@Delete @Delete
suspend fun delete(expense: Expense) suspend fun delete(expense: Expense)
@Query(
"""
SELECT
c.id as categoryId,
c.name as categoryName,
c.icon as icon,
c.color as color,
SUM(e.amount) as amount,
e.currency as currency
FROM
expense e
JOIN
category c ON e.category_id = c.id
WHERE
e.trip_id = :tripId
GROUP BY
c.id, c.name, c.icon, c.color, e.currency
"""
)
fun summaryPerCategoryRaw(tripId: Int): Flow<List<SummaryPerCategoryRaw>>
} }

View File

@@ -23,4 +23,9 @@ interface TripDao {
@Delete @Delete
suspend fun delete(trip: Trip) suspend fun delete(trip: Trip)
@Query(
"SELECT * FROM trip where trip.id = :tripId"
)
fun trip(tripId: Int): Trip?
} }

View File

@@ -0,0 +1,22 @@
package cc.n0th1ng.tripmoney.data.dto
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
data class SummaryPerCategory(
val category: Category,
val amount: Double,
val percent: Float,
val currency: Currencies
)
data class SummaryPerCategoryRaw(
val categoryId: Int,
val categoryName: String,
val icon: Icons,
val color: String,
val amount: Double,
val currency: String
)

View File

@@ -40,14 +40,13 @@ class ExchangeRateRepository @Inject constructor(
date = date.toString() date = date.toString()
) )
) )
clearOldRates()
rate rate
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private suspend fun clearOldRates(daysToKeep: Int = 180) { suspend fun clearOldRates(daysToKeep: Int = 180) {
val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString() val cutoffDate = LocalDate.now().minusDays(daysToKeep.toLong()).toString()
exchangeRateDao.deleteOldRates(cutoffDate) exchangeRateDao.deleteOldRates(cutoffDate)
} }

View File

@@ -5,6 +5,7 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import cc.n0th1ng.tripmoney.data.dao.ExpenseDao 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.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -22,10 +23,18 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao)
expenseDao.delete(expense) expenseDao.delete(expense)
} }
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> { fun getExpensesPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false), config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDto(tripId) } pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
).flow ).flow
} }
fun getExpenses(tripId: Int): Flow<List<ExpenseDto>> {
return expenseDao.expenseDto(tripId)
}
fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategoryRaw>> {
return expenseDao.summaryPerCategoryRaw(tripId)
}
} }

View File

@@ -7,9 +7,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.APP_THEME import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.APP_THEME
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.CURRENT_TRIP import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.CURRENT_TRIP
import cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.DEFAULT_CURRENCY
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.DEFAULT_CONCURRENCY
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.Currency
import javax.inject.Inject import javax.inject.Inject
@@ -18,6 +22,7 @@ val Context.preferencesDataStore by preferencesDataStore(name = "app_preferences
object PreferenceKeys { object PreferenceKeys {
val APP_THEME = intPreferencesKey("app_theme") val APP_THEME = intPreferencesKey("app_theme")
val CURRENT_TRIP = intPreferencesKey("current_trip") val CURRENT_TRIP = intPreferencesKey("current_trip")
val DEFAULT_CURRENCY = stringPreferencesKey("default_currency")
} }
@@ -34,6 +39,16 @@ class PreferencesRepository @Inject constructor(@ApplicationContext private val
prefs[CURRENT_TRIP] ?: -1 prefs[CURRENT_TRIP] ?: -1
} }
val defaultCurrencyFlow: Flow<Currencies> =
context.preferencesDataStore.data.map { prefs ->
Currencies.valueOf(prefs[DEFAULT_CURRENCY] ?: Currencies.default().name)
}
suspend fun saveDefaultCurrency(currency: Currencies) {
context.preferencesDataStore.edit { prefs ->
prefs[DEFAULT_CURRENCY] = currency.name
}
}
suspend fun saveCurrentTrip(tripId: Int) { suspend fun saveCurrentTrip(tripId: Int) {
context.preferencesDataStore.edit { prefs -> context.preferencesDataStore.edit { prefs ->
prefs[CURRENT_TRIP] = tripId prefs[CURRENT_TRIP] = tripId

View File

@@ -23,6 +23,10 @@ class TripRepository @Inject constructor(private val tripDao: TripDao) {
).flow ).flow
} }
fun getTrip(tripId: Int): Trip? {
return tripDao.trip(tripId)
}
@WorkerThread @WorkerThread
suspend fun delete(trip: Trip) { suspend fun delete(trip: Trip) {
tripDao.delete(trip) tripDao.delete(trip)

View File

@@ -14,9 +14,9 @@ import androidx.navigation.NavHostController
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopBar(onClick: () -> Unit) { fun TopBar(onClick: () -> Unit, title: String = "") {
TopAppBar( TopAppBar(
title = {}, title = { Text(title) },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onClick() }) { IconButton(onClick = { onClick() }) {
Icon(Icons.Default.Menu, contentDescription = "Menu") Icon(Icons.Default.Menu, contentDescription = "Menu")

View File

@@ -1,6 +1,5 @@
package cc.n0th1ng.tripmoney.screens package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -28,19 +27,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.Colors
import cc.n0th1ng.tripmoney.utils.Icons import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
@Composable @Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) { fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var icon by remember { mutableStateOf(Icons.entries[0]) } var icon by remember { mutableStateOf(Icons.entries[0]) }
var color by remember { mutableStateOf(Colors.entries[0].hexString) } var color by remember { mutableStateOf(colors[0]) }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = { onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
AlertDialogFill( AlertDialogFill(
@@ -48,7 +45,7 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
name = newText name = newText
}, },
onIconChange = { newIcon -> icon = newIcon }, onIconChange = { newIcon -> icon = newIcon },
onColorChange = {newColor -> color = newColor} onColorChange = { newColor -> color = newColor }
) )
}, confirmButton = { }, confirmButton = {
Button( Button(
@@ -72,10 +69,14 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
} }
@Composable @Composable
fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Unit, onColorChange: (String) -> Unit) { fun AlertDialogFill(
onTextChange: (String) -> Unit,
onIconChange: (Icons) -> Unit,
onColorChange: (String) -> Unit
) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) } var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
var colorHex by remember { mutableStateOf(Colors.entries[0].hexString) } var colorHex by remember { mutableStateOf(colors[0]) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -118,16 +119,16 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
rememberScrollState() rememberScrollState()
) )
) { ) {
Colors.entries.forEach { color -> colors.forEach { color ->
Box( Box(
modifier = Modifier modifier = Modifier
.clickable(onClick = { .clickable(onClick = {
colorHex = color.hexString colorHex = color
onColorChange(colorHex) onColorChange(colorHex)
}) })
.size(30.dp) .size(30.dp)
.aspectRatio(1f) .aspectRatio(1f)
.background(Color(color.hexString.toColorInt())) .background(Color(color.toColorInt()))
) {} ) {}
} }
} }

View File

@@ -66,6 +66,7 @@ import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -81,13 +82,13 @@ fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit, onSave: (Expense) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
expenseDtoToEdit: ExpenseDto?, expenseDtoToEdit: ExpenseDto?,
state: SheetState, state: SheetState
// categories: List<Category> = emptyList()
) { ) {
val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState() val currentTripId by settingsViewModel.currentTrip.collectAsState()
// val currentTripId = 1 val currentTrip = tripViewModel.getTrip(currentTripId)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList()) val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
if (categories.isEmpty()) { if (categories.isEmpty()) {
return return
@@ -103,7 +104,7 @@ fun AddExpenseBottomSheet(
var showDateTimePicker by remember { mutableStateOf(false) } var showDateTimePicker by remember { mutableStateOf(false) }
var currency by remember { var currency by remember {
mutableStateOf( mutableStateOf(
expenseDtoToEdit?.expense?.currency ?: Currencies.PLN.name expenseDtoToEdit?.expense?.currency ?: currentTrip?.currency ?: Currencies.default().name
) )
} }
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) } var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }

View File

@@ -104,7 +104,9 @@ fun DateTimePicker(
if (showDatePicker) { if (showDatePicker) {
DatePicker(onDismiss = { showDatePicker = false }, onConfirm = { newDate -> DatePicker(onDismiss = { showDatePicker = false }, onConfirm = { newDate ->
date = newDate date = newDate
showDatePicker = false
showTimePicker = true
}) })
} }

View File

@@ -37,8 +37,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -53,34 +55,58 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import cc.n0th1ng.tripmoney.R.string import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.service.ExchangeService import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseDtoWithConvertedAmount
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import javax.inject.Inject import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val expensesWithConvertedFlow = expenseAndCategoryViewModel
.getExpensesWithConvertedAmountsPaged(currentTrip)
ListExpenseScreen(
expensesWithConvertedFlow = expensesWithConvertedFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) })
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ListExpenseScreen() { fun ListExpenseScreen(
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() expensesWithConvertedFlow: Flow<PagingData<ExpenseDtoWithConvertedAmount>>,
val settingsViewModel: SettingsViewModel = hiltViewModel() onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
) {
val currentTrip by settingsViewModel.currentTrip.collectAsState() val expensesWithConverted = expensesWithConvertedFlow.collectAsLazyPagingItems()
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var expenseDtoToEdit: ExpenseDto? = null var expenseDtoToEdit: ExpenseDto? = null
val sumMap = remember { mutableStateMapOf<LocalDate, Double>() }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = { Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
@@ -90,30 +116,50 @@ 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)
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
state = listState state = listState
) { ) {
items( items(
count = expenses.itemCount, count = expensesWithConverted.itemCount,
key = { index -> expenses[index]?.expense?.id ?: index } key = { index -> expensesWithConverted[index]?.expenseDto?.expense?.id ?: index }
) { index -> ) { index ->
val expenseDto = expenses[index] val expenseDtoWithConverted = expensesWithConverted[index]
if (expenseDto != null) { val expenseDto = expenseDtoWithConverted?.expenseDto
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1) if (expenseDtoWithConverted != null && expenseDto != null) {
val previousExpense =
expensesWithConverted.itemSnapshotList.items.getOrNull(index - 1)?.expenseDto
val showDayDivider = val showDayDivider =
index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime) index == 0 || LocalDateTime.parse(previousExpense?.expense?.datetime)
.toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime) .toLocalDate() != LocalDateTime.parse(expenseDto.expense.datetime)
.toLocalDate() .toLocalDate()
Spacer(Modifier.height(5.dp)) Spacer(Modifier
.height(5.dp)
.background(MaterialTheme.colorScheme.onBackground))
if (showDayDivider) { if (showDayDivider) {
CustomDivider(expenseDto) CustomDivider(
expenseDto,
sumMap.getOrDefault(
LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate(), 0.00
)
)
} }
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard( SwipeToDeleteExpenseCard(
expenseDto = expenseDto, expenseDtoWithConverted = expenseDtoWithConverted,
onDelete = { expense -> expenseAndCategoryViewModel.delete(expense) }, onDelete = { expense -> onDeleteExpense(expense) },
onClick = { expenseDto -> onClick = { expenseDto ->
expenseDtoToEdit = expenseDto expenseDtoToEdit = expenseDto
showBottomSheet = true showBottomSheet = true
@@ -125,7 +171,7 @@ fun ListExpenseScreen() {
if (showBottomSheet) { if (showBottomSheet) {
AddExpenseBottomSheet( AddExpenseBottomSheet(
onSave = { expense -> onSave = { expense ->
expenseAndCategoryViewModel.save(expense) onSaveExpense(expense)
showBottomSheet = false showBottomSheet = false
expenseDtoToEdit = null expenseDtoToEdit = null
}, },
@@ -140,9 +186,10 @@ fun ListExpenseScreen() {
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun CustomDivider(expenseDto: ExpenseDto) { fun CustomDivider(expenseDto: ExpenseDto, sum: Double) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center, horizontalArrangement = Arrangement.Absolute.Center,
@@ -153,16 +200,32 @@ fun CustomDivider(expenseDto: ExpenseDto) {
LocalDateTime.parse(expenseDto.expense.datetime).format( LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd EEEE") DateTimeFormatter.ofPattern("dd EEEE")
).toString(), ).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f)) modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
) )
HorizontalDivider(modifier = Modifier.weight(1f)) Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(2f))
Text(
"%.2f %s".format(sum, expenseDto.trip.currency),
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.bodyMedium
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun SwipeToDeleteExpenseCard( fun SwipeToDeleteExpenseCard(
expenseDto: ExpenseDto, expenseDtoWithConverted: ExpenseDtoWithConvertedAmount,
onDelete: (Expense) -> Unit, onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit onClick: (ExpenseDto) -> Unit
) { ) {
@@ -186,7 +249,7 @@ fun SwipeToDeleteExpenseCard(
onConfirm = { onConfirm = {
showDialog = false showDialog = false
dismissed = true dismissed = true
onDelete(expenseDto.expense) onDelete(expenseDtoWithConverted.expenseDto.expense)
}, },
onCancel = { showDialog = false } onCancel = { showDialog = false }
) )
@@ -209,7 +272,7 @@ fun SwipeToDeleteExpenseCard(
} }
} }
) { ) {
ExpenseCard(expenseDto, onClick = onClick) ExpenseCard(expenseDtoWithConverted, onClick = onClick)
} }
} }
} }
@@ -226,15 +289,15 @@ fun DeleteConfirmationDialog(
Column( Column(
Modifier Modifier
.background( .background(
MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
.padding(24.dp) .padding(24.dp)
) { ) {
Text( Text(
stringResource(string.delete_confirmation), stringResource(string.delete_confirmation),
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge,
fontSize = 20.sp color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Row( Row(
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -244,6 +307,8 @@ fun DeleteConfirmationDialog(
) { ) {
Text( Text(
text = stringResource(string.cancel), text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier modifier = Modifier
.padding(end = 24.dp) .padding(end = 24.dp)
.clickable { onCancel() } .clickable { onCancel() }
@@ -251,7 +316,7 @@ fun DeleteConfirmationDialog(
Text( Text(
text = stringResource(string.delete), text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() } modifier = Modifier.clickable { onConfirm() }
) )
} }
@@ -261,8 +326,14 @@ fun DeleteConfirmationDialog(
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) { fun ExpenseCard(
expenseDtoWithConverted: ExpenseDtoWithConvertedAmount,
onClick: (ExpenseDto) -> Unit
) {
val expenseDto = expenseDtoWithConverted.expenseDto
ElevatedCard( ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.height(70.dp) .height(70.dp)
@@ -299,14 +370,14 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
{ {
Text( Text(
text = expenseDto.category.name, text = expenseDto.category.name,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium,
lineHeight = 5.sp color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
modifier = Modifier.padding(0.dp), modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note, text = expenseDto.expense.note,
fontSize = 11.sp, style = MaterialTheme.typography.labelSmall,
lineHeight = 5.sp color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
@@ -314,31 +385,132 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
text = LocalDateTime.parse(expenseDto.expense.datetime).format( text = LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd MMM HH:mm") DateTimeFormatter.ofPattern("dd MMM HH:mm")
), ),
fontSize = 12.sp, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
} }
Column { Column {
Text( Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount), text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
fontWeight = FontWeight.Bold style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
) )
if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) { if (expenseDto.expense.currency.lowercase() != expenseDto.trip.currency.lowercase()) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val amount by
expenseAndCategoryViewModel.convertAmount(
amount = expenseDto.expense.amount,
base = Currencies.valueOf(expenseDto.expense.currency),
target = Currencies.valueOf(expenseDto.trip.currency),
date = LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate()
).collectAsState(initial = 0.0)
Text( Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(amount), text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDtoWithConverted.convertedAmount),
fontSize = 12.sp style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
} }
} }
} }
} }
@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
}

View File

@@ -33,6 +33,8 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.* import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.repository.AppTheme import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
@RequiresApi(Build.VERSION_CODES.S) @RequiresApi(Build.VERSION_CODES.S)
@@ -40,8 +42,9 @@ import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
fun SettingsScreen() { fun SettingsScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState() val currentTheme by settingsViewModel.theme.collectAsState()
var showDialog by remember { mutableStateOf(false) } val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
val scope = rememberCoroutineScope() var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -49,7 +52,7 @@ fun SettingsScreen() {
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Card { Card {
SettingsListItem(onClick = { showDialog = true }, stringResource(string.theme)) { SettingsListItem(onClick = { showThemeDialog = true }, stringResource(string.theme)) {
Text( Text(
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource( if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
string.light_theme string.light_theme
@@ -58,16 +61,33 @@ fun SettingsScreen() {
} }
} }
if (showDialog) { Card {
SettingsListItem(
onClick = { showCurrencyDialog = true },
stringResource(string.default_currency)
) {
Text(currentDefaultCurrency.name)
}
}
if (showThemeDialog) {
ThemeSelectionDialog( ThemeSelectionDialog(
onDismiss = { showDialog = false }, onDismiss = { showThemeDialog = false },
onThemeSelected = { theme -> onThemeSelected = { theme ->
settingsViewModel.setTheme(theme) settingsViewModel.setTheme(theme)
showDialog = false showThemeDialog = false
}, },
selected = currentTheme selected = currentTheme
) )
} }
if (showCurrencyDialog) {
CurrencySelectionDialog(onDismiss = {showCurrencyDialog = false}, onCurrencySelected = {
currencyString ->
settingsViewModel.setDefaultCurrency(Currencies.valueOf(currencyString))
showCurrencyDialog = false
}, currentDefaultCurrency.name)
}
} }
} }

View File

@@ -1,9 +1,150 @@
package cc.n0th1ng.tripmoney.screens.statistics package cc.n0th1ng.tripmoney.screens.statistics
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
@RequiresApi(Build.VERSION_CODES.O)
@Composable @Composable
fun StatisticsScreen() { fun StatisticsScreen() {
Text("TODO") val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
} val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTrip)
.collectAsState(emptyList())
Column(modifier = Modifier.padding(10.dp)) {
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
// Text(text = "Summary", fontWeight = FontWeight.Bold, fontSize = 25.sp)
summaryPerCategoryList.forEach {
CategoryCard(
summaryPerCategory = it, modifier = Modifier
.fillMaxWidth()
)
}
}
}
}
@Composable
fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCategory) {
Column(modifier = modifier) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(
painter = painterResource(summaryPerCategory.category.icon.resource),
contentDescription = null,
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
tint = Color(summaryPerCategory.category.color.toColorInt())
)
Text("%s".format(summaryPerCategory.category.name, (summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.bodyLarge, color = Color(summaryPerCategory.category.color.toColorInt()))
}
Text("%.2f ${summaryPerCategory.currency}".format(summaryPerCategory.amount),
style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically){
Box(
modifier = Modifier
.height(40.dp)
.fillMaxWidth(0.12f + (0.90f - 0.12f) * summaryPerCategory.percent)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.primary)
) {
Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize().padding(11.dp)) {
Text("%d%%".format((summaryPerCategory.percent * 100).toInt()),
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimary)
}
}
// Text("%d%%".format((summaryPerCategory.percent * 100).toInt()),
// style = MaterialTheme.typography.labelSmall)
}
}
}
@Preview
@Composable
fun previewLight() {
TripMoneyTheme {
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
@Preview
@Composable
fun previewDark() {
TripMoneyTheme(darkTheme = true) {
SummaryPerCategoryCard(summaryPerCategoryList)
}
}
val categories = listOf(
Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()),
Category(name = "Transport", icon = Icons.FLIGHT, color = colors.random()),
Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = colors.random()),
Category(name = "Zakupy", icon = Icons.GROCERIES, color = colors.random()),
Category(name = "Zakupy1", icon = Icons.GROCERIES, color = colors.random()),
Category(name = "Zakupy2", icon = Icons.GROCERIES, color = colors.random()),
Category(name = "Zakupy3", icon = Icons.GROCERIES, color = colors.random())
)
val summaryPerCategoryList = listOf(
SummaryPerCategory(categories[0], 50.0, 1f, Currencies.PLN),
SummaryPerCategory(categories[1], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[2], 80.0, 0.2f, Currencies.PLN),
SummaryPerCategory(categories[3], 50.0, 0.1f, Currencies.PLN),
SummaryPerCategory(categories[4], 120.0, 0.3f, Currencies.PLN),
SummaryPerCategory(categories[5], 50.0, 0.0001f, Currencies.PLN),
)

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.Shapes
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -37,12 +38,15 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Trip import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import io.ktor.http.hostIsIp
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -62,9 +66,11 @@ fun AddTripBottomSheet(
LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString()) LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString())
) )
} }
val settingsViewModel: SettingsViewModel = hiltViewModel()
val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
var showCurrencyDialog by remember { mutableStateOf(false) } var showCurrencyDialog by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var currency by remember { mutableStateOf(tripToEdit?.currency ?: Currencies.default().name) } var currency by remember { mutableStateOf(tripToEdit?.currency ?: defaultCurrency.name) }
var enableSave by remember { mutableStateOf(tripToEdit != null) } var enableSave by remember { mutableStateOf(tripToEdit != null) }
ModalBottomSheet( ModalBottomSheet(

View File

@@ -182,6 +182,13 @@ fun TripCard(
) { ) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
ElevatedCard( ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
}
),
modifier = Modifier modifier = Modifier
.height(100.dp) .height(100.dp)
.combinedClickable(enabled = true, onLongClick = { .combinedClickable(enabled = true, onLongClick = {

View File

@@ -2,10 +2,218 @@ package cc.n0th1ng.tripmoney.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) val primaryLight = Color(0xFF48672E)
val PurpleGrey80 = Color(0xFFCCC2DC) val onPrimaryLight = Color(0xFFFFFFFF)
val Pink80 = Color(0xFFEFB8C8) val primaryContainerLight = Color(0xFFC9EEA7)
val onPrimaryContainerLight = Color(0xFF324E19)
val secondaryLight = Color(0xFF56624A)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFDAE7C9)
val onSecondaryContainerLight = Color(0xFF3F4A34)
val tertiaryLight = Color(0xFF386664)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFBBECE9)
val onTertiaryContainerLight = Color(0xFF1E4E4D)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF9FAEF)
val onBackgroundLight = Color(0xFF1A1D16)
val surfaceLight = Color(0xFFF9FAEF)
val onSurfaceLight = Color(0xFF1A1D16)
val surfaceVariantLight = Color(0xFFE0E4D6)
val onSurfaceVariantLight = Color(0xFF44483E)
val outlineLight = Color(0xFF74796D)
val outlineVariantLight = Color(0xFFC4C8BA)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2E312A)
val inverseOnSurfaceLight = Color(0xFFF0F2E7)
val inversePrimaryLight = Color(0xFFAED18D)
val surfaceDimLight = Color(0xFFD9DBD0)
val surfaceBrightLight = Color(0xFFF9FAEF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF3F5EA)
val surfaceContainerLight = Color(0xFFEDEFE4)
val surfaceContainerHighLight = Color(0xFFE7E9DE)
val surfaceContainerHighestLight = Color(0xFFE2E3D9)
val Purple40 = Color(0xFF6650a4) val primaryLightMediumContrast = Color(0xFF213D08)
val PurpleGrey40 = Color(0xFF625b71) val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val Pink40 = Color(0xFF7D5260) val primaryContainerLightMediumContrast = Color(0xFF57763B)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF2F3925)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF657158)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF073D3C)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF477573)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF740006)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFF9FAEF)
val onBackgroundLightMediumContrast = Color(0xFF1A1D16)
val surfaceLightMediumContrast = Color(0xFFF9FAEF)
val onSurfaceLightMediumContrast = Color(0xFF0F120C)
val surfaceVariantLightMediumContrast = Color(0xFFE0E4D6)
val onSurfaceVariantLightMediumContrast = Color(0xFF33382E)
val outlineLightMediumContrast = Color(0xFF4F5449)
val outlineVariantLightMediumContrast = Color(0xFF6A6F63)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF2E312A)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F2E7)
val inversePrimaryLightMediumContrast = Color(0xFFAED18D)
val surfaceDimLightMediumContrast = Color(0xFFC5C7BD)
val surfaceBrightLightMediumContrast = Color(0xFFF9FAEF)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F5EA)
val surfaceContainerLightMediumContrast = Color(0xFFE7E9DE)
val surfaceContainerHighLightMediumContrast = Color(0xFFDCDED3)
val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D3C8)
val primaryLightHighContrast = Color(0xFF183301)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF34511B)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF252F1B)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF414D36)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF003231)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF21504F)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF600004)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF98000A)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFF9FAEF)
val onBackgroundLightHighContrast = Color(0xFF1A1D16)
val surfaceLightHighContrast = Color(0xFFF9FAEF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE0E4D6)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF292E24)
val outlineVariantLightHighContrast = Color(0xFF464B40)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF2E312A)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFAED18D)
val surfaceDimLightHighContrast = Color(0xFFB8BAB0)
val surfaceBrightLightHighContrast = Color(0xFFF9FAEF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF0F2E7)
val surfaceContainerLightHighContrast = Color(0xFFE2E3D9)
val surfaceContainerHighLightHighContrast = Color(0xFFD3D5CB)
val surfaceContainerHighestLightHighContrast = Color(0xFFC5C7BD)
val primaryDark = Color(0xFFAED18D)
val onPrimaryDark = Color(0xFF1C3704)
val primaryContainerDark = Color(0xFF324E19)
val onPrimaryContainerDark = Color(0xFFC9EEA7)
val secondaryDark = Color(0xFFBECBAE)
val onSecondaryDark = Color(0xFF29341F)
val secondaryContainerDark = Color(0xFF3F4A34)
val onSecondaryContainerDark = Color(0xFFDAE7C9)
val tertiaryDark = Color(0xFFA0CFCD)
val onTertiaryDark = Color(0xFF003736)
val tertiaryContainerDark = Color(0xFF1E4E4D)
val onTertiaryContainerDark = Color(0xFFBBECE9)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF11140E)
val onBackgroundDark = Color(0xFFE2E3D9)
val surfaceDark = Color(0xFF11140E)
val onSurfaceDark = Color(0xFFE2E3D9)
val surfaceVariantDark = Color(0xFF44483E)
val onSurfaceVariantDark = Color(0xFFC4C8BA)
val outlineDark = Color(0xFF8E9286)
val outlineVariantDark = Color(0xFF44483E)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE2E3D9)
val inverseOnSurfaceDark = Color(0xFF2E312A)
val inversePrimaryDark = Color(0xFF48672E)
val surfaceDimDark = Color(0xFF11140E)
val surfaceBrightDark = Color(0xFF373A33)
val surfaceContainerLowestDark = Color(0xFF0C0F09)
val surfaceContainerLowDark = Color(0xFF1A1D16)
val surfaceContainerDark = Color(0xFF1E211A)
val surfaceContainerHighDark = Color(0xFF282B24)
val surfaceContainerHighestDark = Color(0xFF33362E)
val primaryDarkMediumContrast = Color(0xFFC3E8A1)
val onPrimaryDarkMediumContrast = Color(0xFF132B00)
val primaryContainerDarkMediumContrast = Color(0xFF799A5C)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFD4E1C3)
val onSecondaryDarkMediumContrast = Color(0xFF1E2915)
val secondaryContainerDarkMediumContrast = Color(0xFF89957A)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFB5E5E3)
val onTertiaryDarkMediumContrast = Color(0xFF002B2A)
val tertiaryContainerDarkMediumContrast = Color(0xFF6B9997)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFD2CC)
val onErrorDarkMediumContrast = Color(0xFF540003)
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF11140E)
val onBackgroundDarkMediumContrast = Color(0xFFE2E3D9)
val surfaceDarkMediumContrast = Color(0xFF11140E)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF44483E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFDADED0)
val outlineDarkMediumContrast = Color(0xFFAFB4A6)
val outlineVariantDarkMediumContrast = Color(0xFF8E9285)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E3D9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282B24)
val inversePrimaryDarkMediumContrast = Color(0xFF33501A)
val surfaceDimDarkMediumContrast = Color(0xFF11140E)
val surfaceBrightDarkMediumContrast = Color(0xFF43453E)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF060804)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1F18)
val surfaceContainerDarkMediumContrast = Color(0xFF262922)
val surfaceContainerHighDarkMediumContrast = Color(0xFF31342C)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3F37)
val primaryDarkHighContrast = Color(0xFFD7FCB3)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFAACD89)
val onPrimaryContainerDarkHighContrast = Color(0xFF040E00)
val secondaryDarkHighContrast = Color(0xFFE8F5D6)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFBAC7AA)
val onSecondaryContainerDarkHighContrast = Color(0xFF050E01)
val tertiaryDarkHighContrast = Color(0xFFC9F9F7)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFF9CCBC9)
val onTertiaryContainerDarkHighContrast = Color(0xFF000E0D)
val errorDarkHighContrast = Color(0xFFFFECE9)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF11140E)
val onBackgroundDarkHighContrast = Color(0xFFE2E3D9)
val surfaceDarkHighContrast = Color(0xFF11140E)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF44483E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFEEF2E3)
val outlineVariantDarkHighContrast = Color(0xFFC0C4B6)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE2E3D9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF33501A)
val surfaceDimDarkHighContrast = Color(0xFF11140E)
val surfaceBrightDarkHighContrast = Color(0xFF4E5149)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF1E211A)
val surfaceContainerDarkHighContrast = Color(0xFF2E312A)
val surfaceContainerHighDarkHighContrast = Color(0xFF393C35)
val surfaceContainerHighestDarkHighContrast = Color(0xFF454840)

View File

@@ -13,33 +13,87 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
private val DarkColorScheme = darkColorScheme( private val lightScheme = lightColorScheme(
primary = Purple80, primary = primaryLight,
secondary = PurpleGrey80, onPrimary = onPrimaryLight,
tertiary = Pink80 primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
) )
private val LightColorScheme = lightColorScheme( private val darkScheme = darkColorScheme(
primary = Purple40, primary = primaryDark,
secondary = PurpleGrey40, onPrimary = onPrimaryDark,
tertiary = Pink40 primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
/* Other default colors to override secondary = secondaryDark,
background = Color(0xFFFFFBFE), onSecondary = onSecondaryDark,
surface = Color(0xFFFFFBFE), secondaryContainer = secondaryContainerDark,
onPrimary = Color.White, onSecondaryContainer = onSecondaryContainerDark,
onSecondary = Color.White, tertiary = tertiaryDark,
onTertiary = Color.White, onTertiary = onTertiaryDark,
onBackground = Color(0xFF1C1B1F), tertiaryContainer = tertiaryContainerDark,
onSurface = Color(0xFF1C1B1F), onTertiaryContainer = onTertiaryContainerDark,
*/ error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
) )
@Composable @Composable
fun TripMoneyTheme( fun TripMoneyTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -47,8 +101,8 @@ fun TripMoneyTheme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> darkScheme
else -> LightColorScheme else -> lightScheme
} }
MaterialTheme( MaterialTheme(

View File

@@ -0,0 +1,9 @@
package cc.n0th1ng.tripmoney.utils
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.content.res.Configuration.UI_MODE_TYPE_NORMAL
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
annotation class AllPreviews

View File

@@ -1,25 +1,31 @@
package cc.n0th1ng.tripmoney.utils package cc.n0th1ng.tripmoney.utils
enum class Colors(val hexString: String) { val colors: List<String> = listOf(
RED("#D53E0F"), "#af1b3f",
PINK("#FF69B4"), "#083D77",
ORANGE("#FF8C00"), "#5998c5",
YELLOW("#FFD700"), "#f7934c",
LIME("#32CD32"), "#ec0b43",
GREEN("#228B22"), "#87A330",
MINT("#98FB98"), "#6F8AB7",
TEAL("#008080"), "#F26CA7",
CYAN("#00CED1"), "#5E4AE3",
SKY_BLUE("#1E90FF"), "#2A7F62",
BLUE("#0000FF"), "#0B7189"
LAVENDER("#8A2BE2"), )
LILAC("#C8A2C8"), // GREEN("#228B22"),
PURPLE("#800080"), // MINT("#98FB98"),
MAUVE("#D8BFD8"), // TEAL("#008080"),
MAGENTA("#FF00FF"), // CYAN("#00CED1"),
VIOLET("#9400D3"), // SKY_BLUE("#1E90FF"),
INDIGO("#4B0082"), // BLUE("#0000FF"),
PERIWINKLE("#8A2BE2"), // LAVENDER("#8A2BE2"),
GRAY("#696969"); // LILAC("#C8A2C8"),
} // PURPLE("#800080"),
// MAUVE("#D8BFD8"),
// MAGENTA("#FF00FF"),
// VIOLET("#9400D3"),
// INDIGO("#4B0082"),
// PERIWINKLE("#8A2BE2");
//}

View File

@@ -1,35 +1,39 @@
package cc.n0th1ng.tripmoney.viewmodel package cc.n0th1ng.tripmoney.viewmodel
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.repository.CategoryRepository import cc.n0th1ng.tripmoney.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository import cc.n0th1ng.tripmoney.data.repository.ExpenseRepository
import cc.n0th1ng.tripmoney.service.ExchangeService import cc.n0th1ng.tripmoney.data.repository.TripRepository
import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.request.get
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import kotlinx.coroutines.runBlocking
import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor( open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository, private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository, private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository
) : ViewModel() { ) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> = fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpenses(tripId).cachedIn(viewModelScope) expenseRepo.getExpensesPaged(tripId).cachedIn(viewModelScope)
fun save(expense: Expense) { fun save(expense: Expense) {
viewModelScope.launch { viewModelScope.launch {
@@ -51,9 +55,81 @@ class ExpenseAndCategoryViewModel @Inject constructor(
} }
} }
fun convertAmount(amount: Double, base: Currencies, target: Currencies, date: LocalDate): Flow<Double> { @RequiresApi(Build.VERSION_CODES.O)
return flow { fun getSummaryPerCategory(tripId: Int): Flow<List<SummaryPerCategory>> {
emit(amount * exchangeRateRepository.getRate(base, target, date)) val tripCurrency = tripRepo.getTrip(tripId)?.currency ?: Currencies.default().name
return getExpensesWithConvertedAmounts(tripId)
.map { list ->
// Compute summary
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()
}
}
@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.toExpenseDtoWithConvertedAmount()
}
} 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.toExpenseDtoWithConvertedAmount()
}
} else {
expenseDto.expense.amount
}
ExpenseDtoWithConvertedAmount(expenseDto, convertedAmount)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
fun clearOldRates() {
viewModelScope.launch {
exchangeRateRepository.clearOldRates()
} }
} }
@RequiresApi(Build.VERSION_CODES.O)
suspend fun ExpenseDto.toExpenseDtoWithConvertedAmount(): Double {
return exchangeRateRepository.getRate(
Currencies.valueOf(this.expense.currency),
Currencies.valueOf(this.trip.currency),
LocalDateTime.parse(this.expense.datetime).toLocalDate()
) * this.expense.amount
}
data class ExpenseDtoWithConvertedAmount(
val expenseDto: ExpenseDto,
val convertedAmount: Double
)
} }

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import cc.n0th1ng.tripmoney.data.repository.AppTheme import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -35,6 +36,17 @@ class SettingsViewModel @Inject constructor(
-1 -1
) )
val defaultCurrency = repo.defaultCurrencyFlow.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000),
Currencies.default()
)
fun setDefaultCurrency(currency: Currencies) {
viewModelScope.launch {
repo.saveDefaultCurrency(currency)
}
}
fun setCurrentTrip(tripId: Int) { fun setCurrentTrip(tripId: Int) {
viewModelScope.launch { viewModelScope.launch {
repo.saveCurrentTrip(tripId) repo.saveCurrentTrip(tripId)

View File

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

View File

@@ -20,4 +20,5 @@
<string name="name">Nazwa</string> <string name="name">Nazwa</string>
<string name="edit_trip">Edytuj wycieczkę</string> <string name="edit_trip">Edytuj wycieczkę</string>
<string name="edit_expense">Edytuj wydatek</string> <string name="edit_expense">Edytuj wydatek</string>
<string name="default_currency">Domyślna waluta</string>
</resources> </resources>

View File

@@ -20,4 +20,5 @@
<string name="name">Name</string> <string name="name">Name</string>
<string name="edit_trip">Edit trip</string> <string name="edit_trip">Edit trip</string>
<string name="edit_expense">Edit expense</string> <string name="edit_expense">Edit expense</string>
<string name="default_currency">Default currency</string>
</resources> </resources>