From f3f83b7ca90619858e8f1582fa826673616a8574 Mon Sep 17 00:00:00 2001 From: "Maurice L." Date: Sun, 1 Mar 2026 16:55:09 +0100 Subject: [PATCH] Implemented expense tracking and data fetching The `Expense` entity and its associated API endpoints have been updated, and a new `ExpenseShare` entity with a corresponding DAO has been added. The `MainViewModel` now includes logic to fetch expenses from the server upon successful session validation. The `DashboardScreen` has been updated to display a list of expenses using a `LazyColumn` and a new `ExpenseItem` component. Additionally, the `APIService` has been expanded to include CRUD operations for expenses and user information. --- .../miaurizius/shap_planner/TokenStorage.kt | 1 + .../shap_planner/activities/MainActivity.kt | 9 ++-- .../shap_planner/entities/Expense.kt | 14 +++--- .../shap_planner/entities/ExpenseShare.kt | 33 +++++++++++++ .../miaurizius/shap_planner/entities/User.kt | 1 + .../shap_planner/network/APIService.kt | 28 +++++++---- .../shap_planner/network/ComDataTypes.kt | 15 ++++++ .../miaurizius/shap_planner/ui/AppContent.kt | 23 ++++++++- .../ui/screens/DashboardScreen.kt | 48 +++++++++++++++---- .../shap_planner/viewmodels/MainViewModel.kt | 37 ++++++++++---- 10 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt create mode 100644 app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt diff --git a/app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt b/app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt index a542174..7482fc7 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/TokenStorage.kt @@ -19,6 +19,7 @@ class TokenStorage(context: Context) { ) fun saveTokens(accountId: String, accessToken: String, refreshToken: String) { +// println("Account ID: ${accountId}\nAToken: ${accessToken}\nRToken: ${refreshToken}") prefs.edit() .putString("access_$accountId", accessToken) .putString("refresh_$accountId", refreshToken) 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 21c9902..4cb1258 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 @@ -27,19 +27,19 @@ class MainActivity : ComponentActivity() { val tokenStorage = TokenStorage(applicationContext) val mainViewModel = MainViewModel(dao, tokenStorage) + setContent { ShapPlannerTheme { - val isLoggedIn by loginViewModel.isLoggedIn.collectAsState() val accountList by mainViewModel.accounts.collectAsState() val selectedAccount = mainViewModel.selectedAccount val showLoginForNewAccount = remember { mutableStateOf(false) } + val expenses by mainViewModel.expenses.collectAsState() BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) { showLoginForNewAccount.value = false } AppContent( - isLoggedIn = isLoggedIn, accountList = accountList, selectedAccount = selectedAccount, showLoginForNewAccount = showLoginForNewAccount.value, @@ -53,7 +53,10 @@ class MainActivity : ComponentActivity() { onDeleteAccount = { mainViewModel.deleteAccount(selectedAccount!!) }, sessionState = mainViewModel.sessionState, onValidateSession = { mainViewModel.validateSession(selectedAccount!!) }, - onSessionInvalid = { mainViewModel.logoutFromAccount() } + onSessionInvalid = { mainViewModel.logoutFromAccount() }, + expenses = expenses, + onExpenseClick = { expense -> println("Clicked: ${expense.title}") }, + viewModel = mainViewModel ) } } diff --git a/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt b/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt index 8a17dc1..1435032 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt @@ -12,12 +12,14 @@ import java.util.UUID @Entity(tableName = "expenses") data class Expense ( val id: UUID, - val amt: Double, - val desc: String, - - val payerId: UUID, - val debtors: List -) + val payer_id: UUID, + val amount: Int, + val title: String, + val description: String, + val attachments: List, + val created_at: Int, + val last_updated_at: Int + ) @Dao interface ExpenseDao { diff --git a/app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt b/app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt new file mode 100644 index 0000000..3ddc0a0 --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt @@ -0,0 +1,33 @@ +package de.miaurizius.shap_planner.entities + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +@Entity(tableName = "expense_shares") +data class ExpenseShare( + val id: UUID, + val expense_id: UUID, + val user_id: UUID, + val share_cents: Int +) + +@Dao +interface ExpenseShareDao { + @Query("SELECT * FROM expense_shares") + fun getAllShares(): Flow> + + @Query("SELECT * FROM expense_shares WHERE id = :shareId") + fun getShareById(shareId: UUID) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertShare(share: ExpenseShare) + + @Delete + suspend fun deleteShare(share: ExpenseShare) +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt b/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt index 961fdd3..e59cd4d 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt @@ -13,6 +13,7 @@ import java.util.UUID data class User ( val id: UUID, val name: String, + val avatar_url: String? ) @Dao 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 fc3ab15..6a63453 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 @@ -2,24 +2,34 @@ package de.miaurizius.shap_planner.network import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST - -data class LoginRequest(val username: String, val password: String) -data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?) -data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String) - -data class RefreshRequest(val refresh_token: String) -data class RefreshResponse(val access_token: String, val refresh_token: String) +import retrofit2.http.PUT +import retrofit2.http.Query +import java.util.UUID interface APIService { + // Account @POST("api/login") suspend fun login(@Body req: LoginRequest): Response - @POST("api/refresh") suspend fun refresh(@Body req: RefreshRequest): Response - @GET("api/ping") suspend fun ping(@Header("Authorization") token: String): Response> + + // Expenses + @GET("api/expenses") + suspend fun expenseGet(@Header("Authorization") token: String): Response + @POST("api/expenses") + suspend fun expenseCreate(@Header("Authorization") token: String) + @PUT("api/expenses") + suspend fun expenseUpdate(@Header("Authorization") token: String) + @DELETE("api/expenses") + suspend fun expenseDelete(@Header("Authorization") token: String) + + // User + @GET("api/userinfo") + suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID) } \ No newline at end of file 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 new file mode 100644 index 0000000..a7e1616 --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt @@ -0,0 +1,15 @@ +package de.miaurizius.shap_planner.network + +import de.miaurizius.shap_planner.entities.Expense + +// Login +data class LoginRequest(val username: String, val password: String) +data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?) +data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String) + +// Refresh-Tokens +data class RefreshRequest(val refresh_token: String) +data class RefreshResponse(val access_token: String, val refresh_token: String) + +// Expenses +data class ExpenseResponse(val expenses: List) \ No newline at end of file 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 686ef5c..c693ed3 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 @@ -2,31 +2,50 @@ package de.miaurizius.shap_planner.ui import androidx.compose.runtime.Composable import de.miaurizius.shap_planner.entities.Account +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.LoginScreen +import de.miaurizius.shap_planner.viewmodels.MainViewModel @Composable fun AppContent( - isLoggedIn: Boolean, + // Login accountList: List, selectedAccount: Account?, showLoginForNewAccount: Boolean, onLogin: (String, String, String) -> Unit, + + // Expenses + expenses: List, + onExpenseClick: (Expense) -> Unit, + + // Account onSelectAccount: (Account) -> Unit, onLogoutAccount: () -> Unit, onAddAccountClick: () -> Unit, onDeleteAccount: () -> Unit, + + // Session sessionState: SessionState, onValidateSession: () -> Unit, - onSessionInvalid: () -> Unit + onSessionInvalid: () -> Unit, + + //Important + viewModel: MainViewModel ) { when { showLoginForNewAccount -> LoginScreen(onLogin) accountList.isEmpty() -> LoginScreen(onLogin) selectedAccount != null -> DashboardScreen( + // Data and regarding Methods account = selectedAccount, + expenses = expenses, + onExpenseClick = onExpenseClick, + + // Default Methods + mainViewModel = viewModel, onBack = onLogoutAccount, onDelete = onDeleteAccount, sessionState = sessionState, 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 93f4058..038ab38 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 @@ -2,9 +2,11 @@ package de.miaurizius.shap_planner.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,22 +15,34 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.miaurizius.shap_planner.entities.Account +import de.miaurizius.shap_planner.entities.Expense import de.miaurizius.shap_planner.network.SessionState +import de.miaurizius.shap_planner.viewmodels.MainViewModel @Composable fun DashboardScreen( + // Data and regarding Methods account: Account, + expenses: List, + onExpenseClick: (Expense) -> Unit, + + // Default Methods + mainViewModel: MainViewModel, onBack: () -> Unit, onDelete: () -> Unit, sessionState: SessionState, @@ -36,7 +50,7 @@ fun DashboardScreen( onSessionInvalid: () -> Unit) { LaunchedEffect(Unit) { onValidate() } - + mainViewModel.loadExpenses(account) when (sessionState) { SessionState.Loading -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -55,6 +69,7 @@ fun DashboardScreen( .statusBarsPadding() .navigationBarsPadding() ) { + // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -84,16 +99,19 @@ fun DashboardScreen( Spacer(modifier = Modifier.height(10.dp)) - Box( + Text("WG-Kosten", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( modifier = Modifier .fillMaxSize() - .background( - MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ), - contentAlignment = Alignment.Center + .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Hier kommen bald deine WG-Kosten hin 🚀") + items(expenses) { expense -> + ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) }) + } } } } @@ -108,4 +126,18 @@ fun DashboardScreen( Text("Server error") } } +} + +@Composable +fun ExpenseItem(expense: Expense, onClick: () -> Unit) { + Surface(modifier = Modifier + .fillMaxWidth() + .clickable{onClick()}, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface) { + Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = expense.title, style = MaterialTheme.typography.bodyLarge) + Text(text = expense.amount.toString()+"€", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + } + } } \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt index 841de0f..1638b5b 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt @@ -8,9 +8,11 @@ import androidx.lifecycle.viewModelScope import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.AccountDao +import de.miaurizius.shap_planner.entities.Expense import de.miaurizius.shap_planner.network.RefreshRequest import de.miaurizius.shap_planner.network.RetrofitProvider import de.miaurizius.shap_planner.network.SessionState +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn @@ -19,12 +21,32 @@ import kotlin.collections.emptyList class MainViewModel(private val accountDao: AccountDao, private val tokenStorage: TokenStorage) : ViewModel() { + var selectedAccount by mutableStateOf(null) + private set val accounts: StateFlow> = accountDao.getAllAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - var sessionState by mutableStateOf(SessionState.Loading) private set + private val _expenses = MutableStateFlow>(emptyList()) + val expenses: StateFlow> = _expenses + + fun loadExpenses(account: Account) { + viewModelScope.launch { + try { + val api = RetrofitProvider.create(account.serverUrl) + val accessToken = tokenStorage.getAccess(account.id.toString()) + + val response = api.expenseGet("Bearer $accessToken") + if (response.isSuccessful) { + _expenses.value = response.body()?.expenses ?: emptyList() + } + } catch (e: Exception) { + _expenses.value = emptyList() + } + } + } + fun validateSession(account: Account) { viewModelScope.launch { sessionState = SessionState.Loading @@ -54,10 +76,15 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage tokenStorage.saveTokens( account.id.toString(), newTokens.access_token, - newTokens.access_token + newTokens.refresh_token ) sessionState = SessionState.Valid + + // Fetch data + loadExpenses(account) + println("All data fetched") + return@launch } else { sessionState = SessionState.Invalid @@ -73,7 +100,6 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage accountDao.insertAccount(account) } } - fun deleteAccount(account: Account) { viewModelScope.launch { accountDao.deleteAccount(account) @@ -81,14 +107,9 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage selectedAccount = null } } - - var selectedAccount by mutableStateOf(null) - private set - fun selectAccount(account: Account) { selectedAccount = account } - fun logoutFromAccount() { selectedAccount = null }