diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ae160e3..20d56e0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,7 +16,6 @@ diff --git a/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt b/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt index 7cd64e8..0042806 100644 --- a/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt +++ b/app/src/main/java/de/dbauer/expensetracker/MainActivity.kt @@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -29,15 +27,16 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import de.dbauer.expensetracker.data.BottomNavItem -import de.dbauer.expensetracker.data.NavigationRoute +import de.dbauer.expensetracker.data.BottomNavigation import de.dbauer.expensetracker.data.RecurringExpenseData import de.dbauer.expensetracker.ui.EditRecurringExpense import de.dbauer.expensetracker.ui.RecurringExpenseOverview @@ -68,6 +67,9 @@ class MainActivity : ComponentActivity() { }, onRecurringExpenseEdited = { viewModel.editRecurringExpense(it) + }, + onRecurringExpenseDeleted = { + viewModel.deleteRecurringExpense(it) } ) } @@ -83,6 +85,7 @@ fun MainActivityContent( recurringExpenseData: ImmutableList, onRecurringExpenseAdded: (RecurringExpenseData) -> Unit, onRecurringExpenseEdited: (RecurringExpenseData) -> Unit, + onRecurringExpenseDeleted: (RecurringExpenseData) -> Unit, ) { val navController = rememberNavController() val backStackEntry = navController.currentBackStackEntryAsState() @@ -93,103 +96,120 @@ fun MainActivityContent( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - val bottomNavItems = listOf( - BottomNavItem( - name = "Home", - route = NavigationRoute.Home, - icon = Icons.Rounded.Home, - ), - BottomNavItem( - name = "Settings", - route = NavigationRoute.Settings, - icon = Icons.Rounded.Settings, - ), + val bottomNavigationItems = listOf( + BottomNavigation.Home, + BottomNavigation.Settings, ) ExpenseTrackerTheme { Surface( - modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, ) { - Scaffold(topBar = { - TopAppBar( - title = { - Text( - text = "Recurring Expense Tracker", - ) - }, - scrollBehavior = scrollBehavior, - ) - }, bottomBar = { - NavigationBar { - bottomNavItems.forEach { item -> - val selected = item.route.value == backStackEntry.value?.destination?.route + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Recurring Expense Tracker", + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + bottomBar = { + NavigationBar { + bottomNavigationItems.forEach { item -> + val selected = + item.route == backStackEntry.value?.destination?.route - NavigationBarItem(selected = selected, - onClick = { navController.navigate(item.route.value) }, - icon = { - Icon( - imageVector = item.icon, - contentDescription = "${item.name} Icon" - ) - }, - label = { - Text(text = item.name) - }) + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(item.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + }, + icon = { + Icon( + imageVector = item.icon, + contentDescription = "$item Icon", + ) + }, + label = { + Text(text = stringResource(id = item.name)) + }) + } } - } - }, floatingActionButton = { - FloatingActionButton(onClick = { - addRecurringExpenseVisible = true - }) { - Icon(imageVector = Icons.Rounded.Add, contentDescription = "Add") - } - }, content = { paddingValues -> - NavHost( - navController = navController, - startDestination = NavigationRoute.Home.value, - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - composable(NavigationRoute.Home.value) { - RecurringExpenseOverview( - weeklyExpense = weeklyExpense, - monthlyExpense = monthlyExpense, - yearlyExpense = yearlyExpense, - recurringExpenseData = recurringExpenseData, - onItemClicked = { - selectedRecurringExpense = it + }, + floatingActionButton = { + FloatingActionButton(onClick = { + addRecurringExpenseVisible = true + }) { + Icon(imageVector = Icons.Rounded.Add, contentDescription = "Add") + } + }, + content = { paddingValues -> + NavHost( + navController = navController, + startDestination = BottomNavigation.Home.route, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + composable(BottomNavigation.Home.route) { + RecurringExpenseOverview( + weeklyExpense = weeklyExpense, + monthlyExpense = monthlyExpense, + yearlyExpense = yearlyExpense, + recurringExpenseData = recurringExpenseData, + onItemClicked = { + selectedRecurringExpense = it + }, + contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), + modifier = Modifier + .padding(horizontal = 16.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + ) + } + composable(BottomNavigation.Settings.route) { + + } + } + if (addRecurringExpenseVisible) { + EditRecurringExpense( + onUpdateExpense = { + onRecurringExpenseAdded(it) + addRecurringExpenseVisible = false }, - contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), - modifier = Modifier - .padding(horizontal = 16.dp) - .nestedScroll(scrollBehavior.nestedScrollConnection), + onDismissRequest = { addRecurringExpenseVisible = false }, ) } - composable(NavigationRoute.Settings.value) { - + if (selectedRecurringExpense != null) { + EditRecurringExpense( + onUpdateExpense = { + onRecurringExpenseEdited(it) + selectedRecurringExpense = null + }, + onDismissRequest = { selectedRecurringExpense = null }, + currentData = selectedRecurringExpense, + onDeleteExpense = { + onRecurringExpenseDeleted(it) + selectedRecurringExpense = null + } + ) } - } - if (addRecurringExpenseVisible) { - EditRecurringExpense( - onUpdateExpense = { - onRecurringExpenseAdded(it) - addRecurringExpenseVisible = false - }, - onDismissRequest = { addRecurringExpenseVisible = false }, - ) - } - if (selectedRecurringExpense != null) { - EditRecurringExpense( - onUpdateExpense = { - onRecurringExpenseEdited(it) - selectedRecurringExpense = null - }, - onDismissRequest = { selectedRecurringExpense = null }, - currentData = selectedRecurringExpense, - ) - } - }) + }) } } } @@ -223,5 +243,6 @@ private fun MainActivityContentPreview() { ), onRecurringExpenseAdded = {}, onRecurringExpenseEdited = {}, + onRecurringExpenseDeleted = {}, ) } \ No newline at end of file diff --git a/app/src/main/java/de/dbauer/expensetracker/data/BottomNavItem.kt b/app/src/main/java/de/dbauer/expensetracker/data/BottomNavItem.kt deleted file mode 100644 index fe967c5..0000000 --- a/app/src/main/java/de/dbauer/expensetracker/data/BottomNavItem.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.dbauer.expensetracker.data - -import androidx.compose.ui.graphics.vector.ImageVector - -data class BottomNavItem( - val name: String, - val route: NavigationRoute, - val icon: ImageVector, -) \ No newline at end of file diff --git a/app/src/main/java/de/dbauer/expensetracker/data/BottomNavigation.kt b/app/src/main/java/de/dbauer/expensetracker/data/BottomNavigation.kt new file mode 100644 index 0000000..348ab80 --- /dev/null +++ b/app/src/main/java/de/dbauer/expensetracker/data/BottomNavigation.kt @@ -0,0 +1,14 @@ +package de.dbauer.expensetracker.data + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import de.dbauer.expensetracker.R + +sealed class BottomNavigation(val route: String, @StringRes val name: Int, val icon: ImageVector) { + data object Home : BottomNavigation("home", R.string.bottom_nav_home, Icons.Rounded.Home) + data object Settings : + BottomNavigation("settings", R.string.bottom_nav_settings, Icons.Rounded.Settings) +} \ No newline at end of file diff --git a/app/src/main/java/de/dbauer/expensetracker/data/NavigationRoute.kt b/app/src/main/java/de/dbauer/expensetracker/data/NavigationRoute.kt deleted file mode 100644 index d50e89b..0000000 --- a/app/src/main/java/de/dbauer/expensetracker/data/NavigationRoute.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.dbauer.expensetracker.data - -enum class NavigationRoute( - val value: String -) { - Home("home"), - Settings("settings"), -} \ No newline at end of file diff --git a/app/src/main/java/de/dbauer/expensetracker/ui/EditRecurringExpense.kt b/app/src/main/java/de/dbauer/expensetracker/ui/EditRecurringExpense.kt index 54964fb..b1d5953 100644 --- a/app/src/main/java/de/dbauer/expensetracker/ui/EditRecurringExpense.kt +++ b/app/src/main/java/de/dbauer/expensetracker/ui/EditRecurringExpense.kt @@ -1,31 +1,44 @@ package de.dbauer.expensetracker.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Error import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview @@ -41,6 +54,7 @@ fun EditRecurringExpense( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, currentData: RecurringExpenseData? = null, + onDeleteExpense: ((RecurringExpenseData) -> Unit)? = null, ) { val sheetState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -48,12 +62,14 @@ fun EditRecurringExpense( ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, + windowInsets = WindowInsets.statusBars, modifier = modifier, ) { EditRecurringExpenseInternal( onUpdateExpense = onUpdateExpense, confirmButtonString = if (currentData == null) "Add Expense" else "Update Expense", currentData = currentData, + onDeleteExpense = onDeleteExpense, ) } } @@ -64,28 +80,31 @@ private fun EditRecurringExpenseInternal( confirmButtonString: String, modifier: Modifier = Modifier, currentData: RecurringExpenseData? = null, + onDeleteExpense: ((RecurringExpenseData) -> Unit)? = null, ) { var nameState by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(currentData?.name ?: "")) } - var nameInputError by rememberSaveable { + val nameInputError = rememberSaveable { mutableStateOf(false) } var descriptionState by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(currentData?.description ?: "")) } - var descriptionInputError by rememberSaveable { - mutableStateOf(false) - } var priceState by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(currentData?.priceValue.toString())) + mutableStateOf(TextFieldValue(currentData?.priceValue?.toString() ?: "")) } - var priceInputError by rememberSaveable { + val priceInputError = rememberSaveable { mutableStateOf(false) } + val scrollState = rememberScrollState() + val localFocusManager = LocalFocusManager.current + Column( - modifier = modifier.padding(horizontal = 16.dp) + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState) ) { Text( text = "Name", @@ -95,8 +114,11 @@ private fun EditRecurringExpenseInternal( value = nameState, onValueChange = { nameState = it }, placeholder = "e.g. Netflix", + keyboardActions = KeyboardActions( + onNext = { localFocusManager.moveFocus(FocusDirection.Next) } + ), singleLine = true, - isError = nameInputError, + isError = nameInputError.value, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) @@ -110,8 +132,10 @@ private fun EditRecurringExpenseInternal( value = descriptionState, onValueChange = { descriptionState = it }, placeholder = "e.g. special subscription", + keyboardActions = KeyboardActions( + onNext = { localFocusManager.moveFocus(FocusDirection.Next) } + ), maxLines = 2, - isError = descriptionInputError, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) @@ -125,49 +149,75 @@ private fun EditRecurringExpenseInternal( value = priceState, onValueChange = { priceState = it }, placeholder = "0,00", - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + onConfirmClicked( + nameInputError, + priceInputError, + nameState, + descriptionState, + priceState, + onUpdateExpense, + currentData + ) + } + ), singleLine = true, - isError = priceInputError, + isError = priceInputError.value, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) ) - Button( - onClick = { - nameInputError = false - descriptionInputError = false - priceInputError = false - - val name = nameState.text - val description = descriptionState.text - val price = priceState.text - if (verifyUserInput(name = name, - onNameInputError = { nameInputError = true }, - description = description, - onDescriptionInputError = { descriptionInputError = true }, - price = price, - onPriceInputError = { priceInputError = true }) - ) { - onUpdateExpense( - RecurringExpenseData( - id = currentData?.id ?: 0, - name = name, - description = description, - priceValue = price.toFloatIgnoreSeparator() - ) - ) - } - }, + Row( modifier = Modifier .fillMaxWidth() .wrapContentWidth(align = Alignment.CenterHorizontally) .navigationBarsPadding() .padding(top = 8.dp, bottom = 24.dp) ) { - Text( - text = confirmButtonString, - modifier = Modifier.padding(vertical = 4.dp), - ) + if (currentData != null) { + OutlinedButton( + onClick = { + onDeleteExpense?.invoke(currentData) + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier + .weight(1f) + .wrapContentWidth() + ) { + Text( + text = "Delete", + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + Button( + onClick = { + onConfirmClicked( + nameInputError, + priceInputError, + nameState, + descriptionState, + priceState, + onUpdateExpense, + currentData + ) + }, + modifier = Modifier + .weight(1f) + .wrapContentWidth() + ) { + Text( + text = confirmButtonString, + modifier = Modifier.padding(vertical = 4.dp), + ) + } } } } @@ -178,7 +228,11 @@ private fun CustomTextField( onValueChange: (TextFieldValue) -> Unit, placeholder: String, modifier: Modifier = Modifier, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ), + keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, isError: Boolean = false, @@ -188,6 +242,7 @@ private fun CustomTextField( onValueChange = onValueChange, placeholder = { Text(text = placeholder) }, keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, singleLine = singleLine, isError = isError, maxLines = maxLines, @@ -209,11 +264,40 @@ private fun CustomTextField( ) } +private fun onConfirmClicked( + nameInputError: MutableState, + priceInputError: MutableState, + nameState: TextFieldValue, + descriptionState: TextFieldValue, + priceState: TextFieldValue, + onUpdateExpense: (RecurringExpenseData) -> Unit, + currentData: RecurringExpenseData? +) { + nameInputError.value = false + priceInputError.value = false + + val name = nameState.text + val description = descriptionState.text + val price = priceState.text + if (verifyUserInput(name = name, + onNameInputError = { nameInputError.value = true }, + price = price, + onPriceInputError = { priceInputError.value = true }) + ) { + onUpdateExpense( + RecurringExpenseData( + id = currentData?.id ?: 0, + name = name, + description = description, + priceValue = price.toFloatIgnoreSeparator() + ) + ) + } +} + private fun verifyUserInput( name: String, onNameInputError: () -> Unit, - description: String, - onDescriptionInputError: () -> Unit, price: String, onPriceInputError: () -> Unit, ): Boolean { @@ -222,10 +306,6 @@ private fun verifyUserInput( onNameInputError() everythingCorrect = false } - if (!isDescriptionValid(description)) { - onDescriptionInputError() - everythingCorrect = false - } if (!isPriceValid(price)) { onPriceInputError() everythingCorrect = false @@ -237,10 +317,6 @@ private fun isNameValid(name: String): Boolean { return name.isNotBlank() } -private fun isDescriptionValid(description: String): Boolean { - return description.isNotBlank() -} - private fun isPriceValid(price: String): Boolean { val priceConverted = price.replace(",", ".") return priceConverted.toFloatOrNull() != null diff --git a/app/src/main/java/de/dbauer/expensetracker/ui/RecurringExpenseOverview.kt b/app/src/main/java/de/dbauer/expensetracker/ui/RecurringExpenseOverview.kt index adb960a..bfc953c 100644 --- a/app/src/main/java/de/dbauer/expensetracker/ui/RecurringExpenseOverview.kt +++ b/app/src/main/java/de/dbauer/expensetracker/ui/RecurringExpenseOverview.kt @@ -76,11 +76,11 @@ private fun RecurringExpenseSummary( ) { Text( text = "Monthly", - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, ) Text( text = monthlyExpense, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, ) Spacer(modifier = Modifier.size(8.dp)) Row { @@ -136,20 +136,22 @@ private fun RecurringExpense( ) { Text( text = recurringExpenseData.name, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = recurringExpenseData.description, - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + if (recurringExpenseData.description.isNotBlank()) { + Text( + text = recurringExpenseData.description, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } Text( text = recurringExpenseData.priceString, - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineSmall ) } } @@ -180,7 +182,7 @@ private fun RecurringExpenseOverviewPreview() { RecurringExpenseData( id = 2, name = "Amazon Prime with a long name", - description = "My Disney Plus description", + description = "", priceValue = 7.95f, ), ), diff --git a/app/src/main/java/de/dbauer/expensetracker/ui/theme/Theme.kt b/app/src/main/java/de/dbauer/expensetracker/ui/theme/Theme.kt index 141b8a3..49e7429 100644 --- a/app/src/main/java/de/dbauer/expensetracker/ui/theme/Theme.kt +++ b/app/src/main/java/de/dbauer/expensetracker/ui/theme/Theme.kt @@ -59,6 +59,7 @@ fun ExpenseTrackerTheme( SideEffect { val window = (view.context as Activity).window window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = Color.Transparent.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } diff --git a/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt b/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt index e89dbd8..5515c18 100644 --- a/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt +++ b/app/src/main/java/de/dbauer/expensetracker/viewmodel/MainActivityViewModel.kt @@ -77,6 +77,19 @@ class MainActivityViewModel( } } + fun deleteRecurringExpense(recurringExpense: RecurringExpenseData) { + viewModelScope.launch { + expenseRepository.delete( + RecurringExpense( + id = recurringExpense.id, + name = recurringExpense.name, + description = recurringExpense.description, + price = recurringExpense.priceValue, + ) + ) + } + } + private fun updateExpenseSummary() { var price = 0f _recurringExpenseData.forEach { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5a7108..95e1fdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ - Recurring Expense Tracker - MainActivity + Recurring Expenses + + Home + Settings \ No newline at end of file