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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.UserPreferences
import de.miaurizius.shap_planner.room.AppDatabase import de.miaurizius.shap_planner.room.AppDatabase
import de.miaurizius.shap_planner.ui.AppContent import de.miaurizius.shap_planner.ui.AppContent
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme 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.ExpenseDetailViewModel
import de.miaurizius.shap_planner.viewmodels.LoginViewModel import de.miaurizius.shap_planner.viewmodels.LoginViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel
@@ -38,6 +40,12 @@ class MainActivity : ComponentActivity() {
database.userDao(), database.userDao(),
tokenStorage tokenStorage
) )
val creationViewModel = ExpenseCreationViewModel(
database.userDao(),
database.expenseDao(),
database.expenseShareDao(),
tokenStorage
)
setContent { setContent {
@@ -67,7 +75,8 @@ class MainActivity : ComponentActivity() {
onSessionInvalid = { mainViewModel.logoutFromAccount() }, onSessionInvalid = { mainViewModel.logoutFromAccount() },
onExpenseClick = { expense -> println("Clicked: ${expense.title}") }, onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
viewModel = mainViewModel, viewModel = mainViewModel,
detailViewModel = detailViewModel detailViewModel = detailViewModel,
creationViewModel = creationViewModel,
) )
} }
} }

View File

@@ -25,7 +25,7 @@ interface APIService {
@GET("api/expenses") @GET("api/expenses")
suspend fun expensesGet(@Header("Authorization") token: String): Response<ExpensesResponse> suspend fun expensesGet(@Header("Authorization") token: String): Response<ExpensesResponse>
@POST("api/expenses") @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") @PUT("api/expenses")
suspend fun expenseUpdate(@Header("Authorization") token: String) suspend fun expenseUpdate(@Header("Authorization") token: String)
@DELETE("api/expenses") @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.Expense
import de.miaurizius.shap_planner.entities.ExpenseShare import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.User
// Login // Login
data class LoginRequest(val username: String, val password: String) 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 // Expenses
data class ExpensesResponse(val expenses: List<Expense>) 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 // ExpenseShares
data class ExpenseSharesResponse(val shares: List<ExpenseShare>) data class ExpenseSharesResponse(val shares: List<ExpenseShare>)

View File

@@ -1,5 +1,7 @@
package de.miaurizius.shap_planner.ui 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.network.SessionState
import de.miaurizius.shap_planner.ui.screens.AccountSelectionScreen import de.miaurizius.shap_planner.ui.screens.AccountSelectionScreen
import de.miaurizius.shap_planner.ui.screens.DashboardScreen 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.ExpenseDetailScreen
import de.miaurizius.shap_planner.ui.screens.LoginScreen 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.ExpenseDetailViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel
@@ -39,11 +43,21 @@ fun AppContent(
//Important //Important
viewModel: MainViewModel, viewModel: MainViewModel,
detailViewModel: ExpenseDetailViewModel detailViewModel: ExpenseDetailViewModel,
creationViewModel: ExpenseCreationViewModel
) { ) {
var selectedExpense by remember { mutableStateOf<Expense?>(null) } var selectedExpense by remember { mutableStateOf<Expense?>(null) }
var showAddExpenseScreen by remember { mutableStateOf(false) }
when { when {
showAddExpenseScreen -> {
ExpenseCreationScreen(
account = selectedAccount!!,
viewModel = creationViewModel,
onBack = { showAddExpenseScreen = false },
onSaved = { showAddExpenseScreen = false }
)
}
selectedExpense != null -> { selectedExpense != null -> {
ExpenseDetailScreen( ExpenseDetailScreen(
expense = selectedExpense!!, expense = selectedExpense!!,
@@ -65,7 +79,8 @@ fun AppContent(
onDelete = onDeleteAccount, onDelete = onDeleteAccount,
sessionState = sessionState, sessionState = sessionState,
onValidate = onValidateSession, onValidate = onValidateSession,
onSessionInvalid = onSessionInvalid onSessionInvalid = onSessionInvalid,
onAddExpenseClick = { showAddExpenseScreen = true },
) )
else -> AccountSelectionScreen( else -> AccountSelectionScreen(
accounts = accountList, accounts = accountList,

View File

@@ -22,6 +22,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -45,6 +46,7 @@ fun DashboardScreen(
// Data and regarding Methods // Data and regarding Methods
account: Account, account: Account,
onExpenseClick: (Expense) -> Unit, onExpenseClick: (Expense) -> Unit,
onAddExpenseClick: () -> Unit,
// Default Methods // Default Methods
mainViewModel: MainViewModel, mainViewModel: MainViewModel,
@@ -69,9 +71,19 @@ fun DashboardScreen(
BackHandler { BackHandler {
onBack() onBack()
} }
Scaffold(floatingActionButton = {
androidx.compose.material3.FloatingActionButton(
onClick = onAddExpenseClick,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Text("+", style = MaterialTheme.typography.headlineSmall)
}
}) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(16.dp) .padding(16.dp)
.statusBarsPadding() .statusBarsPadding()
.navigationBarsPadding() .navigationBarsPadding()
@@ -130,6 +142,7 @@ fun DashboardScreen(
} }
} }
} }
}
SessionState.Invalid -> { SessionState.Invalid -> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

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