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`.
This commit is contained in:
2026-03-04 14:55:31 +01:00
parent 25d1038c9d
commit 69e0344261
7 changed files with 246 additions and 57 deletions

View File

@@ -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,
)
}
}

View File

@@ -25,7 +25,7 @@ interface APIService {
@GET("api/expenses")
suspend fun expensesGet(@Header("Authorization") token: String): Response<ExpensesResponse>
@POST("api/expenses")
suspend fun expenseCreate(@Header("Authorization") token: String)
suspend fun expenseCreate(@Header("Authorization") token: String, @Body req: ExpenseCreationRequest): Response<ExpenseCreationResponse>
@PUT("api/expenses")
suspend fun expenseUpdate(@Header("Authorization") token: String)
@DELETE("api/expenses")

View File

@@ -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<Expense>)
data class ExpenseCreationRequest(val expense: Expense, val shares: List<ExpenseShare>)
data class ExpenseCreationResponse(val expense: Expense, val shares: List<ExpenseShare>)
// ExpenseShares
data class ExpenseSharesResponse(val shares: List<ExpenseShare>)

View File

@@ -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<Expense?>(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,

View File

@@ -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) })
}
}
}
}

View File

@@ -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<UUID>() }
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")
}
}
}
}

View File

@@ -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<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users
fun loadUsers() {
viewModelScope.launch {
userDao.getAllUsers().collect { _users.value = it }
}
}
fun saveExpense(account: Account, title: String, amountCents: Int, selectedUserIds: List<UUID>) {
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
}
}
}