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 9c81597..2d42480 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 @@ -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 ) } } 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 index 79b88b8..de1067d 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/ExpenseShare.kt @@ -26,6 +26,9 @@ interface ExpenseShareDao { @Query("SELECT * FROM expense_shares WHERE id = :shareId") fun getShareById(shareId: UUID): Flow + @Query("SELECT * FROM expense_shares WHERE expense_id = :expense_id") + fun getSharesByExpense(expense_id: UUID): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertShare(share: ExpenseShare) 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 e6eee00..7ded53e 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 @@ -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 @GET("api/shares") suspend fun shareGet(@Header("Authorization") token: String, @Query("id") shareId: UUID): Response + @GET("api/shares") + suspend fun shareGet(@Header("Authorization") token: String, @Query("id") expenseId: UUID, @Query("idType") idType: IDType): Response // User @GET("api/userinfo") - suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID): Response + suspend fun userinfo(@Header("Authorization") token: String, @Query("id") userId: UUID): Response +} + +enum class IDType { + @SerializedName("share") + Share, + @SerializedName("expense") + Expense, + @SerializedName("user") + User, } \ 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 index d414417..6dd69bf 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/network/ComDataTypes.kt @@ -18,7 +18,4 @@ data class ExpensesResponse(val expenses: List) // ExpenseShares data class ExpenseSharesResponse(val shares: List) -data class ExpenseShareResponse(val share: ExpenseShare) - -// User -data class UserinfoResponse(val user: User) \ No newline at end of file +data class ExpenseShareResponse(val share: ExpenseShare) \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseShareRepository.kt b/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseShareRepository.kt index f172e90..b36b56c 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseShareRepository.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseShareRepository.kt @@ -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>> = 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)) } + } } \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/repository/UserRepository.kt b/app/src/main/java/de/miaurizius/shap_planner/repository/UserRepository.kt index 5495015..3a37b28 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/repository/UserRepository.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/repository/UserRepository.kt @@ -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)) 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 295fe5f..d890c8f 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 @@ -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(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, diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/AccountSelectionScreen.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/AccountSelectionScreen.kt index 0d35dda..2634aa1 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/AccountSelectionScreen.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/AccountSelectionScreen.kt @@ -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, onAccountClick: (Account) -> Unit, onAddAccountClick: () -> Unit) { LazyColumn( 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 cc3b3e1..9c3d771 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 @@ -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 diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseDetailScreen.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseDetailScreen.kt new file mode 100644 index 0000000..77424ec --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/ExpenseDetailScreen.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/LoginScreen.kt b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/LoginScreen.kt index 6cec987..2d73eab 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/ui/screens/LoginScreen.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/ui/screens/LoginScreen.kt @@ -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) { diff --git a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseDetailViewModel.kt b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseDetailViewModel.kt new file mode 100644 index 0000000..9db927f --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/ExpenseDetailViewModel.kt @@ -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>(emptyList()) + val sharesWithUser: StateFlow> = _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 + } + } + } +} \ No newline at end of file