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.
This commit is contained in:
2026-03-01 16:55:09 +01:00
parent 552f604200
commit f3f83b7ca9
10 changed files with 173 additions and 36 deletions

View File

@@ -19,6 +19,7 @@ class TokenStorage(context: Context) {
) )
fun saveTokens(accountId: String, accessToken: String, refreshToken: String) { fun saveTokens(accountId: String, accessToken: String, refreshToken: String) {
// println("Account ID: ${accountId}\nAToken: ${accessToken}\nRToken: ${refreshToken}")
prefs.edit() prefs.edit()
.putString("access_$accountId", accessToken) .putString("access_$accountId", accessToken)
.putString("refresh_$accountId", refreshToken) .putString("refresh_$accountId", refreshToken)

View File

@@ -27,19 +27,19 @@ class MainActivity : ComponentActivity() {
val tokenStorage = TokenStorage(applicationContext) val tokenStorage = TokenStorage(applicationContext)
val mainViewModel = MainViewModel(dao, tokenStorage) val mainViewModel = MainViewModel(dao, tokenStorage)
setContent { setContent {
ShapPlannerTheme { ShapPlannerTheme {
val isLoggedIn by loginViewModel.isLoggedIn.collectAsState()
val accountList by mainViewModel.accounts.collectAsState() val accountList by mainViewModel.accounts.collectAsState()
val selectedAccount = mainViewModel.selectedAccount val selectedAccount = mainViewModel.selectedAccount
val showLoginForNewAccount = remember { mutableStateOf(false) } val showLoginForNewAccount = remember { mutableStateOf(false) }
val expenses by mainViewModel.expenses.collectAsState()
BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) { BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
showLoginForNewAccount.value = false showLoginForNewAccount.value = false
} }
AppContent( AppContent(
isLoggedIn = isLoggedIn,
accountList = accountList, accountList = accountList,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
showLoginForNewAccount = showLoginForNewAccount.value, showLoginForNewAccount = showLoginForNewAccount.value,
@@ -53,7 +53,10 @@ class MainActivity : ComponentActivity() {
onDeleteAccount = { mainViewModel.deleteAccount(selectedAccount!!) }, onDeleteAccount = { mainViewModel.deleteAccount(selectedAccount!!) },
sessionState = mainViewModel.sessionState, sessionState = mainViewModel.sessionState,
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) }, onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
onSessionInvalid = { mainViewModel.logoutFromAccount() } onSessionInvalid = { mainViewModel.logoutFromAccount() },
expenses = expenses,
onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
viewModel = mainViewModel
) )
} }
} }

View File

@@ -12,12 +12,14 @@ import java.util.UUID
@Entity(tableName = "expenses") @Entity(tableName = "expenses")
data class Expense ( data class Expense (
val id: UUID, val id: UUID,
val amt: Double, val payer_id: UUID,
val desc: String, val amount: Int,
val title: String,
val payerId: UUID, val description: String,
val debtors: List<User> val attachments: List<String>,
) val created_at: Int,
val last_updated_at: Int
)
@Dao @Dao
interface ExpenseDao { interface ExpenseDao {

View File

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

View File

@@ -13,6 +13,7 @@ import java.util.UUID
data class User ( data class User (
val id: UUID, val id: UUID,
val name: String, val name: String,
val avatar_url: String?
) )
@Dao @Dao

View File

@@ -2,24 +2,34 @@ package de.miaurizius.shap_planner.network
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
data class LoginRequest(val username: String, val password: String) import retrofit2.http.Query
data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?) import java.util.UUID
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)
interface APIService { interface APIService {
// Account
@POST("api/login") @POST("api/login")
suspend fun login(@Body req: LoginRequest): Response<LoginResponse> suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
@POST("api/refresh") @POST("api/refresh")
suspend fun refresh(@Body req: RefreshRequest): Response<RefreshResponse> suspend fun refresh(@Body req: RefreshRequest): Response<RefreshResponse>
@GET("api/ping") @GET("api/ping")
suspend fun ping(@Header("Authorization") token: String): Response<Map<String, String>> suspend fun ping(@Header("Authorization") token: String): Response<Map<String, String>>
// Expenses
@GET("api/expenses")
suspend fun expenseGet(@Header("Authorization") token: String): Response<ExpenseResponse>
@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)
} }

View File

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

View File

@@ -2,31 +2,50 @@ package de.miaurizius.shap_planner.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import de.miaurizius.shap_planner.entities.Account 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.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.LoginScreen import de.miaurizius.shap_planner.ui.screens.LoginScreen
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable @Composable
fun AppContent( fun AppContent(
isLoggedIn: Boolean, // Login
accountList: List<Account>, accountList: List<Account>,
selectedAccount: Account?, selectedAccount: Account?,
showLoginForNewAccount: Boolean, showLoginForNewAccount: Boolean,
onLogin: (String, String, String) -> Unit, onLogin: (String, String, String) -> Unit,
// Expenses
expenses: List<Expense>,
onExpenseClick: (Expense) -> Unit,
// Account
onSelectAccount: (Account) -> Unit, onSelectAccount: (Account) -> Unit,
onLogoutAccount: () -> Unit, onLogoutAccount: () -> Unit,
onAddAccountClick: () -> Unit, onAddAccountClick: () -> Unit,
onDeleteAccount: () -> Unit, onDeleteAccount: () -> Unit,
// Session
sessionState: SessionState, sessionState: SessionState,
onValidateSession: () -> Unit, onValidateSession: () -> Unit,
onSessionInvalid: () -> Unit onSessionInvalid: () -> Unit,
//Important
viewModel: MainViewModel
) { ) {
when { when {
showLoginForNewAccount -> LoginScreen(onLogin) showLoginForNewAccount -> LoginScreen(onLogin)
accountList.isEmpty() -> LoginScreen(onLogin) accountList.isEmpty() -> LoginScreen(onLogin)
selectedAccount != null -> DashboardScreen( selectedAccount != null -> DashboardScreen(
// Data and regarding Methods
account = selectedAccount, account = selectedAccount,
expenses = expenses,
onExpenseClick = onExpenseClick,
// Default Methods
mainViewModel = viewModel,
onBack = onLogoutAccount, onBack = onLogoutAccount,
onDelete = onDeleteAccount, onDelete = onDeleteAccount,
sessionState = sessionState, sessionState = sessionState,

View File

@@ -2,9 +2,11 @@ package de.miaurizius.shap_planner.ui.screens
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account 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.network.SessionState
import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
// Data and regarding Methods
account: Account, account: Account,
expenses: List<Expense>,
onExpenseClick: (Expense) -> Unit,
// Default Methods
mainViewModel: MainViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
sessionState: SessionState, sessionState: SessionState,
@@ -36,7 +50,7 @@ fun DashboardScreen(
onSessionInvalid: () -> Unit) { onSessionInvalid: () -> Unit) {
LaunchedEffect(Unit) { onValidate() } LaunchedEffect(Unit) { onValidate() }
mainViewModel.loadExpenses(account)
when (sessionState) { when (sessionState) {
SessionState.Loading -> { SessionState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -55,6 +69,7 @@ fun DashboardScreen(
.statusBarsPadding() .statusBarsPadding()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
// Header
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -84,16 +99,19 @@ fun DashboardScreen(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
Box( Text("WG-Kosten", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium),
MaterialTheme.colorScheme.surfaceVariant, contentPadding = PaddingValues(8.dp),
shape = MaterialTheme.shapes.medium verticalArrangement = Arrangement.spacedBy(8.dp)
),
contentAlignment = Alignment.Center
) { ) {
Text("Hier kommen bald deine WG-Kosten hin 🚀") items(expenses) { expense ->
ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) })
}
} }
} }
} }
@@ -109,3 +127,17 @@ fun DashboardScreen(
} }
} }
} }
@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)
}
}
}

View File

@@ -8,9 +8,11 @@ import androidx.lifecycle.viewModelScope
import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao 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.RefreshRequest
import de.miaurizius.shap_planner.network.RetrofitProvider import de.miaurizius.shap_planner.network.RetrofitProvider
import de.miaurizius.shap_planner.network.SessionState import de.miaurizius.shap_planner.network.SessionState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -19,12 +21,32 @@ import kotlin.collections.emptyList
class MainViewModel(private val accountDao: AccountDao, private val tokenStorage: TokenStorage) : ViewModel() { class MainViewModel(private val accountDao: AccountDao, private val tokenStorage: TokenStorage) : ViewModel() {
var selectedAccount by mutableStateOf<Account?>(null)
private set
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts() val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var sessionState by mutableStateOf<SessionState>(SessionState.Loading) var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
private set private set
private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
val expenses: StateFlow<List<Expense>> = _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) { fun validateSession(account: Account) {
viewModelScope.launch { viewModelScope.launch {
sessionState = SessionState.Loading sessionState = SessionState.Loading
@@ -54,10 +76,15 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
tokenStorage.saveTokens( tokenStorage.saveTokens(
account.id.toString(), account.id.toString(),
newTokens.access_token, newTokens.access_token,
newTokens.access_token newTokens.refresh_token
) )
sessionState = SessionState.Valid sessionState = SessionState.Valid
// Fetch data
loadExpenses(account)
println("All data fetched")
return@launch return@launch
} else { } else {
sessionState = SessionState.Invalid sessionState = SessionState.Invalid
@@ -73,7 +100,6 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
accountDao.insertAccount(account) accountDao.insertAccount(account)
} }
} }
fun deleteAccount(account: Account) { fun deleteAccount(account: Account) {
viewModelScope.launch { viewModelScope.launch {
accountDao.deleteAccount(account) accountDao.deleteAccount(account)
@@ -81,14 +107,9 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
selectedAccount = null selectedAccount = null
} }
} }
var selectedAccount by mutableStateOf<Account?>(null)
private set
fun selectAccount(account: Account) { fun selectAccount(account: Account) {
selectedAccount = account selectedAccount = account
} }
fun logoutFromAccount() { fun logoutFromAccount() {
selectedAccount = null selectedAccount = null
} }