diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ec2c5c..c485d7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,4 +102,5 @@ dependencies { implementation("io.ktor:ktor-client-core:3.4.1") implementation("io.ktor:ktor-client-okhttp:3.4.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0") + implementation("org.apache.commons:commons-csv:1.5") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ad5229..e16262c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt index fe912c8..3ff77a1 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/CustomNavigationDrawer.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import cc.n0th1ng.tripmoney.R.string import com.composables.icons.materialsymbols.outlined.R import kotlinx.coroutines.launch @@ -32,7 +33,7 @@ fun CustomNavigationDrawer( Text("Trip Money", modifier = Modifier.padding(16.dp)) HorizontalDivider() NavigationDrawerItem( - label = { Text(text = "Pick trip") }, + label = { Text(text = stringResource(string.pick_trip)) }, selected = false, onClick = { navController.navigate(Screens.TRIP_PICKER) @@ -48,7 +49,7 @@ fun CustomNavigationDrawer( ) }) NavigationDrawerItem( - label = { Text(text = "List of expenses") }, + label = { Text(text = stringResource(string.list_of_expenses)) }, selected = false, onClick = { navController.navigate(Screens.LIST_EXPENSE) @@ -64,7 +65,7 @@ fun CustomNavigationDrawer( ) }) NavigationDrawerItem( - label = { Text(text = "Statistics") }, + label = { Text(text = stringResource(string.statistics)) }, selected = false, onClick = { navController.navigate(Screens.STATISTICS) @@ -80,7 +81,7 @@ fun CustomNavigationDrawer( ) }) NavigationDrawerItem( - label = { Text(text = "Settings") }, + label = { Text(text = stringResource(string.settings)) }, selected = false, onClick = { navController.navigate(Screens.SETTINGS) @@ -88,7 +89,7 @@ fun CustomNavigationDrawer( drawerState.close() } }, - icon = { Icon(Icons.Default.Settings, contentDescription = "settings") } + icon = { Icon(Icons.Default.Settings, contentDescription = stringResource(string.settings)) } ) } }) { content() } diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt index 5c2a69a..fe8eb9e 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/navigation/TopBar.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController +import cc.n0th1ng.tripmoney.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,7 +32,7 @@ fun TopBar(onClick: () -> Unit, title: String = "") { fun TopBarSettings(navController: NavHostController) { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt index 7e0e350..c1802d6 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/addexpense/AddExpenseBottomSheet.kt @@ -1,6 +1,7 @@ package cc.n0th1ng.tripmoney.screens.addexpense import android.annotation.SuppressLint +import android.graphics.drawable.PaintDrawable import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.clickable @@ -21,9 +22,14 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DividerDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -34,6 +40,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,10 +49,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout +import androidx.compose.ui.modifier.modifierLocalMapOf import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.sensitiveContent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -59,21 +69,24 @@ import cc.n0th1ng.tripmoney.R 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.listexpense.CategorySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.CurrencySelectionDialog import cc.n0th1ng.tripmoney.screens.listexpense.DateTimePicker 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.SettingsViewModel import cc.n0th1ng.tripmoney.viewmodel.TripViewModel +import com.composables.icons.materialsymbols.outlined.R.drawable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter - - +import kotlin.collections.listOf @OptIn(ExperimentalMaterial3Api::class) @RequiresApi(Build.VERSION_CODES.O) @@ -88,23 +101,48 @@ fun AddExpenseBottomSheet( val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel() val currentTripId by settingsViewModel.currentTrip.collectAsState() - val currentTrip = tripViewModel.getTrip(currentTripId) + val currentTrip = tripViewModel.getTrip(currentTripId)!! val categories by expenseAndCategoryViewModel.getCategories().collectAsState(emptyList()) + AddExpenseBottomSheet( + onSave = onSave, + onDismiss = onDismiss, + expenseDtoToEdit = expenseDtoToEdit, + state = state, + currentTrip = currentTrip, + categories = categories + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun AddExpenseBottomSheet( + onSave: (Expense) -> Unit, + onDismiss: () -> Unit, + expenseDtoToEdit: ExpenseDto?, + state: SheetState, + currentTrip: Trip, + categories: List +) { + val currentTripId = currentTrip.id + if (categories.isEmpty()) { return } + var amount by remember { mutableStateOf( expenseDtoToEdit?.expense?.amount?.toString() ?: "0.00" ) } + var equationResult by remember { mutableDoubleStateOf(0.0) } val dummyFocusRequester = remember { FocusRequester() } var showCurrencyDialog by remember { mutableStateOf(false) } var showCategoryDialog by remember { mutableStateOf(false) } var showDateTimePicker by remember { mutableStateOf(false) } var currency by remember { mutableStateOf( - expenseDtoToEdit?.expense?.currency ?: currentTrip?.currency ?: Currencies.default().name + expenseDtoToEdit?.expense?.currency ?: currentTrip.currency ) } var category by remember { mutableStateOf(expenseDtoToEdit?.category ?: categories[0]) } @@ -122,6 +160,8 @@ fun AddExpenseBottomSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = state, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) { Column( modifier = Modifier @@ -135,96 +175,127 @@ fun AddExpenseBottomSheet( .fillMaxWidth() .padding(start = 15.dp), text = stringResource(if (expenseDtoToEdit == null) R.string.add_expense else R.string.edit_expense), - fontWeight = FontWeight.Bold, - fontSize = 35.sp, + style = MaterialTheme.typography.displaySmall, textAlign = TextAlign.Start ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(0.9f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(10.dp) ) { - Text( - text = amount.ifEmpty { "0.00" }, - fontSize = 25.sp, - fontWeight = FontWeight.Bold - ) - CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency) - } - Row( - modifier = Modifier.fillMaxWidth(0.9f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - OutlinedButton( - onClick = { showDateTimePicker = true }, - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")), - fontSize = 17.sp - ) + Column{ + Text( + text = amount.ifEmpty { "0.00" }, + fontSize = 25.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = if(amount.contains(Regex("[+\\/*-]\\d+"))) "%.2f".format(equationResult) else "", + fontSize = 14.sp, + ) + } + CurrencyButton(onClick = { showCurrencyDialog = true }, text = currency) } - CategoryButton( - onClick = { showCategoryDialog = true }, - category = category, - modifier = Modifier.weight(1f) + Box( + modifier = Modifier + .size(0.dp) + .focusRequester(dummyFocusRequester) + .focusable() ) - - } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { NoteInput( note = note, onTextChange = { newNote -> note = newNote }, - modifier = Modifier.fillMaxWidth(0.9f), + modifier = Modifier.fillMaxWidth(), focusRequester = dummyFocusRequester ) - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + onClick = { showDateTimePicker = true }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.medium, - Box( - modifier = Modifier - .size(0.dp) - .focusRequester(dummyFocusRequester) - .focusable() - ) - NumberKeyboard( - onNumberClick = { number -> - val newText = (if (amount == "0.00") "" else amount) + number - if (newText.isDoubleTwoDigitsAboveZero()) { - amount = newText - enableSave = true - } else if (amount == "0.00") { - enableSave = false + ) { + Text( + text = datetime.format(DateTimeFormatter.ofPattern("dd.MM HH:mm")), + fontSize = 17.sp + ) } - dummyFocusRequester.requestFocus() - }, - onBackspaceClick = { - if (amount == "0.00") return@NumberKeyboard - amount = amount.safeSubstring(0, amount.length - 1) - enableSave = amount.isDoubleTwoDigitsAboveZero() - }, - onSave = { - val expenseToSave = Expense( - amount = amount.toDouble(), - currency = currency, - note = note, - datetime = datetime.toString(), - categoryId = category.id, - tripId = currentTripId + CategoryButton( + onClick = { showCategoryDialog = true }, + category = category, + modifier = Modifier.weight(1f) ) - onSave( - if (expenseDtoToEdit == null) expenseToSave - else expenseToSave.copy(id = expenseDtoToEdit.expense.id) - ) - }, enableSave = enableSave - ) + } + + NumberKeyboard( + modifier = Modifier.fillMaxWidth(), + onOperatorClick = { operator -> + if(amount.isDoubleTwoDigitsOrEquation() && amount.contains(Regex("[+\\/*-]\\d+"))) { + amount = evaluate(amount).toString() +// equationResult = 0.0 + } + val newText = amount + operator + if(newText.isDoubleTwoDigitsOrEquation()) { + amount = newText + enableSave = false + } + }, + onNumberClick = { number -> + val newText = (if (amount == "0.00") "" else amount) + number + if (newText.isDoubleTwoDigitsOrEquation()) { + amount = newText + equationResult = evaluate(amount) + enableSave = equationResult > 0 + } else if (amount == "0.00") { + enableSave = false + } + dummyFocusRequester.requestFocus() + }, + onBackspaceClick = { + if (amount == "0.00") return@NumberKeyboard + amount = amount.safeSubstring(0, amount.length - 1) + enableSave = amount.isDoubleTwoDigitsOrEquation() + equationResult = evaluate(amount) + enableSave = amount.isDoubleTwoDigitsOrEquation() && equationResult > 0 + }, + ) + + SaveButton( + modifier = Modifier.fillMaxWidth(), + enabled = enableSave, + onClick = { + val expenseToSave = Expense( + amount = equationResult, + currency = currency, + note = note, + datetime = datetime.toString(), + categoryId = category.id, + tripId = currentTripId + ) + onSave( + if (expenseDtoToEdit == null) expenseToSave + else expenseToSave.copy(id = expenseDtoToEdit.expense.id) + ) + }) + } } } + if (showDateTimePicker) { DateTimePicker(datetime, onChange = { newDateTime -> datetime = newDateTime @@ -264,12 +335,50 @@ fun String.safeSubstring(start: Int, end: Int): String { } } -fun String.isDoubleTwoDigitsAboveZero(): Boolean { - return this.toDoubleOrNull() != null && this.matches(Regex("^\\d*(\\.\\d{0,2})?$")) && this.toDouble() > 0 +private fun evaluate(equation: String): Double { + if (equation.isEmpty()) return 0.0 + + val operatorIndex = equation.indexOfFirstIndexed { i, c -> + i != 0 && c in "+-*/" + } + + if (operatorIndex == -1) return equation.toDouble() + + val leftString = equation.substring(0, operatorIndex) + val rightString = equation.substring(operatorIndex + 1) + + if (leftString.isEmpty() || rightString.isEmpty()) return 0.0 + + val left = leftString.toDouble() + val right = rightString.toDouble() + + return when (equation[operatorIndex]) { + '+' -> left + right + '-' -> left - right + '*' -> left * right + '/' -> left / right + else -> 0.0 + } +} + +private inline fun String.indexOfFirstIndexed(predicate: (index: Int, Char) -> Boolean): Int { + for (i in indices) { + if (predicate(i, this[i])) return i + } + return -1 +} + +private fun String.isDoubleTwoDigitsOrEquation(): Boolean { + return this != "0.00" && this.matches(Regex("^(-?(0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?))([+\\/*-](0\\.?|0\\.\\d{1,2}|[1-9]\\d*(\\.\\d{0,2})?)?)?$")) } @Composable -fun NoteInput(note: String, onTextChange: (String) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester) { +fun NoteInput( + note: String, + onTextChange: (String) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester +) { var text by remember { mutableStateOf(note) } OutlinedTextField( @@ -292,33 +401,36 @@ fun NoteInput(note: String, onTextChange: (String) -> Unit, modifier: Modifier = @Composable fun CurrencyButton(modifier: Modifier = Modifier, onClick: () -> Unit, text: String) { - OutlinedButton(onClick = onClick, modifier = modifier) { + Button(onClick = onClick, modifier = modifier, shape = MaterialTheme.shapes.medium) { Text(text) } } @Composable fun CategoryButton(onClick: () -> Unit, category: Category, modifier: Modifier = Modifier) { - OutlinedButton( + Button( onClick = onClick, - modifier = modifier + modifier = modifier, + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors() + .copy(containerColor = Color(category.color.toColorInt()), contentColor = Color.Black) ) { Icon( modifier = Modifier.padding(end = 10.dp), painter = painterResource(category.icon.resource), contentDescription = stringResource(R.string.category), - tint = Color(category.color.toColorInt()) ) - Text(category.name, color = Color(category.color.toColorInt())) + Text(category.name) } } @Composable -fun SaveButton(enabled: Boolean, onClick: () -> Unit) { - OutlinedButton( +fun SaveButton(modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit) { + Button( onClick = onClick, enabled = enabled, - modifier = Modifier + modifier = modifier, + shape = MaterialTheme.shapes.medium ) { Icon( imageVector = Icons.Filled.Check, @@ -332,273 +444,171 @@ fun NumberKeyboard( modifier: Modifier = Modifier, onNumberClick: (String) -> Unit, onBackspaceClick: () -> Unit, - onSave: () -> Unit, - enableSave: Boolean + onOperatorClick: (String) -> Unit ) { - val buttonModifier = Modifier - .padding(4.dp) - .aspectRatio(2f) - Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextButton( - onClick = { onNumberClick("1") }, - modifier = buttonModifier.weight(1f) + keyboard.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Text("1", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("2") }, - modifier = buttonModifier.weight(1f) - ) { - Text("2", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("3") }, - modifier = buttonModifier.weight(1f) - ) { - Text("3", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("") }, - modifier = buttonModifier.weight(1f) - ) { - Text("+", fontSize = 20.sp) - } - } + row.forEach { key -> + when (key) { + "backspace" -> KeyboardButton( + icon = painterResource(drawable.materialsymbols_ic_arrow_left_alt_outlined), + onClick = onBackspaceClick, + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.primary + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextButton( - onClick = { onNumberClick("4") }, - modifier = buttonModifier.weight(1f) - ) { - Text("4", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("5") }, - modifier = buttonModifier.weight(1f) - ) { - Text("5", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("6") }, - modifier = buttonModifier.weight(1f) - ) { - Text("6", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("") }, - modifier = buttonModifier.weight(1f) - ) { - Text("-", fontSize = 20.sp) - } - } + "+", "/", "-", "*" -> KeyboardButton( + text = key, + onClick = { onOperatorClick(key) }, + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextButton( - onClick = { onNumberClick("7") }, - modifier = buttonModifier.weight(1f) - ) { - Text("7", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("8") }, - modifier = buttonModifier.weight(1f) - ) { - Text("8", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("9") }, - modifier = buttonModifier.weight(1f) - ) { - Text("9", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("") }, - modifier = buttonModifier.weight(1f) - ) { - Text("*", fontSize = 20.sp) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextButton( - onClick = { onNumberClick(".") }, - modifier = buttonModifier.weight(1f) - ) { - Text(".", fontSize = 20.sp) - } - TextButton( - onClick = { onNumberClick("0") }, - modifier = buttonModifier.weight(1f) - ) { - Text("0", fontSize = 20.sp) - } - TextButton( - onClick = onBackspaceClick, - modifier = buttonModifier.weight(1f) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.backspace) - ) - } - TextButton( - onClick = onSave, - modifier = buttonModifier.weight(1f), - enabled = enableSave - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.backspace) - ) + else -> KeyboardButton( + text = key, + onClick = { onNumberClick(key) }, + modifier = Modifier.weight(1f), + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary + ) + } + } } } } } +@Composable +fun KeyboardButton( + text: String? = null, + icon: Painter? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary +) { + Button( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + modifier = modifier + .padding(4.dp) + .aspectRatio(2.5f), + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + when { + text != null -> Text( + text, + style = MaterialTheme.typography.titleMedium + ) -//@SuppressLint("CoroutineCreationDuringComposition") -//@RequiresApi(Build.VERSION_CODES.O) -//@OptIn(ExperimentalMaterial3Api::class) -//@Preview -//@Composable -//fun PreviewLight() { -// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) -// CoroutineScope(Dispatchers.IO).launch { -// sheetState.show() -// } -// -// TripMoneyTheme { -// AddExpenseBottomSheet( -// {}, {}, null, sheetState, -// categories = listOf( -// Category( -// name = "Hotel", -// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, -// color = "#B3E5FC" -// ), -// Category( -// name = "Jedzenie", -// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, -// color = "#C8E6C9" -// ), -// Category( -// name = "Transport", -// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, -// color = "#FFCDD2" -// ), -// Category( -// name = "Rozrywka", -// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, -// color = "#FFF9C4" -// ), -// Category( -// name = "Zakupy", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#E1BEE7" -// ), -// Category( -// name = "Zakupy1", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#D7CCC8" -// ), -// Category( -// name = "Zakupy2", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#BBDEFB" -// ), -// Category( -// name = "Zakupy3", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#D1C4E9" -// ), -// Category( -// name = "Zakupy4", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#DCEDC8" -// ), -// ) -// ) -// } -//} -// -//@SuppressLint("CoroutineCreationDuringComposition") -//@RequiresApi(Build.VERSION_CODES.O) -//@OptIn(ExperimentalMaterial3Api::class) -//@Preview -//@Composable -//fun PreviewDark() { -// val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) -// CoroutineScope(Dispatchers.IO).launch { -// sheetState.show() -// } -// -// TripMoneyTheme(darkTheme = true) { -// AddExpenseBottomSheet( -// {}, {}, null, sheetState, -// categories = listOf( -// Category( -// name = "Hotel", -// icon = cc.n0th1ng.tripmoney.utils.Icons.HOTEL, -// color = "#B3E5FC" -// ), -// Category( -// name = "Jedzenie", -// icon = cc.n0th1ng.tripmoney.utils.Icons.RESTAURANT, -// color = "#C8E6C9" -// ), -// Category( -// name = "Transport", -// icon = cc.n0th1ng.tripmoney.utils.Icons.FLIGHT, -// color = "#FFCDD2" -// ), -// Category( -// name = "Rozrywka", -// icon = cc.n0th1ng.tripmoney.utils.Icons.ATTRACTION, -// color = "#FFF9C4" -// ), -// Category( -// name = "Zakupy", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#E1BEE7" -// ), -// Category( -// name = "Zakupy1", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#D7CCC8" -// ), -// Category( -// name = "Zakupy2", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#BBDEFB" -// ), -// Category( -// name = "Zakupy3", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#D1C4E9" -// ), -// Category( -// name = "Zakupy4", -// icon = cc.n0th1ng.tripmoney.utils.Icons.GROCERIES, -// color = "#DCEDC8" -// ), -// ) -// ) -// } -//} \ No newline at end of file + icon != null -> Icon(painter = icon, contentDescription = null) + } + } +} + +val keyboard = listOf( + listOf("+", "-", "*", "/"), + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf(".", "0", "backspace") +) + + +@SuppressLint("CoroutineCreationDuringComposition") +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalMaterial3Api::class) +@AllPreviews +@Composable +fun PreviewAddExpenseDisabled() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + CoroutineScope(Dispatchers.IO).launch { + sheetState.show() + } + + TripMoneyTheme { + AddExpenseBottomSheet( + onSave = {}, + onDismiss = {}, + expenseDtoToEdit = null, + state = sheetState, + currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name), + categories = categoriesToPreview + ) + } + +} + +@SuppressLint("CoroutineCreationDuringComposition") +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalMaterial3Api::class) +@AllPreviews +@Composable +fun PreviewAddExpenseEnabled() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + CoroutineScope(Dispatchers.IO).launch { + sheetState.show() + } + + TripMoneyTheme { + AddExpenseBottomSheet( + onSave = {}, + onDismiss = {}, + expenseDtoToEdit = ExpenseDto( + Expense( + amount = 10.31, + currency = "PLN", + note = "some note", + datetime = "2025-11-30T10:16:26.939", + categoryId = 1, + tripId = 1 + ), category = categoriesToPreview.get(0), Trip(1, "Włochy", "2025-01-02", "PLN") + ), + state = sheetState, + currentTrip = Trip(1, "Trip", "2020-01-01", Currencies.entries.random().name), + categories = categoriesToPreview + ) + } + +} + +val categoriesToPreview = 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() + ), +) \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt index a875eb1..d126310 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/listexpense/ListExpenseScreen.kt @@ -50,9 +50,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight 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 @@ -126,7 +124,6 @@ fun ListExpenseScreen( sumMap.clear() sumMap.putAll(newSums) } - LazyColumn( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -513,4 +510,4 @@ private fun sampleExpenseDtoWithConvertedAmountList(): List Unit, + onCurrencySave: (Currencies) -> Unit, + tripName: String, + onExportToCsv: () -> Unit, +) { + + Scaffold { padding -> + var showThemeDialog by remember { mutableStateOf(false) } + var showCurrencyDialog by remember { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { SettingsListItem( onClick = { showCurrencyDialog = true }, - stringResource(string.default_currency) - ) { - Text(currentDefaultCurrency.name) - } - } - - if (showThemeDialog) { - ThemeSelectionDialog( - onDismiss = { showThemeDialog = false }, - onThemeSelected = { theme -> - settingsViewModel.setTheme(theme) - showThemeDialog = false - }, - selected = currentTheme + headlineText = stringResource(string.default_currency), + supportingText = currentDefaultCurrency.name, + iconResource = R.drawable.materialsymbols_ic_currency_yen_outlined ) - } - if (showCurrencyDialog) { - CurrencySelectionDialog(onDismiss = {showCurrencyDialog = false}, onCurrencySelected = { - currencyString -> - settingsViewModel.setDefaultCurrency(Currencies.valueOf(currencyString)) - showCurrencyDialog = false - }, currentDefaultCurrency.name) + SettingsCard(string.theme) { + SettingsListItem( + onClick = { showThemeDialog = true }, + stringResource(string.theme), + supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource( + string.light_theme + ), + iconResource = R.drawable.materialsymbols_ic_format_paint_outlined + ) + SettingsListItem( + onClick = { }, + "Pallete", + supportingText = if (isSystemInDarkTheme()) stringResource(string.dark_theme) else stringResource( + string.light_theme + ), + iconResource = R.drawable.materialsymbols_ic_palette_outlined + ) + } + SettingsListItem( + onClick = onExportToCsv, + stringResource(string.export_to_csv), + supportingText = "Save expenses from %s to a file".format(tripName), + iconResource = R.drawable.materialsymbols_ic_csv_outlined + ) + + if (showThemeDialog) { + ThemeSelectionDialog( + onDismiss = { showThemeDialog = false }, + onThemeSelected = { theme -> + onThemeSave(theme) + showThemeDialog = false + }, + selected = currentTheme + ) + } + + if (showCurrencyDialog) { + CurrencySelectionDialog( + onDismiss = { showCurrencyDialog = false }, + onCurrencySelected = { currencyString -> + onCurrencySave(Currencies.valueOf(currencyString)) + showCurrencyDialog = false + }, + currentDefaultCurrency.name + ) + } } } } @@ -97,7 +176,7 @@ fun SettingsCard(@StringRes title: Int = -1, content: @Composable () -> Unit) { if (title != -1) { Text( text = stringResource(title), - fontSize = 13.sp, + style = MaterialTheme.typography.titleSmall, modifier = Modifier .padding(start = 15.dp, top = 15.dp, end = 15.dp) .alpha(0.6f) @@ -112,16 +191,22 @@ fun SettingsListItem( onClick: () -> Unit, headlineText: String, trailingContent: @Composable () -> Unit = {}, - supportingContent: @Composable () -> Unit + supportingText: String, + iconResource: Int ) { - ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - headlineContent = { Text(headlineText) }, - supportingContent = supportingContent, - trailingContent = trailingContent, - modifier = Modifier - .clickable(true, onClick = onClick) - ) + Card { + ListItem( + leadingContent = { + Icon(painter = painterResource(iconResource), contentDescription = null) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + headlineContent = { Text(headlineText) }, + supportingContent = { Text(supportingText) }, + trailingContent = trailingContent, + modifier = Modifier + .clickable(true, onClick = onClick) + ) + } } @Composable @@ -165,4 +250,35 @@ fun ThemeSelectionDialog( }, confirmButton = {} ) +} + +@RequiresApi(Build.VERSION_CODES.S) +@AllPreviews +@Composable +fun PreviewSettingsScreen() { + TripMoneyTheme { + SettingsScreen(Currencies.entries.random(), AppTheme.entries.random(), {}, {}, "Włochy", {}) + } +} + +@RequiresApi(Build.VERSION_CODES.S) +@AllPreviews +@Composable +fun PreviewThemeSelectionDialog() { + TripMoneyTheme { + ThemeSelectionDialog(onDismiss = {}, onThemeSelected = {}, AppTheme.SYSTEM) + } +} + +@RequiresApi(Build.VERSION_CODES.S) +@AllPreviews +@Composable +fun PreviewCurrencySelectionDialog() { + TripMoneyTheme { + CurrencySelectionDialog( + onDismiss = {}, + onCurrencySelected = {}, + selected = Currencies.entries.random().name + ) + } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt index 6fb4ebe..cdca6da 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/statistics/StatisticsScreen.kt @@ -7,7 +7,6 @@ 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 @@ -25,38 +24,97 @@ 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.layout.ModifierLocalBeyondBoundsLayout 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.res.stringResource 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.data.entity.Trip import cc.n0th1ng.tripmoney.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews 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 +import cc.n0th1ng.tripmoney.viewmodel.TripViewModel +import com.composables.icons.materialsymbols.outlined.R @RequiresApi(Build.VERSION_CODES.O) @Composable fun StatisticsScreen() { val expenseAndCategoryViewModel: ExpenseAndCategoryViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel() - val currentTrip by settingsViewModel.currentTrip.collectAsState() - val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTrip) + val tripViewModel: TripViewModel = hiltViewModel() + val currentTripId by settingsViewModel.currentTrip.collectAsState() + val currentTrip = tripViewModel.getTrip(currentTripId) + val summaryPerCategoryList by expenseAndCategoryViewModel.getSummaryPerCategory(currentTripId) .collectAsState(emptyList()) + val summaryAmount by expenseAndCategoryViewModel.getSummaryAmount(currentTripId) + .collectAsState(0.0) + StatisticsScreen( + summaryPerCategoryList, + summaryAmount, + Currencies.valueOf(currentTrip?.currency ?: Currencies.default().name) + ) +} - Column(modifier = Modifier.padding(10.dp)) { +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun StatisticsScreen( + summaryPerCategoryList: List, + summaryAmount: Double, + tripCurrency: Currencies +) { + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Summary(summaryAmount, tripCurrency.name) SummaryPerCategoryCard(summaryPerCategoryList) } } +@Composable +fun Summary(summaryAmount: Double, currency: String) { + Card( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text(stringResource(cc.n0th1ng.tripmoney.R.string.total_expenses), style = MaterialTheme.typography.titleSmall) + Text( + "%.2f %s".format(summaryAmount, currency), + style = MaterialTheme.typography.headlineLarge + ) + } + Row( + horizontalArrangement = Arrangement.Center + ) + { + Icon( + painter = painterResource(R.drawable.materialsymbols_ic_payment_arrow_down_outlined), + contentDescription = null, + modifier = Modifier.size(45.dp) + ) + + } + } + + } +} @Composable fun SummaryPerCategoryCard(summaryPerCategoryList: List) { @@ -65,7 +123,6 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List) { 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 @@ -80,21 +137,35 @@ fun SummaryPerCategoryCard(summaryPerCategoryList: List) { 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)) { + 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( + "%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) + Text( + "%.2f ${summaryPerCategory.currency}".format(summaryPerCategory.amount), + style = MaterialTheme.typography.bodyMedium + ) } - Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically){ + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier .height(40.dp) @@ -102,31 +173,34 @@ fun CategoryCard(modifier: Modifier = Modifier, summaryPerCategory: SummaryPerCa .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) + 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 +@RequiresApi(Build.VERSION_CODES.O) +@AllPreviews @Composable -fun previewLight() { +fun Preview() { TripMoneyTheme { - SummaryPerCategoryCard(summaryPerCategoryList) - } -} - -@Preview -@Composable -fun previewDark() { - TripMoneyTheme(darkTheme = true) { - SummaryPerCategoryCard(summaryPerCategoryList) + StatisticsScreen( + summaryPerCategoryList, + summaryAmount = 125.24, + Currencies.entries.random() + ) } } @@ -143,8 +217,8 @@ val categories = listOf( val summaryPerCategoryList = listOf( SummaryPerCategory(categories[0], 50.0, 1f, Currencies.PLN), SummaryPerCategory(categories[1], 120.0, 0.3f, Currencies.PLN), + SummaryPerCategory(categories[4], 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), ) \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt index 204e6a1..e4474f1 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/screens/trippicker/AddTripBottomSheet.kt @@ -1,5 +1,6 @@ package cc.n0th1ng.tripmoney.screens.trippicker +import android.annotation.SuppressLint import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Arrangement @@ -17,12 +18,14 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Shapes import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -44,9 +47,14 @@ 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.theme.TripMoneyTheme +import cc.n0th1ng.tripmoney.utils.AllPreviews import cc.n0th1ng.tripmoney.utils.Currencies import cc.n0th1ng.tripmoney.viewmodel.SettingsViewModel import io.ktor.http.hostIsIp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -59,6 +67,29 @@ fun AddTripBottomSheet( tripToEdit: Trip?, sheetState: SheetState ) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + val defaultCurrency by settingsViewModel.defaultCurrency.collectAsState() + + AddTripBottomSheet( + onDismiss = onDismiss, + onSave = onSave, + tripToEdit = tripToEdit, + sheetState = sheetState, + defaultCurrency = defaultCurrency + ) +} + + +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTripBottomSheet( + onDismiss: () -> Unit, + onSave: (Trip) -> Unit, + tripToEdit: Trip?, + sheetState: SheetState, + defaultCurrency: Currencies +) { var name by remember { mutableStateOf(tripToEdit?.name ?: "") } var startDate by remember { @@ -66,8 +97,7 @@ 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 ?: defaultCurrency.name) } @@ -87,7 +117,7 @@ fun AddTripBottomSheet( modifier = Modifier .fillMaxWidth() .padding(start = 15.dp), - text = stringResource(if(tripToEdit == null) R.string.add_trip else R.string.edit_trip), + text = stringResource(if (tripToEdit == null) R.string.add_trip else R.string.edit_trip), fontWeight = FontWeight.Bold, fontSize = 35.sp, textAlign = TextAlign.Start @@ -101,32 +131,35 @@ fun AddTripBottomSheet( modifier = Modifier.fillMaxWidth(0.9f), horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - CurrencyButton( - modifier = Modifier - .weight(1f) - .fillMaxWidth(1f), - onClick = { showCurrencyDialog = true }, text = currency - ) - OutlinedButton( + Button( modifier = Modifier .fillMaxWidth(1f) .weight(1f), + shape = MaterialTheme.shapes.medium, onClick = { showDatePicker = true }) { Text( text = startDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")), fontSize = 17.sp ) } + CurrencyButton( + modifier = Modifier + .weight(1f) + .fillMaxWidth(1f), + onClick = { showCurrencyDialog = true }, text = currency + ) } Button( modifier = Modifier.fillMaxWidth(0.9f), enabled = enableSave, + shape = MaterialTheme.shapes.medium, onClick = { - val trip = Trip(name = name, startDate = startDate.toString(), currency = currency) + val trip = + Trip(name = name, startDate = startDate.toString(), currency = currency) - onSave(if(tripToEdit == null) trip else trip.copy(id = tripToEdit.id)) + onSave(if (tripToEdit == null) trip else trip.copy(id = tripToEdit.id)) }) { Icon( imageVector = Icons.Filled.Check, @@ -166,4 +199,41 @@ fun NameInput(name: String, onTextChange: (String) -> Unit) { onTextChange(text) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) ) +} + + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("CoroutineCreationDuringComposition") +@OptIn(ExperimentalMaterial3Api::class) +@AllPreviews +@Composable +fun PreviewAddTripBottomSheet() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + CoroutineScope(Dispatchers.IO).launch { + sheetState.show() + } + TripMoneyTheme { + AddTripBottomSheet({}, {}, null, sheetState, defaultCurrency = Currencies.entries.random()) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@SuppressLint("CoroutineCreationDuringComposition") +@OptIn(ExperimentalMaterial3Api::class) +@AllPreviews +@Composable +fun PreviewAddTripBottomSheetEditTrip() { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + CoroutineScope(Dispatchers.IO).launch { + sheetState.show() + } + TripMoneyTheme { + AddTripBottomSheet( + {}, + {}, + Trip(1, "Włochy", "2025-01-02", "PLN"), + sheetState, + defaultCurrency = Currencies.entries.random() + ) + } } \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt b/app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt index 7449ef9..b94c7d2 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/utils/AllPreviews.kt @@ -2,7 +2,18 @@ package cc.n0th1ng.tripmoney.utils import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_TYPE_NORMAL +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import cc.n0th1ng.tripmoney.screens.addexpense.PreviewAddExpenseDisabled +import cc.n0th1ng.tripmoney.screens.addexpense.PreviewAddExpenseEnabled +import cc.n0th1ng.tripmoney.screens.settings.PreviewSettingsScreen +import cc.n0th1ng.tripmoney.screens.settings.SettingsScreen +import cc.n0th1ng.tripmoney.theme.TripMoneyTheme @Preview(name = "Light") @Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL) diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/utils/CSVUtils.kt b/app/src/main/java/cc/n0th1ng/tripmoney/utils/CSVUtils.kt new file mode 100644 index 0000000..e3e03f5 --- /dev/null +++ b/app/src/main/java/cc/n0th1ng/tripmoney/utils/CSVUtils.kt @@ -0,0 +1,30 @@ +package cc.n0th1ng.tripmoney.utils + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +fun saveCsv(context: Context, fileName: String, content: String): File { + val file = File(context.cacheDir, "$fileName.csv") + file.writeText(content) + return file +} + +fun shareCsv(context: Context, file: File) { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file + ) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/csv" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity( + Intent.createChooser(intent, "Share CSV") + ) +} \ No newline at end of file diff --git a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt index 6153d1f..2ab88d2 100644 --- a/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt +++ b/app/src/main/java/cc/n0th1ng/tripmoney/viewmodel/ExpenseAndCategoryViewModel.kt @@ -18,12 +18,17 @@ import cc.n0th1ng.tripmoney.data.repository.TripRepository import cc.n0th1ng.tripmoney.utils.Currencies import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVPrinter +import java.io.File import java.time.LocalDateTime import javax.inject.Inject + @HiltViewModel open class ExpenseAndCategoryViewModel @Inject constructor( private val expenseRepo: ExpenseRepository, @@ -55,12 +60,39 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } } + @RequiresApi(Build.VERSION_CODES.O) + suspend fun generateCSVToFile(tripId: Int, file: File) { + file.writer().use { writer -> + CSVPrinter( + writer, + CSVFormat.DEFAULT.withHeader("date", "category", "currency", "amount") + ).use { printer -> + expenseRepo.getExpenses(tripId).first().forEach { expenseDto -> + printer.printRecord( + expenseDto.expense.datetime, + expenseDto.category.name, + expenseDto.expense.currency, + expenseDto.expense.amount + ) + + } + } + } + } + + + @RequiresApi(Build.VERSION_CODES.O) + fun getSummaryAmount(tripId: Int): Flow { + return getExpensesWithConvertedAmounts(tripId).map { list -> + list.sumOf { it.convertedAmount } + } + } + @RequiresApi(Build.VERSION_CODES.O) fun getSummaryPerCategory(tripId: Int): Flow> { 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) -> @@ -84,7 +116,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor( val convertedAmount = if (expenseDto.expense.currency != expenseDto.trip.currency) { runBlocking { - expenseDto.toExpenseDtoWithConvertedAmount() + expenseDto.convertedAmount() } } else { expenseDto.expense.amount @@ -102,7 +134,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor( val convertedAmount = if (expenseDto.expense.currency != expenseDto.trip.currency) { runBlocking { - expenseDto.toExpenseDtoWithConvertedAmount() + expenseDto.convertedAmount() } } else { expenseDto.expense.amount @@ -120,7 +152,7 @@ open class ExpenseAndCategoryViewModel @Inject constructor( } @RequiresApi(Build.VERSION_CODES.O) - suspend fun ExpenseDto.toExpenseDtoWithConvertedAmount(): Double { + suspend fun ExpenseDto.convertedAmount(): Double { return exchangeRateRepository.getRate( Currencies.valueOf(this.expense.currency), Currencies.valueOf(this.trip.currency), diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 508d84a..1fd2749 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -21,4 +21,10 @@ Edytuj wycieczkę Edytuj wydatek Domyślna waluta + Suma wydatków + Ustawienia + Wybierz wycieczkę + Lista wydatków + Statystyki + Eksport do CSV \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f487f89..68bde61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,4 +21,10 @@ Edit trip Edit expense Default currency + Total expenses + Settings + Pick trip + List of expenses + Statistics + Export to CSV \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..af7c742 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file