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) {
// println("Account ID: ${accountId}\nAToken: ${accessToken}\nRToken: ${refreshToken}")
prefs.edit()
.putString("access_$accountId", accessToken)
.putString("refresh_$accountId", refreshToken)

View File

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

View File

@@ -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<User>
)
val payer_id: UUID,
val amount: Int,
val title: String,
val description: String,
val attachments: List<String>,
val created_at: Int,
val last_updated_at: Int
)
@Dao
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 (
val id: UUID,
val name: String,
val avatar_url: String?
)
@Dao

View File

@@ -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<LoginResponse>
@POST("api/refresh")
suspend fun refresh(@Body req: RefreshRequest): Response<RefreshResponse>
@GET("api/ping")
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 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<Account>,
selectedAccount: Account?,
showLoginForNewAccount: Boolean,
onLogin: (String, String, String) -> Unit,
// Expenses
expenses: List<Expense>,
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,

View File

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

View File

@@ -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<Account?>(null)
private set
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
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) {
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<Account?>(null)
private set
fun selectAccount(account: Account) {
selectedAccount = account
}
fun logoutFromAccount() {
selectedAccount = null
}