init #48

Merged
admin merged 18 commits from develop into main 2026-04-30 10:35:58 +02:00
27 changed files with 960 additions and 154 deletions
Showing only changes of commit 916481e4e3 - Show all commits

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.trippicker.TripPickerScreen
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -43,6 +45,8 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
TripMoneyTheme {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
expenseAndCategoryViewModel.clearOldRates()
NavigationDrawer()
}
}
@@ -53,7 +57,9 @@ class MainActivity : ComponentActivity() {
@Composable
fun NavigationDrawer() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip = tripViewModel.getTrip(currentTripId)
val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState()
val current = navBackStack?.destination?.route
@@ -65,7 +71,9 @@ fun NavigationDrawer() {
topBar = {
if (current == Screens.SETTINGS) TopBarSettings(
navController
) else TopBar(onClick = {
) else TopBar(
title = currentTrip?.name ?: "",
onClick = {
scope.launch {
if (drawerState.isClosed) {
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.Trip
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.utils.colors
import dagger.Module
import dagger.Provides
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 = "Szwajcaria", startDate = "2025-03-01", currency = "EUR"))
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 = "Jedzenie", icon = Icons.RESTAURANT, color = "#C8E6C9"))
categoryDao.insert(Category(name = "Transport", icon = Icons.FLIGHT, color = "#FFCDD2"))
categoryDao.insert(Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = "#FFF9C4"))
categoryDao.insert(Category(name = "Zakupy", icon = Icons.GROCERIES, color = "#E1BEE7"))
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"))
categoryDao.insert(Category(name = "Hotel", icon = Icons.HOTEL, color = colors.random()))
categoryDao.insert(Category(name = "Jedzenie", icon = Icons.RESTAURANT, color = colors.random()))
categoryDao.insert(Category(name = "Transport", icon = Icons.FLIGHT, color = colors.random()))
categoryDao.insert(Category(name = "Rozrywka", icon = Icons.ATTRACTION, color = colors.random()))
categoryDao.insert(Category(name = "Zakupy", icon = Icons.GROCERIES,color = colors.random()))
val now = LocalDateTime.now()
expenseDao.insert(

View File

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

View File

@@ -23,4 +23,9 @@ interface TripDao {
@Delete
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()
)
)
clearOldRates()
rate
}
}
@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()
exchangeRateDao.deleteOldRates(cutoffDate)
}

View File

@@ -5,6 +5,7 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
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 kotlinx.coroutines.flow.Flow
@@ -22,10 +23,18 @@ class ExpenseRepository @Inject constructor(private val expenseDao: ExpenseDao)
expenseDao.delete(expense)
}
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> {
fun getExpensesPaged(tripId: Int): Flow<PagingData<ExpenseDto>> {
return Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false),
pagingSourceFactory = { expenseDao.expenseDto(tripId) }
pagingSourceFactory = { expenseDao.expenseDtoPaged(tripId) }
).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 cc.n0th1ng.tripmoney.data.repository.PreferenceKeys.APP_THEME
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 kotlinx.coroutines.flow.DEFAULT_CONCURRENCY
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.Currency
import javax.inject.Inject
@@ -18,6 +22,7 @@ val Context.preferencesDataStore by preferencesDataStore(name = "app_preferences
object PreferenceKeys {
val APP_THEME = intPreferencesKey("app_theme")
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
}
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) {
context.preferencesDataStore.edit { prefs ->
prefs[CURRENT_TRIP] = tripId

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -28,19 +27,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
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.colors
@Composable
fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
var name by remember { mutableStateOf("") }
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(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
AlertDialogFill(
@@ -48,7 +45,7 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
name = newText
},
onIconChange = { newIcon -> icon = newIcon },
onColorChange = {newColor -> color = newColor}
onColorChange = { newColor -> color = newColor }
)
}, confirmButton = {
Button(
@@ -72,10 +69,14 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
}
@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 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)) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -118,16 +119,16 @@ fun AlertDialogFill(onTextChange: (String) -> Unit, onIconChange: (Icons) -> Uni
rememberScrollState()
)
) {
Colors.entries.forEach { color ->
colors.forEach { color ->
Box(
modifier = Modifier
.clickable(onClick = {
colorHex = color.hexString
colorHex = color
onColorChange(colorHex)
})
.size(30.dp)
.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.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -81,13 +82,13 @@ fun AddExpenseBottomSheet(
onSave: (Expense) -> Unit,
onDismiss: () -> Unit,
expenseDtoToEdit: ExpenseDto?,
state: SheetState,
// categories: List<Category> = emptyList()
state: SheetState
) {
val tripViewModel: TripViewModel = hiltViewModel()
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
// val currentTripId = 1
val currentTrip = tripViewModel.getTrip(currentTripId)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
if (categories.isEmpty()) {
return
@@ -103,7 +104,7 @@ fun AddExpenseBottomSheet(
var showDateTimePicker by remember { mutableStateOf(false) }
var currency by remember {
mutableStateOf(
expenseDtoToEdit?.expense?.currency ?: Currencies.PLN.name
expenseDtoToEdit?.expense?.currency ?: currentTrip?.currency ?: Currencies.default().name
)
}
var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) }

View File

@@ -105,6 +105,8 @@ fun DateTimePicker(
if (showDatePicker) {
DatePicker(onDismiss = { showDatePicker = false }, onConfirm = { 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.rememberSwipeToDismissBoxState
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,34 +55,58 @@ 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 androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
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.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.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseDtoWithConvertedAmount
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
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 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)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTrip by settingsViewModel.currentTrip.collectAsState()
val expenses = expenseAndCategoryViewModel.getExpenses(currentTrip).collectAsLazyPagingItems()
fun ListExpenseScreen(
expensesWithConvertedFlow: Flow<PagingData<ExpenseDtoWithConvertedAmount>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
) {
val expensesWithConverted = expensesWithConvertedFlow.collectAsLazyPagingItems()
val listState = rememberLazyListState()
var showBottomSheet by remember { mutableStateOf(false) }
var expenseDtoToEdit: ExpenseDto? = null
val sumMap = remember { mutableStateMapOf<LocalDate, Double>() }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
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(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
items(
count = expenses.itemCount,
key = { index -> expenses[index]?.expense?.id ?: index }
count = expensesWithConverted.itemCount,
key = { index -> expensesWithConverted[index]?.expenseDto?.expense?.id ?: index }
) { index ->
val expenseDto = expenses[index]
if (expenseDto != null) {
val previousExpense = expenses.itemSnapshotList.items.getOrNull(index - 1)
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))
Spacer(Modifier
.height(5.dp)
.background(MaterialTheme.colorScheme.onBackground))
if (showDayDivider) {
CustomDivider(expenseDto)
CustomDivider(
expenseDto,
sumMap.getOrDefault(
LocalDateTime.parse(expenseDto.expense.datetime).toLocalDate(), 0.00
)
)
}
Spacer(Modifier.height(5.dp))
SwipeToDeleteExpenseCard(
expenseDto = expenseDto,
onDelete = { expense -> expenseAndCategoryViewModel.delete(expense) },
expenseDtoWithConverted = expenseDtoWithConverted,
onDelete = { expense -> onDeleteExpense(expense) },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
@@ -125,7 +171,7 @@ fun ListExpenseScreen() {
if (showBottomSheet) {
AddExpenseBottomSheet(
onSave = { expense ->
expenseAndCategoryViewModel.save(expense)
onSaveExpense(expense)
showBottomSheet = false
expenseDtoToEdit = null
},
@@ -140,9 +186,10 @@ fun ListExpenseScreen() {
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CustomDivider(expenseDto: ExpenseDto) {
fun CustomDivider(expenseDto: ExpenseDto, sum: Double) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
@@ -153,16 +200,32 @@ fun CustomDivider(expenseDto: ExpenseDto) {
LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f))
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
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)
@Composable
fun SwipeToDeleteExpenseCard(
expenseDto: ExpenseDto,
expenseDtoWithConverted: ExpenseDtoWithConvertedAmount,
onDelete: (Expense) -> Unit,
onClick: (ExpenseDto) -> Unit
) {
@@ -186,7 +249,7 @@ fun SwipeToDeleteExpenseCard(
onConfirm = {
showDialog = false
dismissed = true
onDelete(expenseDto.expense)
onDelete(expenseDtoWithConverted.expenseDto.expense)
},
onCancel = { showDialog = false }
)
@@ -209,7 +272,7 @@ fun SwipeToDeleteExpenseCard(
}
}
) {
ExpenseCard(expenseDto, onClick = onClick)
ExpenseCard(expenseDtoWithConverted, onClick = onClick)
}
}
}
@@ -226,15 +289,15 @@ fun DeleteConfirmationDialog(
Column(
Modifier
.background(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
)
.padding(24.dp)
) {
Text(
stringResource(string.delete_confirmation),
fontWeight = FontWeight.Bold,
fontSize = 20.sp
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
horizontalArrangement = Arrangement.End,
@@ -244,6 +307,8 @@ fun DeleteConfirmationDialog(
) {
Text(
text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(end = 24.dp)
.clickable { onCancel() }
@@ -251,7 +316,7 @@ fun DeleteConfirmationDialog(
Text(
text = stringResource(string.delete),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onConfirm() }
)
}
@@ -261,8 +326,14 @@ fun DeleteConfirmationDialog(
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
fun ExpenseCard(
expenseDtoWithConverted: ExpenseDtoWithConvertedAmount,
onClick: (ExpenseDto) -> Unit
) {
val expenseDto = expenseDtoWithConverted.expenseDto
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
@@ -299,14 +370,14 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
{
Text(
text = expenseDto.category.name,
fontWeight = FontWeight.Bold,
lineHeight = 5.sp
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note,
fontSize = 11.sp,
lineHeight = 5.sp
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
@@ -314,27 +385,24 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
text = LocalDateTime.parse(expenseDto.expense.datetime).format(
DateTimeFormatter.ofPattern("dd MMM HH:mm")
),
fontSize = 12.sp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Column {
Text(
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()) {
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 = "≈ %.2f ${expenseDto.trip.currency}".format(amount),
fontSize = 12.sp
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDtoWithConverted.convertedAmount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
@@ -342,3 +410,107 @@ fun ExpenseCard(expenseDto: ExpenseDto, onClick: (ExpenseDto) -> Unit) {
}
}
}
@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 cc.n0th1ng.tripmoney.R.*
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
@RequiresApi(Build.VERSION_CODES.S)
@@ -40,8 +42,9 @@ import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
fun SettingsScreen() {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
var showDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -49,7 +52,7 @@ fun SettingsScreen() {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Card {
SettingsListItem(onClick = { showDialog = true }, stringResource(string.theme)) {
SettingsListItem(onClick = { showThemeDialog = true }, stringResource(string.theme)) {
Text(
if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource(
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(
onDismiss = { showDialog = false },
onDismiss = { showThemeDialog = false },
onThemeSelected = { theme ->
settingsViewModel.setTheme(theme)
showDialog = false
showThemeDialog = false
},
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
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.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
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.CurrencyButton
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.DatePicker
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import io.ktor.http.hostIsIp
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@@ -62,9 +66,11 @@ fun AddTripBottomSheet(
LocalDate.parse(tripToEdit?.startDate ?: LocalDate.now().toString())
)
}
val settingsViewModel: SettingsViewModel = hiltViewModel()
val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
var showCurrencyDialog 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) }
ModalBottomSheet(

View File

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

View File

@@ -2,10 +2,218 @@ package cc.n0th1ng.tripmoney.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val primaryLight = Color(0xFF48672E)
val onPrimaryLight = Color(0xFFFFFFFF)
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 PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val primaryLightMediumContrast = Color(0xFF213D08)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
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.unit.dp
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
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(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
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
fun TripMoneyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -47,8 +101,8 @@ fun TripMoneyTheme(
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
darkTheme -> darkScheme
else -> lightScheme
}
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
enum class Colors(val hexString: String) {
RED("#D53E0F"),
PINK("#FF69B4"),
ORANGE("#FF8C00"),
YELLOW("#FFD700"),
LIME("#32CD32"),
GREEN("#228B22"),
MINT("#98FB98"),
TEAL("#008080"),
CYAN("#00CED1"),
SKY_BLUE("#1E90FF"),
BLUE("#0000FF"),
LAVENDER("#8A2BE2"),
LILAC("#C8A2C8"),
PURPLE("#800080"),
MAUVE("#D8BFD8"),
MAGENTA("#FF00FF"),
VIOLET("#9400D3"),
INDIGO("#4B0082"),
PERIWINKLE("#8A2BE2"),
GRAY("#696969");
}
val colors: List<String> = listOf(
"#af1b3f",
"#083D77",
"#5998c5",
"#f7934c",
"#ec0b43",
"#87A330",
"#6F8AB7",
"#F26CA7",
"#5E4AE3",
"#2A7F62",
"#0B7189"
)
// GREEN("#228B22"),
// MINT("#98FB98"),
// TEAL("#008080"),
// CYAN("#00CED1"),
// SKY_BLUE("#1E90FF"),
// BLUE("#0000FF"),
// LAVENDER("#8A2BE2"),
// 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
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
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.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.repository.CategoryRepository
import cc.n0th1ng.tripmoney.data.repository.ExchangeRateRepository
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 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.map
import kotlinx.coroutines.launch
import java.time.LocalDate
import kotlinx.coroutines.runBlocking
import java.time.LocalDateTime
import javax.inject.Inject
@HiltViewModel
class ExpenseAndCategoryViewModel @Inject constructor(
open class ExpenseAndCategoryViewModel @Inject constructor(
private val expenseRepo: ExpenseRepository,
private val categoryRepo: CategoryRepository,
private val exchangeRateRepository: ExchangeRateRepository
private val exchangeRateRepository: ExchangeRateRepository,
private val tripRepo: TripRepository
) : ViewModel() {
fun getExpenses(tripId: Int): Flow<PagingData<ExpenseDto>> =
expenseRepo.getExpenses(tripId).cachedIn(viewModelScope)
expenseRepo.getExpensesPaged(tripId).cachedIn(viewModelScope)
fun save(expense: Expense) {
viewModelScope.launch {
@@ -51,9 +55,81 @@ class ExpenseAndCategoryViewModel @Inject constructor(
}
}
fun convertAmount(amount: Double, base: Currencies, target: Currencies, date: LocalDate): Flow<Double> {
return flow {
emit(amount * exchangeRateRepository.getRate(base, target, date))
@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 ->
// 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 cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.data.repository.PreferencesRepository
import cc.n0th1ng.tripmoney.utils.Currencies
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted
@@ -35,6 +36,17 @@ class SettingsViewModel @Inject constructor(
-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) {
viewModelScope.launch {
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 getTrip(tripId: Int): Trip? = repository.getTrip(tripId)
fun delete(trip: Trip) {
viewModelScope.launch {
repository.delete(trip)

View File

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

View File

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