Added Expense Detail screen and functionality

The new `ExpenseDetailScreen` displays expense information, including title, amount, and a breakdown of cost shares among users.

Key changes:
- Created `ExpenseDetailViewModel` to manage loading and combining expense shares with user information.
- Added `ExpenseDetailScreen` UI with a list of shares and a back navigation option.
- Updated `MainActivity` and `AppContent` to handle navigation to the expense detail view.
- Extended `ExpenseShareDao` and `ExpenseShareRepository` to support fetching shares by expense ID.
- Updated `APIService` and `UserRepository` to simplify user information retrieval and support querying shares by different ID types.
- Added `@OptIn(ExperimentalMaterial3Api::class)` to several UI components to support Material 3 features.
This commit is contained in:
2026-03-04 14:16:45 +01:00
parent 4104930ea5
commit ea6349c85c
12 changed files with 241 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ 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.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
@@ -31,6 +32,12 @@ class MainActivity : ComponentActivity() {
expenseDao, expenseDao,
tokenStorage tokenStorage
) )
val detailViewModel = ExpenseDetailViewModel(
database.expenseDao(),
database.expenseShareDao(),
database.userDao(),
tokenStorage
)
setContent { setContent {
@@ -59,7 +66,8 @@ class MainActivity : ComponentActivity() {
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) }, onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
onSessionInvalid = { mainViewModel.logoutFromAccount() }, onSessionInvalid = { mainViewModel.logoutFromAccount() },
onExpenseClick = { expense -> println("Clicked: ${expense.title}") }, onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
viewModel = mainViewModel viewModel = mainViewModel,
detailViewModel = detailViewModel
) )
} }
} }

View File

@@ -26,6 +26,9 @@ interface ExpenseShareDao {
@Query("SELECT * FROM expense_shares WHERE id = :shareId") @Query("SELECT * FROM expense_shares WHERE id = :shareId")
fun getShareById(shareId: UUID): Flow<ExpenseShare?> fun getShareById(shareId: UUID): Flow<ExpenseShare?>
@Query("SELECT * FROM expense_shares WHERE expense_id = :expense_id")
fun getSharesByExpense(expense_id: UUID): Flow<List<ExpenseShare>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ExpenseShare) suspend fun insertShare(share: ExpenseShare)

View File

@@ -1,5 +1,7 @@
package de.miaurizius.shap_planner.network package de.miaurizius.shap_planner.network
import com.google.gson.annotations.SerializedName
import de.miaurizius.shap_planner.entities.User
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
@@ -34,8 +36,19 @@ interface APIService {
suspend fun sharesGet(@Header("Authorization") token: String): Response<ExpenseSharesResponse> suspend fun sharesGet(@Header("Authorization") token: String): Response<ExpenseSharesResponse>
@GET("api/shares") @GET("api/shares")
suspend fun shareGet(@Header("Authorization") token: String, @Query("id") shareId: UUID): Response<ExpenseShareResponse> suspend fun shareGet(@Header("Authorization") token: String, @Query("id") shareId: UUID): Response<ExpenseShareResponse>
@GET("api/shares")
suspend fun shareGet(@Header("Authorization") token: String, @Query("id") expenseId: UUID, @Query("idType") idType: IDType): Response<ExpenseSharesResponse>
// User // User
@GET("api/userinfo") @GET("api/userinfo")
suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID): Response<UserinfoResponse> suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID): Response<User>
}
enum class IDType {
@SerializedName("share")
Share,
@SerializedName("expense")
Expense,
@SerializedName("user")
User,
} }

View File

@@ -18,7 +18,4 @@ data class ExpensesResponse(val expenses: List<Expense>)
// ExpenseShares // ExpenseShares
data class ExpenseSharesResponse(val shares: List<ExpenseShare>) data class ExpenseSharesResponse(val shares: List<ExpenseShare>)
data class ExpenseShareResponse(val share: ExpenseShare) data class ExpenseShareResponse(val share: ExpenseShare)
// User
data class UserinfoResponse(val user: User)

View File

@@ -1,8 +1,10 @@
package de.miaurizius.shap_planner.repository package de.miaurizius.shap_planner.repository
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.ExpenseShareDao import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.network.APIService import de.miaurizius.shap_planner.network.APIService
import de.miaurizius.shap_planner.network.IDType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -50,4 +52,22 @@ class ExpenseShareRepository(
else emit(Resource.Error("Share nicht gefunden", null)) else emit(Resource.Error("Share nicht gefunden", null))
} }
} }
fun getSharesByExpenseId(token: String, expenseId: UUID, forceRefresh: Boolean = false): Flow<Resource<List<ExpenseShare>>> = flow {
val cached = dao.getSharesByExpense(expenseId).first()
emit(Resource.Loading(cached))
if(cached.isEmpty() || forceRefresh) {
try {
val response = api.shareGet("Bearer $token", expenseId, IDType.Expense)
if(response.isSuccessful) {
println("Body: ${response.body()}")
val remoteShare = response.body()?.shares ?: emptyList()
remoteShare.forEach { dao.insertShare(it) }
}
} catch(e: Exception) {
emit(Resource.Error("Network Error: ${e.localizedMessage}", cached))
}
}
dao.getSharesByExpense(expenseId).collect { emit(Resource.Success(it)) }
}
} }

View File

@@ -19,7 +19,8 @@ class UserRepository(
try { try {
val response = api.userinfo("Bearer $token", userId) val response = api.userinfo("Bearer $token", userId)
if(response.isSuccessful) { if(response.isSuccessful) {
response.body()?.user?.let { remoteUser -> dao.insertUser(remoteUser) } println("Body: ${response.body()}")
response.body()?.let { remoteUser -> dao.insertUser(remoteUser) }
} }
} catch(e: Exception) { } catch(e: Exception) {
emit(Resource.Error("Network-Error: ${e.localizedMessage}", cached)) emit(Resource.Error("Network-Error: ${e.localizedMessage}", cached))

View File

@@ -1,12 +1,18 @@
package de.miaurizius.shap_planner.ui package de.miaurizius.shap_planner.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.entities.Account import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.Expense 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.ExpenseDetailScreen
import de.miaurizius.shap_planner.ui.screens.LoginScreen import de.miaurizius.shap_planner.ui.screens.LoginScreen
import de.miaurizius.shap_planner.viewmodels.ExpenseDetailViewModel
import de.miaurizius.shap_planner.viewmodels.MainViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel
@Composable @Composable
@@ -32,15 +38,26 @@ fun AppContent(
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
//Important //Important
viewModel: MainViewModel viewModel: MainViewModel,
detailViewModel: ExpenseDetailViewModel
) { ) {
var selectedExpense by remember { mutableStateOf<Expense?>(null) }
when { when {
selectedExpense != null -> {
ExpenseDetailScreen(
expense = selectedExpense!!,
account = selectedAccount!!,
viewModel = detailViewModel,
onBack = { selectedExpense = null }
)
}
showLoginForNewAccount -> LoginScreen(onLogin) showLoginForNewAccount -> LoginScreen(onLogin)
accountList.isEmpty() -> LoginScreen(onLogin) accountList.isEmpty() -> LoginScreen(onLogin)
selectedAccount != null -> DashboardScreen( selectedAccount != null -> DashboardScreen(
// Data and regarding Methods // Data and regarding Methods
account = selectedAccount, account = selectedAccount,
onExpenseClick = onExpenseClick, onExpenseClick = { selectedExpense = it },
// Default Methods // Default Methods
mainViewModel = viewModel, mainViewModel = viewModel,

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -30,6 +31,7 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) { fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) {
LazyColumn( LazyColumn(

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -37,6 +38,7 @@ import de.miaurizius.shap_planner.network.SessionState
import de.miaurizius.shap_planner.repository.Resource import de.miaurizius.shap_planner.repository.Resource
import de.miaurizius.shap_planner.viewmodels.MainViewModel import de.miaurizius.shap_planner.viewmodels.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
// Data and regarding Methods // Data and regarding Methods

View File

@@ -0,0 +1,108 @@
package de.miaurizius.shap_planner.ui.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.style.TextAlign
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.viewmodels.ExpenseDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExpenseDetailScreen(
expense: Expense,
account: Account,
viewModel: ExpenseDetailViewModel,
onBack: () -> Unit
) {
val shares by viewModel.sharesWithUser.collectAsState()
LaunchedEffect(expense) {
viewModel.loadExpenseDetail(account, expense)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(expense.title) },
navigationIcon = { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück")
} }
)
}
) { padding ->
Column(modifier = Modifier.padding(padding).padding(16.dp)) {
Text(
text = "${expense.amount / 100.0}",
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(expense.description, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Spacer(modifier = Modifier.height(16.dp))
Text("Kostenaufteilung", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(shares) { item ->
ShareItem(item.user?.name ?: "Unbekannter Nutzer", item.share.share_cents)
}
}
}
}
}
@SuppressLint("DefaultLocale")
@Composable
fun ShareItem(name: String, amountCents: Int) {
Card(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(name, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Text(
text = String.format("%.2f€", amountCents / 100.0),
color = if (amountCents > 0) Color(0xFF4CAF50) else Color.Gray,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -8,6 +8,7 @@ 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.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -18,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen(onLogin: (String, String, String) -> Unit, onBack: (() -> Unit)? = null) { fun LoginScreen(onLogin: (String, String, String) -> Unit, onBack: (() -> Unit)? = null) {

View File

@@ -0,0 +1,59 @@
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 de.miaurizius.shap_planner.network.RetrofitProvider
import de.miaurizius.shap_planner.repository.ExpenseShareRepository
import de.miaurizius.shap_planner.repository.Resource
import de.miaurizius.shap_planner.repository.UserRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class ShareWithUser(
val share: ExpenseShare,
val user: User?
)
class ExpenseDetailViewModel(
private val expenseDao: ExpenseDao,
private val shareDao: ExpenseShareDao,
private val userDao: UserDao,
private val tokenStorage: TokenStorage
) : ViewModel() {
private val _sharesWithUser = MutableStateFlow<List<ShareWithUser>>(emptyList())
val sharesWithUser: StateFlow<List<ShareWithUser>> = _sharesWithUser
fun loadExpenseDetail(account: Account, expense: Expense) {
viewModelScope.launch {
val api = RetrofitProvider.create(account.serverUrl)
val token = tokenStorage.getAccess(account.id.toString()) ?: ""
val shareRepo = ExpenseShareRepository(shareDao, api)
val userRepo = UserRepository(userDao, api)
shareRepo.getSharesByExpenseId(token, expense.id).collect { resource ->
val shares = resource.data ?: emptyList()
val combinedList = shares.map { share ->
val cachedUser = userDao.getUserById(share.user_id).first()
if (cachedUser == null) {
val userResource = userRepo.getUser(token, share.user_id).first { it is Resource.Success || it is Resource.Error }
ShareWithUser(share, userResource.data)
} else {
ShareWithUser(share, cachedUser)
}
}
_sharesWithUser.value = combinedList
}
}
}
}