From 69e03442616c056e239f1dba2a062af3b2151588 Mon Sep 17 00:00:00 2001 From: "Maurice L." Date: Wed, 4 Mar 2026 14:55:31 +0100 Subject: [PATCH] Implemented expense creation functionality. A new `ExpenseCreationScreen` and `ExpenseCreationViewModel` have been added to allow users to create new expenses. This includes fields for the title, amount, and a participant selection using filter chips. Key changes: - Added `ExpenseCreationViewModel` to handle user loading and persistent storage of expenses and shares. - Created `ExpenseCreationScreen` UI with validation for the "Save" button. - Updated `DashboardScreen` to include a Floating Action Button (FAB) for navigating to the expense creation flow. - Added `expenseCreate` API endpoint and related data types (`ExpenseCreationRequest`, `ExpenseCreationResponse`). - Integrated `ExpenseCreationViewModel` into `MainActivity` and `AppContent`. --- .../shap_planner/activities/MainActivity.kt | 11 +- .../shap_planner/network/APIService.kt | 2 +- .../shap_planner/network/ComDataTypes.kt | 3 +- .../miaurizius/shap_planner/ui/AppContent.kt | 19 ++- .../ui/screens/DashboardScreen.kt | 117 ++++++++++-------- .../ui/screens/ExpenseCreationScreen.kt | 87 +++++++++++++ .../viewmodels/ExpenseCreationViewModel.kt | 64 ++++++++++ 7 files changed, 246 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseCreationScreen.kt create mode 100644 app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseCreationViewModel.kt diff --git a/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt b/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt index 2d42480..2588d13 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt @@ -8,11 +8,13 @@ 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 de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.room.AppDatabase import de.miaurizius.shap_planner.ui.AppContent import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme +import de.miaurizius.shap_planner.viewmodels.ExpenseCreationViewModel import de.miaurizius.shap_planner.viewmodels.ExpenseDetailViewModel import de.miaurizius.shap_planner.viewmodels.LoginViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel @@ -38,6 +40,12 @@ class MainActivity : ComponentActivity() { database.userDao(), tokenStorage ) + val creationViewModel = ExpenseCreationViewModel( + database.userDao(), + database.expenseDao(), + database.expenseShareDao(), + tokenStorage + ) setContent { @@ -67,7 +75,8 @@ class MainActivity : ComponentActivity() { onSessionInvalid = { mainViewModel.logoutFromAccount() }, onExpenseClick = { expense -> println("Clicked: ${expense.title}") }, viewModel = mainViewModel, - detailViewModel = detailViewModel + detailViewModel = detailViewModel, + creationViewModel = creationViewModel, ) } } diff --git a/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt b/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt index 7ded53e..d7a3e15 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt @@ -25,7 +25,7 @@ interface APIService { @GET("api/expenses") suspend fun expensesGet(@Header("Authorization") token: String): Response @POST("api/expenses") - suspend fun expenseCreate(@Header("Authorization") token: String) + suspend fun expenseCreate(@Header("Authorization") token: String, @Body req: ExpenseCreationRequest): Response @PUT("api/expenses") suspend fun expenseUpdate(@Header("Authorization") token: String) @DELETE("api/expenses") diff --git a/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt b/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt index 6dd69bf..db2960b 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt @@ -2,7 +2,6 @@ package de.miaurizius.shap_planner.network import de.miaurizius.shap_planner.entities.Expense import de.miaurizius.shap_planner.entities.ExpenseShare -import de.miaurizius.shap_planner.entities.User // Login data class LoginRequest(val username: String, val password: String) @@ -15,6 +14,8 @@ data class RefreshResponse(val access_token: String, val refresh_token: String) // Expenses data class ExpensesResponse(val expenses: List) +data class ExpenseCreationRequest(val expense: Expense, val shares: List) +data class ExpenseCreationResponse(val expense: Expense, val shares: List) // ExpenseShares data class ExpenseSharesResponse(val shares: List) diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/AppContent.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/AppContent.kt index d890c8f..ef80c3a 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/ui/AppContent.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/AppContent.kt @@ -1,5 +1,7 @@ package de.miaurizius.shap_planner.ui +import androidx.activity.compose.BackHandler +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -10,8 +12,10 @@ import de.miaurizius.shap_planner.entities.Expense import de.miaurizius.shap_planner.network.SessionState import de.miaurizius.shap_planner.ui.screens.AccountSelectionScreen import de.miaurizius.shap_planner.ui.screens.DashboardScreen +import de.miaurizius.shap_planner.ui.screens.ExpenseCreationScreen import de.miaurizius.shap_planner.ui.screens.ExpenseDetailScreen import de.miaurizius.shap_planner.ui.screens.LoginScreen +import de.miaurizius.shap_planner.viewmodels.ExpenseCreationViewModel import de.miaurizius.shap_planner.viewmodels.ExpenseDetailViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel @@ -39,11 +43,21 @@ fun AppContent( //Important viewModel: MainViewModel, - detailViewModel: ExpenseDetailViewModel + detailViewModel: ExpenseDetailViewModel, + creationViewModel: ExpenseCreationViewModel ) { var selectedExpense by remember { mutableStateOf(null) } + var showAddExpenseScreen by remember { mutableStateOf(false) } when { + showAddExpenseScreen -> { + ExpenseCreationScreen( + account = selectedAccount!!, + viewModel = creationViewModel, + onBack = { showAddExpenseScreen = false }, + onSaved = { showAddExpenseScreen = false } + ) + } selectedExpense != null -> { ExpenseDetailScreen( expense = selectedExpense!!, @@ -65,7 +79,8 @@ fun AppContent( onDelete = onDeleteAccount, sessionState = sessionState, onValidate = onValidateSession, - onSessionInvalid = onSessionInvalid + onSessionInvalid = onSessionInvalid, + onAddExpenseClick = { showAddExpenseScreen = true }, ) else -> AccountSelectionScreen( accounts = accountList, diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/DashboardScreen.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/DashboardScreen.kt index da8ff82..b299a91 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/DashboardScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,6 +46,7 @@ fun DashboardScreen( // Data and regarding Methods account: Account, onExpenseClick: (Expense) -> Unit, + onAddExpenseClick: () -> Unit, // Default Methods mainViewModel: MainViewModel, @@ -69,63 +71,74 @@ fun DashboardScreen( BackHandler { onBack() } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .statusBarsPadding() - .navigationBarsPadding() - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Scaffold(floatingActionButton = { + androidx.compose.material3.FloatingActionButton( + onClick = onAddExpenseClick, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary ) { - Column { - Text( - text = "Hello, ${account.name}!", - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = "Household: ${account.wgName}", - style = MaterialTheme.typography.bodyLarge, - color = Color.Gray - ) - } - Button(onClick = onBack) { - Text("Switch") - } + Text("+", style = MaterialTheme.typography.headlineSmall) } - - Spacer(modifier = Modifier.height(5.dp)) - - Button(onClick = onDelete) { - Text("Delete") - } - - Spacer(modifier = Modifier.height(10.dp)) - - Text("Costs", style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.height(8.dp)) - - if(expenseResource is Resource.Loading && expenseResource.data?.isEmpty() == true) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) - } - - if(expenseResource is Resource.Error) { - Text("Error: ${expenseResource.message}", color = Color.Red) - } - - LazyColumn( + }) { paddingValues -> + Column( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium), - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(paddingValues) + .padding(16.dp) + .statusBarsPadding() + .navigationBarsPadding() ) { - items(expenseResource.data ?: emptyList()) { expense -> - ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) }) + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Hello, ${account.name}!", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "Household: ${account.wgName}", + style = MaterialTheme.typography.bodyLarge, + color = Color.Gray + ) + } + Button(onClick = onBack) { + Text("Switch") + } + } + + Spacer(modifier = Modifier.height(5.dp)) + + Button(onClick = onDelete) { + Text("Delete") + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text("Costs", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + + if(expenseResource is Resource.Loading && expenseResource.data?.isEmpty() == true) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + if(expenseResource is Resource.Error) { + Text("Error: ${expenseResource.message}", color = Color.Red) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(expenseResource.data ?: emptyList()) { expense -> + ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) }) + } } } } diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseCreationScreen.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseCreationScreen.kt new file mode 100644 index 0000000..293f97b --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseCreationScreen.kt @@ -0,0 +1,87 @@ +package de.miaurizius.shap_planner.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.miaurizius.shap_planner.entities.Account +import de.miaurizius.shap_planner.viewmodels.ExpenseCreationViewModel +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ExpenseCreationScreen( + account: Account, + viewModel: ExpenseCreationViewModel, + onSaved: () -> Unit, + onBack: () -> Unit +) { + var title by remember { mutableStateOf("") } + var amountStr by remember { mutableStateOf("") } + val users by viewModel.users.collectAsState() + val selectedUsers = remember { mutableStateListOf() } + + LaunchedEffect(Unit) { viewModel.loadUsers() } + + Scaffold( + topBar = { + TopAppBar(title = { Text("New Expense") }) + } + ) { padding -> + Column(modifier = Modifier.padding(padding).padding(16.dp)) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("What was bought?") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = amountStr, + onValueChange = { amountStr = it }, + label = { Text("Amount in €") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text("Who participated?", style = MaterialTheme.typography.titleMedium) + + FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + users.forEach { user -> + FilterChip( + selected = selectedUsers.contains(user.id), + onClick = { + if (selectedUsers.contains(user.id)) selectedUsers.remove(user.id) + else selectedUsers.add(user.id) + }, + label = { Text(user.name) } + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + val cents = (amountStr.replace(",", ".").toDoubleOrNull() ?: 0.0) * 100 + viewModel.saveExpense(account, title, cents.toInt(), selectedUsers.toList()) + onSaved() + }, + modifier = Modifier.fillMaxWidth(), + enabled = title.isNotBlank() && amountStr.isNotBlank() && selectedUsers.isNotEmpty() + ) { + Text("Save") + } + + TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { + Text("Cancel") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseCreationViewModel.kt b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseCreationViewModel.kt new file mode 100644 index 0000000..f8dafe1 --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseCreationViewModel.kt @@ -0,0 +1,64 @@ +package de.miaurizius.shap_planner.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.miaurizius.shap_planner.TokenStorage +import de.miaurizius.shap_planner.entities.Account +import de.miaurizius.shap_planner.entities.Expense +import de.miaurizius.shap_planner.entities.ExpenseDao +import de.miaurizius.shap_planner.entities.ExpenseShare +import de.miaurizius.shap_planner.entities.ExpenseShareDao +import de.miaurizius.shap_planner.entities.User +import de.miaurizius.shap_planner.entities.UserDao +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +class ExpenseCreationViewModel( + private val userDao: UserDao, + private val expenseDao: ExpenseDao, + private val shareDao: ExpenseShareDao, + private val tokenStorage: TokenStorage +) : ViewModel() { + private val _users = MutableStateFlow>(emptyList()) + val users: StateFlow> = _users + + fun loadUsers() { + viewModelScope.launch { + userDao.getAllUsers().collect { _users.value = it } + } + } + + fun saveExpense(account: Account, title: String, amountCents: Int, selectedUserIds: List) { + viewModelScope.launch { + val expenseId = UUID.randomUUID() //TODO: Backend has to generate UUID + val newExpense = Expense( + id = expenseId, + payer_id = account.id, + amount = amountCents, + title = title, + description = "", + attachments = null, + created_at = (System.currentTimeMillis() / 1000).toInt(), + last_updated_at = 0 + ) + + expenseDao.insertExpense(newExpense) + + val shareAmount = amountCents / selectedUserIds.size + selectedUserIds.forEach { userId -> + shareDao.insertShare( + ExpenseShare( + UUID.randomUUID(), //TODO: Backend has to generate UUID + expenseId, + userId, + shareAmount + ) + ) + } + + // API Calls + } + } +} \ No newline at end of file