This commit is contained in:
Rafal Wisniewski
2026-03-31 15:31:01 +02:00
parent 9b19b100e9
commit c4c9868698
37 changed files with 1539 additions and 475 deletions

View File

@@ -1,5 +1,8 @@
package cc.n0th1ng.tripmoney.screens
import android.graphics.drawable.Icon
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -8,14 +11,17 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -27,44 +33,68 @@ 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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import cc.n0th1ng.tripmoney.R
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
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[0]) }
fun AddCategoryDialog(
onDismiss: () -> Unit,
onSave: (Category) -> Unit,
categoryToEdit: Category? = null
) {
var name by remember { mutableStateOf(categoryToEdit?.name ?: "") }
var icon by remember { mutableStateOf(categoryToEdit?.icon ?: Icons.entries[0]) }
var color by remember { mutableStateOf(categoryToEdit?.color ?: colors[0]) }
var isArchived by remember { mutableStateOf(categoryToEdit?.archived ?: false) }
AlertDialog(
onDismissRequest = onDismiss, title = { Text("Add new category") }, text = {
onDismissRequest = onDismiss,
title = { Text(stringResource(if (categoryToEdit == null) R.string.add_new_category else R.string.edit_category)) },
text = {
AlertDialogFill(
onTextChange = { newText ->
name = newText
},
onIconChange = { newIcon -> icon = newIcon },
onColorChange = { newColor -> color = newColor }
onColorChange = { newColor -> color = newColor },
onArchivedChange = { newArchived ->
isArchived = newArchived
},
name = name,
icon = icon,
color = color,
isArchived = isArchived
)
}, confirmButton = {
},
confirmButton = {
Button(
enabled = !name.isEmpty(),
onClick = {
onSave(
Category(
name = name,
icon = icon,
color = color
)
val categoryToSave = Category(
name = name,
icon = icon,
color = color,
archived = isArchived
)
}) { Text("Save") }
onSave(
if (categoryToEdit != null) categoryToSave.copy(id = categoryToEdit.id) else categoryToSave
)
}) { Text(stringResource(R.string.save)) }
},
dismissButton = {
Button(
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.error),
onClick = onDismiss
) { Text("close") }
Row() {
Button(
colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.secondary),
onClick = onDismiss
) { Text(stringResource(R.string.cancel)) }
}
})
}
@@ -72,24 +102,26 @@ fun AddCategoryDialog(onDismiss: () -> Unit, onSave: (Category) -> Unit) {
fun AlertDialogFill(
onTextChange: (String) -> Unit,
onIconChange: (Icons) -> Unit,
onColorChange: (String) -> Unit
onColorChange: (String) -> Unit,
onArchivedChange: (Boolean) -> Unit,
name: String,
icon: Icons,
color: String,
isArchived: Boolean
) {
var text by remember { mutableStateOf("") }
var iconId by remember { mutableIntStateOf(Icons.entries[0].resource) }
var colorHex by remember { mutableStateOf(colors[0]) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(iconId), contentDescription = null,
tint = Color(colorHex.toColorInt())
painter = painterResource(icon.resource), contentDescription = null,
tint = Color(color.toColorInt())
)
OutlinedTextField(label = { Text("Name") }, value = text, onValueChange = { newText ->
text = newText
onTextChange(text)
OutlinedTextField(label = { Text("Name") }, value = name, onValueChange = { newText ->
onTextChange(newText)
})
}
@@ -104,7 +136,6 @@ fun AlertDialogFill(
modifier = Modifier
.size(30.dp)
.clickable(onClick = {
iconId = icon.resource
onIconChange(icon)
}),
painter = painterResource(icon.resource),
@@ -123,8 +154,7 @@ fun AlertDialogFill(
Box(
modifier = Modifier
.clickable(onClick = {
colorHex = color
onColorChange(colorHex)
onColorChange(color)
})
.size(30.dp)
.aspectRatio(1f)
@@ -132,5 +162,49 @@ fun AlertDialogFill(
) {}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = isArchived,
onCheckedChange = onArchivedChange
)
Text(
text = "Archived",
style = MaterialTheme.typography.titleMedium
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewAddCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewEditCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {},
categoryToEdit = Category(
0, "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
color = colors.random(),
archived = true
)
)
}
}

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -11,6 +12,7 @@ import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
@@ -224,7 +226,7 @@ fun AddExpenseBottomSheet(
) {
Text(
text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")),
fontSize = 17.sp
style = MaterialTheme.typography.titleMedium
)
}
CategoryButton(
@@ -265,6 +267,11 @@ fun AddExpenseBottomSheet(
equationResult = evaluate(amount)
enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0
},
onLongBackspaceClick = {
amount = "0.00"
equationResult = evaluate(amount)
enableSave = false
}
)
SaveButton(
@@ -394,7 +401,13 @@ fun NoteInput(
@Composable
fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) {
Button(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium) {
Button(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text(text)
}
}
@@ -402,18 +415,35 @@ fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: Str
@Composable
fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) {
Button(
contentPadding = PaddingValues(0.dp),
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors()
.copy(containerColor = Color(category.color.toColorInt()), contentColor = Color.Black)
.copy(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
// Row(modifier = modifier.fillMaxWidth()) {
Icon(
modifier = Modifier.padding(end = 10.dp),
tint = Color(category.color.toColorInt()),
modifier = Modifier
.size(30.dp)
// .background(
// color = MaterialTheme.colorScheme.prima,
// shape = MaterialTheme.shapes.small
// )
.padding(end = 10.dp),
painter = painterResource(category.icon.resource),
contentDescription = stringResource(R.string.category),
)
Text(category.name)
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium
)
// }
}
}
@@ -437,7 +467,8 @@ fun NumberKeyboard(
modifier: Modifier = Modifier,
onNumberClick: (String) -> Unit,
onBackspaceClick: () -> Unit,
onOperatorClick: (String) -> Unit
onOperatorClick: (String) -> Unit,
onLongBackspaceClick: () -> Unit,
) {
Column(
modifier = modifier,
@@ -455,22 +486,23 @@ fun NumberKeyboard(
onClick = onBackspaceClick,
modifier = Modifier
.weight(1f),
containerColor = MaterialTheme.colorScheme.primary
containerColor = Color.Transparent,
onLongClick = onLongBackspaceClick
)
"+", "/", "-", "*" -> KeyboardButton(
text = key,
onClick = { onOperatorClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
)
else -> KeyboardButton(
text = key,
onClick = { onNumberClick(key) },
modifier = Modifier.weight(1f),
containerColor = MaterialTheme.colorScheme.secondary,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSecondary
)
}
@@ -487,26 +519,24 @@ fun KeyboardButton(
icon: Painter? = null,
onClick: () -> Unit,
enabled: Boolean = true,
onLongClick: () -> Unit = {},
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary
) {
Button(
onClick = onClick,
shape = MaterialTheme.shapes.medium,
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(2.dp)
.aspectRatio(2.5f),
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor
)
) {
.aspectRatio(2.5f)
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.background(containerColor, shape = MaterialTheme.shapes.medium),
) {
when {
text != null -> Text(
text,
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.headlineMedium
)
icon != null -> Icon(painter = icon, contentDescription = null)
@@ -594,26 +624,31 @@ fun PreviewAddExpenseEnabled() {
val categoriesToPreview = listOf(
Category(
1,
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
2,
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
3,
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
4,
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
5,
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()

View File

@@ -28,7 +28,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.utils.Icons
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
@@ -37,7 +36,7 @@ fun CategorySelectionDialog(
onDismiss: () -> Unit,
onCategorySelected: (Category) -> Unit,
selected: Category,
categories: List<Category>
categories: List<Category>,
) {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val listState = rememberLazyListState()
@@ -91,7 +90,7 @@ fun CategorySelectionDialog(
contentDescription = stringResource(string.category)
)
Text(
text = stringResource(string.add_new_category), modifier = Modifier.padding(start = 8.dp),
text = stringResource(string.add_new), modifier = Modifier.padding(start = 8.dp),
)
}
}

View File

@@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -47,6 +46,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -58,33 +58,46 @@ import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Expense
import cc.n0th1ng.tripmoney.data.entity.ExpenseDto
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel.ExpenseListItemUi
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ListExpenseScreen() {
fun ListExpenseScreen(filter: String) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val expensesFlow = expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId)
val expensesFlow =
expenseAndCategoryViewModel.getExpensesWithHeadersPaged(currentTripId, filter)
val isRecalculatingRate by tripViewModel.isRecalculating.collectAsState()
ListExpenseScreen(
expensesFlow = expensesFlow,
onSaveExpense = { expenseAndCategoryViewModel.save(it, currentTrip!!) },
onDeleteExpense = { expenseAndCategoryViewModel.delete(it) },
isRecalculatingRate = isRecalculatingRate
)
}
@@ -94,7 +107,8 @@ fun ListExpenseScreen() {
@Composable
fun ListExpenseScreen(
expensesFlow: Flow<PagingData<ExpenseListItemUi>>,
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit
onSaveExpense: (Expense) -> Unit, onDeleteExpense: (Expense) -> Unit,
isRecalculatingRate: Boolean
) {
val items = expensesFlow.collectAsLazyPagingItems()
@@ -111,58 +125,52 @@ fun ListExpenseScreen(
)
})
{
if (items.loadState.refresh == LoadState.Loading) {
// Show loading indicator
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
items(
count = items.itemCount,
key = items.itemKey { item ->
when (item) {
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
is ExpenseListItemUi.Header -> "header_${item.date}"
}
Box {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState
) {
items(
count = items.itemCount,
key = items.itemKey { item ->
when (item) {
is ExpenseListItemUi.Item -> item.expenseDto.expense.id
is ExpenseListItemUi.Header -> "header_${item.date}"
}
) { index ->
}
) { index ->
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
}
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
when (val item = items[index]) {
is ExpenseListItemUi.Header -> {
CustomDivider(
date = item.date,
sum = item.sum,
currency = item.currency
)
}
Spacer(Modifier.height(10.dp))
is ExpenseListItemUi.Item -> {
SwipeToDeleteExpenseCard(
expenseDto = item.expenseDto,
onDelete = { expense -> itemToDelete = expense },
onClick = { expenseDto ->
expenseDtoToEdit = expenseDto
showBottomSheet = true
}
)
}
null -> {}
}
Spacer(Modifier.height(10.dp))
}
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
@@ -208,7 +216,7 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
date.format(
DateTimeFormatter.ofPattern("dd EEEE")
).toString(),
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
modifier = Modifier.padding(horizontal = 5.dp).background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
Row(
@@ -221,8 +229,14 @@ fun CustomDivider(date: LocalDate, sum: Double, currency: String) {
HorizontalDivider(modifier = Modifier.weight(2f))
Text(
"%.2f %s".format(sum, currency),
modifier = Modifier.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.bodyMedium
modifier = Modifier
.background(
MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.small
)
.padding(5.dp),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.bodySmall
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
@@ -256,7 +270,7 @@ fun SwipeToDeleteExpenseCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
@@ -329,7 +343,7 @@ fun ExpenseCard(
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.secondaryContainer),
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
@@ -344,14 +358,29 @@ fun ExpenseCard(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
//TODO
// .background(
// Brush.horizontalGradient(
// colorStops = arrayOf(
// 1f to Color(expenseDto.category.color.toColorInt()),
// 4f to MaterialTheme.colorScheme.surfaceDim
// )
// )
// )
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxHeight()
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(expenseDto.category.icon.resource),
contentDescription = "Category",
tint = Color(expenseDto.category.color.toColorInt())
@@ -367,13 +396,13 @@ fun ExpenseCard(
Text(
text = expenseDto.category.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
Text(
modifier = Modifier.padding(0.dp),
text = expenseDto.expense.note,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
}
@@ -382,7 +411,7 @@ fun ExpenseCard(
DateTimeFormatter.ofPattern("dd MMM HH:mm")
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -390,7 +419,7 @@ fun ExpenseCard(
Text(
text = "- %.2f ${expenseDto.expense.currency}".format(expenseDto.expense.amount),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
@@ -398,7 +427,7 @@ fun ExpenseCard(
Text(
text = "≈ %.2f ${expenseDto.trip.currency}".format(expenseDto.expense.convertedAmount()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSurface
)
}
@@ -407,107 +436,125 @@ fun ExpenseCard(
}
}
//@RequiresApi(Build.VERSION_CODES.O)
//@AllPreviews
//@Composable
//fun PreviewListExpenseScreen() {
// TripMoneyTheme() {
// val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
// ListExpenseScreen(
// expensesDtoFlow = MutableStateFlow(pagingData),
// onSaveExpense = {},
// onDeleteExpense = {},
// dailySums = emptyMap()
// )
//
// }
//}
//
//@AllPreviews
//@Composable
//fun PreviewDeleteConfirmationDialog() {
// TripMoneyTheme() {
// DeleteConfirmationDialog(
// onConfirm = {},
// onCancel = {})
// }
//}
//
//
//@RequiresApi(Build.VERSION_CODES.O)
//private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseDto> {
// val sampleCategories = listOf(
// Category(
// name = "Hotel",
// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
// color = colors.random()
// ),
// Category(
// name = "Jedzenie",
// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
// color = colors.random()
// ),
// Category(
// name = "Transport",
// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
// color = colors.random()
// ),
// Category(
// name = "Rozrywka",
// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
// color = colors.random()
// ),
// Category(
// name = "Zakupy",
// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
// color = colors.random()
// ),
// )
//
// val trip = Trip(
// id = 1,
// name = "Vacation",
// currency = "USD",
// startDate = LocalDate.parse("2026-01-01")
// )
//
// val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
// val endLong = LocalDateTime.now().toEpochMilli()
//
// val result: MutableList<ExpenseDto> = mutableListOf()
// for (i in 0..15) {
// val category = sampleCategories.random()
// val datetime = if (i > 4) {
// LocalDateTime.ofEpochSecond(
// Random.nextLong(startLong, endLong),
// 0,
// ZoneOffset.UTC
// )
// } else LocalDateTime.now()
//
// val expense = Expense(
// id = i,
// categoryId = category.id,
// tripId = 1,
// amount = Random.nextDouble(0.1, 300.0),
// currency = Currencies.entries.random().name,
// note = if (i % 3 == 0) "Some note" else "",
// datetime = datetime,
// rate = if (Random.nextBoolean()) Random.nextDouble(
// 0.1,
// 5.0
// ) else 1.0
// )
//
//
// val expenseDto = ExpenseDto(
// expense = expense,
// category = category,
// trip = trip
// )
// result.add(
// expenseDto
// )
// }
// return result
//}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewListExpenseScreen() {
TripMoneyTheme() {
val pagingData = PagingData.from(sampleExpenseDtoWithConvertedAmountList())
ListExpenseScreen(
expensesFlow = MutableStateFlow(pagingData),
onSaveExpense = {},
onDeleteExpense = {},
true
)
}
}
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme() {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun sampleExpenseDtoWithConvertedAmountList(): List<ExpenseListItemUi> {
val sampleCategories = listOf(
Category(
name = "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL,
color = colors.random()
),
Category(
name = "Jedzenie",
icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT,
color = colors.random()
),
Category(
name = "Transport",
icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT,
color = colors.random()
),
Category(
name = "Rozrywka",
icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION,
color = colors.random()
),
Category(
name = "Zakupy",
icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES,
color = colors.random()
),
)
val trip = Trip(
id = 1,
name = "Vacation",
currency = "USD",
startDate = LocalDate.parse("2026-01-01")
)
val startLong = LocalDateTime.now().minusDays(10).toEpochMilli()
val endLong = LocalDateTime.now().toEpochMilli()
val result: MutableList<ExpenseListItemUi> = mutableListOf()
result.add(
ExpenseListItemUi.Header(
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
).toLocalDate(), Random.nextDouble(0.1, 300.0), Currencies.entries.random().name
)
)
for (i in 0..15) {
val category = sampleCategories.random()
val datetime = if (i > 4) {
LocalDateTime.ofEpochSecond(
Random.nextLong(startLong, endLong),
0,
ZoneOffset.UTC
)
} else LocalDateTime.now()
val expense = Expense(
id = i,
categoryId = category.id,
tripId = 1,
amount = Random.nextDouble(0.1, 300.0),
currency = Currencies.entries.random().name,
note = if (i % 3 == 0) "Some note" else "",
datetime = datetime,
rate = if (Random.nextBoolean()) Random.nextDouble(
0.1,
5.0
) else 1.0
)
val expenseDto = ExpenseDto(
expense = expense,
category = category,
trip = trip
)
result.add(
ExpenseListItemUi.Item(expenseDto)
)
if (i % 5 == 0) {
result.add(
ExpenseListItemUi.Header(
datetime.toLocalDate(),
Random.nextDouble(0.1, 300.0),
Currencies.entries.random().name
)
)
}
}
return result
}

View File

@@ -0,0 +1,431 @@
package cc.n0th1ng.tripmoney.screens.managecategories
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.screens.AddCategoryDialog
import cc.n0th1ng.tripmoney.screens.addexpense.categoriesToPreview
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.colors
import cc.n0th1ng.tripmoney.viewmodel.ExpenseAndCategoryViewModel
import com.composables.icons.materialsymbols.outlined.R
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.collections.emptyList
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ManageCategoriesScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val archivedCategories by expenseAndCategoryViewModel.getArchivedCategories()
.collectAsState(emptyList())
ManageCategoriesScreen(
categories = categories,
archivedCategories = archivedCategories,
onSaveCategory = { expenseAndCategoryViewModel.save(it) },
onDeleteCategory = {
expenseAndCategoryViewModel.delete(it)
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun ManageCategoriesScreen(
categories: List<Category>,
archivedCategories: List<Category>,
onSaveCategory: (Category) -> Unit,
onDeleteCategory: (Category) -> Unit,
) {
var categoryToEdit by remember { mutableStateOf<Category?>(null) }
var showAddCategoryDialog by remember { mutableStateOf(false) }
var itemToDelete by remember { mutableStateOf<Category?>(null) }
var itemToArchive by remember { mutableStateOf<Category?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showAddCategoryDialog = true },
icon = { Icon(Icons.Filled.Add, stringResource(string.add_new)) },
text = { Text(text = stringResource(string.add_new)) },
)
})
{
LazyColumn(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
items(categories, key = { it.id }) { category ->
SwipeToDeleteExpenseCard(
category = category,
onDelete = { itemToArchive = category },
onClick = {
categoryToEdit = category
showAddCategoryDialog = true
}
)
Spacer(Modifier.height(10.dp))
}
if (archivedCategories.isNotEmpty()) {
item {
CustomDivider()
Spacer(modifier = Modifier.height(10.dp))
}
}
items(archivedCategories, key = { it.id }) { archivedCategory ->
SwipeToDeleteExpenseCard(
category = archivedCategory,
onDelete = { itemToDelete = archivedCategory },
onClick = {
categoryToEdit = archivedCategory
showAddCategoryDialog = true
},
isArchived = true
)
Spacer(Modifier.height(10.dp))
}
}
if (showAddCategoryDialog) {
AddCategoryDialog(
onDismiss = {
showAddCategoryDialog = false
}, onSave = { category ->
onSaveCategory(category)
showAddCategoryDialog = false
},
categoryToEdit = categoryToEdit
)
}
}
if (itemToDelete != null) {
DeleteConfirmationDialog(
bodyText = stringResource(string.delete_category_info),
onConfirm = {
onDeleteCategory(itemToDelete!!)
itemToDelete = null
},
onCancel = {
itemToDelete = null
}
)
}
if (itemToArchive != null) {
DeleteConfirmationDialog(
title = stringResource(string.you_want_archive),
buttonText = stringResource(string.archive),
bodyText = stringResource(string.archive_category_info),
onConfirm = {
onSaveCategory(itemToArchive!!.copy(archived = true))
itemToArchive = null
},
onCancel = {
itemToArchive = null
}
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun CustomDivider() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.Center,
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
"Archived",
modifier = Modifier
.padding(horizontal = 5.dp)
.background(Color.White.copy(alpha = 0f)),
style = MaterialTheme.typography.titleMedium
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun SwipeToDeleteExpenseCard(
category: Category,
onDelete: (Category) -> Unit,
onClick: (Category) -> Unit,
isArchived: Boolean = false
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { dismissValue ->
if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
onDelete(category)
false
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
Box(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
painter = painterResource(
if (isArchived) R.drawable.materialsymbols_ic_delete_outlined
else R.drawable.materialsymbols_ic_archive_outlined
),
contentDescription = stringResource(string.delete)
)
}
}
) {
CategoryCard(
category = category,
onClick = onClick,
isArchived = isArchived
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeleteConfirmationDialog(
title: String = stringResource(string.delete_confirmation),
buttonText: String = stringResource(string.delete),
bodyText: String = "",
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
BasicAlertDialog(
onDismissRequest = { onCancel() }
) {
Column(
Modifier
.background(
MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium
)
.padding(24.dp)
) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = bodyText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
Button(
onClick = onCancel,
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.secondary),
modifier = Modifier
.padding(end = 10.dp)
){
Text(text = stringResource(string.cancel),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondary)
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors().copy(containerColor = MaterialTheme.colorScheme.error),
){
Text(text = buttonText,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onError)
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CategoryCard(
category: Category,
onClick: (Category) -> Unit,
isArchived: Boolean = false
) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth(0.9f)
.height(70.dp)
.combinedClickable(
enabled = true,
onClick = { onClick(category) },
onLongClick = { onClick(category) }),
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.alpha(if (isArchived) 0.6f else 1f)
.padding(horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(15.dp),
modifier = Modifier.fillMaxHeight()
) {
Icon(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceDim,
shape = MaterialTheme.shapes.small
)
.padding(10.dp),
painter = painterResource(category.icon.resource),
contentDescription = "Category",
tint = Color(category.color.toColorInt())
)
Column()
{
Text(
text = category.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewManageCategoriesScreen() {
TripMoneyTheme {
ManageCategoriesScreen(categories = categoriesToPreview.subList(0,2), categoriesToPreview.subList(3,5), {}, {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewAddCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {})
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewEditCategoryDialog() {
TripMoneyTheme {
AddCategoryDialog(
onDismiss = {},
onSave = {},
categoryToEdit = Category(
0, "Hotel",
icon = cc.n0th1ng.tripmoney.utils.Icons.entries.random(),
color = colors.random(),
archived = false
)
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewDeleteConfirmationDialog() {
TripMoneyTheme {
DeleteConfirmationDialog(
onConfirm = {},
onCancel = {},
bodyText = "Your all expenses with category Hotel will be removed.",
title = "Do you want to delete?",
buttonText = "Delete"
)
}
}

View File

@@ -38,10 +38,16 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import cc.n0th1ng.tripmoney.R.*
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.data.repository.AppTheme
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.listexpense.CategorySelectionDialog
import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog
import cc.n0th1ng.tripmoney.screens.statistics.categories
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.utils.Currencies
@@ -60,7 +66,7 @@ import java.nio.file.Files
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun SettingsScreen() {
fun SettingsScreen(navController: NavHostController) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val currentTheme by settingsViewModel.theme.collectAsState()
val currentDefaultCurrency by settingsViewModel.defaultCurrency.collectAsState()
@@ -68,6 +74,7 @@ fun SettingsScreen() {
val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
val currentTrip by tripViewModel.getTrip(currentTripId).collectAsState(Trip.DUMMY)
val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList())
val context = LocalContext.current
val tripName = currentTrip?.name ?: ""
val scope = rememberCoroutineScope()
@@ -90,7 +97,8 @@ fun SettingsScreen() {
e.printStackTrace()
}
}
}
},
onCategoriesClick = {navController.navigate(Screens.MANAGE_CATEGORIES)}
)
}
@@ -103,11 +111,13 @@ fun SettingsScreen(
onCurrencySave: (Currencies) -> Unit,
tripName: String,
onExportToCsv: () -> Unit,
onCategoriesClick: () -> Unit
) {
Scaffold { padding ->
var showThemeDialog by remember { mutableStateOf(false) }
var showCurrencyDialog by remember { mutableStateOf(false) }
var showCategoriesDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -142,9 +152,15 @@ fun SettingsScreen(
SettingsListItem(
onClick = onExportToCsv,
stringResource(string.export_to_csv),
supportingText = "Save expenses from %s to a file".format(tripName),
supportingText = stringResource(string.export_csv_subttext).format(tripName),
iconResource = R.drawable.materialsymbols_ic_csv_outlined
)
SettingsListItem(
onClick = onCategoriesClick,
stringResource(string.categories),
supportingText = stringResource(string.manage_categories),
iconResource = R.drawable.materialsymbols_ic_label_outlined
)
if (showThemeDialog) {
ThemeSelectionDialog(
@@ -258,7 +274,14 @@ fun ThemeSelectionDialog(
@Composable
fun PreviewSettingsScreen() {
TripMoneyTheme {
SettingsScreen(Currencies.entries.random(), AppTheme.entries.random(), {}, {}, "Włochy", {})
SettingsScreen(
Currencies.entries.random(),
AppTheme.entries.random(),
{},
{},
"Włochy",
{},
{})
}
}

View File

@@ -1,5 +1,6 @@
package cc.n0th1ng.tripmoney.screens.statistics
import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
@@ -13,9 +14,15 @@ 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.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -30,6 +37,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.dto.SummaryPerCategory
import cc.n0th1ng.tripmoney.data.entity.Category
import cc.n0th1ng.tripmoney.data.entity.Trip
@@ -82,9 +90,10 @@ fun StatisticsScreen(
@Composable
fun Summary(summaryAmount: Double, currency: String) {
Card(
modifier = Modifier
.fillMaxWidth()
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Row(
modifier = Modifier
@@ -94,7 +103,10 @@ fun Summary(summaryAmount: Double, currency: String) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall)
Text(
stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses),
style = MaterialTheme.typography.titleSmall
)
Text(
"%.2f %s".format(summaryAmount, currency),
style = MaterialTheme.typography.headlineLarge
@@ -118,7 +130,11 @@ fun Summary(summaryAmount: Double, currency: String) {
@Composable
fun SummaryPerCategoryCard(summaryPerCategoryList: List<SummaryPerCategory>) {
Card(modifier = Modifier.fillMaxWidth()) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainer)
) {
Column(
modifier = Modifier.padding(15.dp),
verticalArrangement = Arrangement.spacedBy(5.dp)
@@ -191,16 +207,19 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun Preview() {
TripMoneyTheme {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random()
)
Scaffold {
StatisticsScreen(
summaryPerCategoryList,
summaryAmount = 125.24,
Currencies.entries.random()
)
}
}
}

View File

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -21,7 +20,6 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -50,16 +48,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cc.n0th1ng.tripmoney.R.string
import cc.n0th1ng.tripmoney.data.entity.Trip
import cc.n0th1ng.tripmoney.navigation.Screens
import cc.n0th1ng.tripmoney.screens.addexpense.AddExpenseBottomSheet
import cc.n0th1ng.tripmoney.screens.listexpense.DeleteConfirmationDialog
import cc.n0th1ng.tripmoney.theme.TripMoneyTheme
import cc.n0th1ng.tripmoney.utils.AllPreviews
import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel
import cc.n0th1ng.tripmoney.viewmodel.TripViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@@ -70,9 +73,38 @@ fun TripPickerScreen(
) {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val tripViewModel: TripViewModel = hiltViewModel()
var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripViewModel.getTrips().collectAsLazyPagingItems()
val tripsFlow = tripViewModel.getTrips()
val currentTripId by settingsViewModel.currentTrip.collectAsState()
TripPickerScreen(
tripsFlow = tripsFlow,
currentTripId = currentTripId,
onDelete = { trip -> tripViewModel.delete(trip) },
onClick = { trip ->
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
},
onSave = { trip ->
tripViewModel.save(trip)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.O)
@Composable
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
fun TripPickerScreen(
tripsFlow: Flow<PagingData<Trip>>,
currentTripId: Int,
onDelete: (Trip) -> Unit,
onClick: (Trip) -> Unit,
onSave: (Trip) -> Unit
) {
var showBottomSheet by remember { mutableStateOf(false) }
val trips: LazyPagingItems<Trip> = tripsFlow.collectAsLazyPagingItems()
var tripToEdit by remember { mutableStateOf<Trip?>(null) }
Scaffold(floatingActionButtonPosition = FabPosition.EndOverlay, floatingActionButton = {
FloatingActionButton(
@@ -91,12 +123,12 @@ fun TripPickerScreen(
val trip = trips[i]
if (trip != null) {
SwipeToDeleteTripCard(
trip, onDelete = {
tripViewModel.delete(trip)
}, onClick = {
settingsViewModel.setCurrentTrip(trip.id)
navController.navigate(Screens.LIST_EXPENSE)
}, isSelected = currentTripId == trip.id,
trip = trip,
onDelete = {
onDelete(trip)
}, onClick = {
onClick(trip)
}, isSelected = currentTripId == trip.id,
onLongClick = { trip ->
tripToEdit = trip
showBottomSheet = true
@@ -113,7 +145,7 @@ fun TripPickerScreen(
tripToEdit = null
},
onSave = { trip ->
tripViewModel.save(trip)
onSave(trip)
showBottomSheet = false
tripToEdit = null
},
@@ -152,7 +184,6 @@ fun SwipeToDeleteTripCard(
}
SwipeToDismissBox(
modifier = Modifier.alpha(if (isSelected) 1.0f else 0.7f),
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = {
@@ -160,7 +191,7 @@ fun SwipeToDeleteTripCard(
Modifier
.clip(CardDefaults.elevatedShape)
.fillMaxSize()
.background(MaterialTheme.colorScheme.onError)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
@@ -186,7 +217,7 @@ fun TripCard(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
MaterialTheme.colorScheme.surfaceContainer
}
),
modifier = Modifier
@@ -195,7 +226,7 @@ fun TripCard(
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(trip)
}, onClick = { onClick(trip) }),
elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 7.dp else 0.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 7.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -216,4 +247,39 @@ fun TripCard(
)
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
@AllPreviews
@Composable
fun PreviewTripPickerScreen() {
val tripsToPreview = listOf(
Trip(
1,
name = "Włochy",
startDate = LocalDate.parse("2026-03-01"),
currency = "PLN"
),
Trip(
2,
name = "Szwajcaria",
startDate = LocalDate.parse("2025-03-01"),
currency = "EUR"
),
Trip(
3,
name = "Portugalia",
startDate = LocalDate.parse("2025-03-01"),
currency = "USD"
)
)
TripMoneyTheme {
TripPickerScreen(
tripsFlow = MutableStateFlow(PagingData.from(tripsToPreview)),
currentTripId = 1,
onDelete = {},
onClick = {},
onSave = {}
)
}
}