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.ui.AppContent
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.MainViewModel
@@ -31,6 +32,12 @@ class MainActivity : ComponentActivity() {
expenseDao,
tokenStorage
)
val detailViewModel = ExpenseDetailViewModel(
database.expenseDao(),
database.expenseShareDao(),
database.userDao(),
tokenStorage
)
setContent {
@@ -59,7 +66,8 @@ class MainActivity : ComponentActivity() {
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
onSessionInvalid = { mainViewModel.logoutFromAccount() },
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")
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)
suspend fun insertShare(share: ExpenseShare)

View File

@@ -1,5 +1,7 @@
package de.miaurizius.shap_planner.network
import com.google.gson.annotations.SerializedName
import de.miaurizius.shap_planner.entities.User
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
@@ -34,8 +36,19 @@ interface APIService {
suspend fun sharesGet(@Header("Authorization") token: String): Response<ExpenseSharesResponse>
@GET("api/shares")
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
@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

@@ -19,6 +19,3 @@ data class ExpensesResponse(val expenses: List<Expense>)
// ExpenseShares
data class ExpenseSharesResponse(val shares: List<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
import de.miaurizius.shap_planner.entities.Expense
import de.miaurizius.shap_planner.entities.ExpenseShare
import de.miaurizius.shap_planner.entities.ExpenseShareDao
import de.miaurizius.shap_planner.network.APIService
import de.miaurizius.shap_planner.network.IDType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@@ -50,4 +52,22 @@ class ExpenseShareRepository(
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 {
val response = api.userinfo("Bearer $token", userId)
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) {
emit(Resource.Error("Network-Error: ${e.localizedMessage}", cached))

View File

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

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -30,6 +31,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.miaurizius.shap_planner.entities.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSelectionScreen(accounts: List<Account>, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) {
LazyColumn(

View File

@@ -19,6 +19,7 @@ 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.viewmodels.MainViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
// 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.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
@@ -18,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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
}
}
}
}