From 3d789c0352ce4f2dc54ddf9d066128aaef2ad9bb Mon Sep 17 00:00:00 2001 From: "Maurice L." Date: Wed, 4 Mar 2026 11:56:57 +0100 Subject: [PATCH] Implemented a repository-based data layer for expenses and updated Room entities. The `ExpenseRepository` was introduced to handle data fetching with a caching strategy, utilizing a new `Resource` sealed class to represent Loading, Success, and Error states. Key changes include: - Added `ExpenseRepository` to manage data flow between the local database and remote API. - Updated `MainViewModel` to use the repository and expose expenses via a `StateFlow>>`. - Enhanced Room entities (`Expense`, `ExpenseShare`, `User`) with `@PrimaryKey` annotations and added a `Converters` class to handle `List` types. - Expanded `AppDatabase` to include DAOs for `Expense`, `ExpenseShare`, and `User`. - Updated `DashboardScreen` to reactively display loading indicators, error messages, and cached/remote expense data. --- .../shap_planner/activities/MainActivity.kt | 11 +++--- .../shap_planner/entities/Expense.kt | 7 ++-- .../shap_planner/entities/ExpenseShare.kt | 5 +-- .../miaurizius/shap_planner/entities/User.kt | 3 +- .../repository/ExpenseRepository.kt | 36 +++++++++++++++++++ .../shap_planner/repository/Resource.kt | 7 ++++ .../shap_planner/room/AppDatabase.kt | 21 ++++++++++- .../miaurizius/shap_planner/ui/AppContent.kt | 2 -- .../ui/screens/DashboardScreen.kt | 18 ++++++++-- .../shap_planner/viewmodels/MainViewModel.kt | 31 ++++++++-------- 10 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseRepository.kt create mode 100644 app/src/main/java/de/miaurizius/shap_planner/repository/Resource.kt 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 4cb1258..9c81597 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 @@ -23,9 +23,14 @@ class MainActivity : ComponentActivity() { val prefs = UserPreferences(this) val loginViewModel = LoginViewModel(prefs, applicationContext) val database = AppDatabase.getDatabase(applicationContext) - val dao = database.accountDao() + val accountDao = database.accountDao() + val expenseDao = database.expenseDao() val tokenStorage = TokenStorage(applicationContext) - val mainViewModel = MainViewModel(dao, tokenStorage) + val mainViewModel = MainViewModel( + accountDao, + expenseDao, + tokenStorage + ) setContent { @@ -33,7 +38,6 @@ class MainActivity : ComponentActivity() { 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 @@ -54,7 +58,6 @@ class MainActivity : ComponentActivity() { sessionState = mainViewModel.sessionState, onValidateSession = { mainViewModel.validateSession(selectedAccount!!) }, onSessionInvalid = { mainViewModel.logoutFromAccount() }, - expenses = expenses, onExpenseClick = { expense -> println("Clicked: ${expense.title}") }, viewModel = mainViewModel ) diff --git a/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt b/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt index 1435032..3f609fc 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/Expense.kt @@ -5,21 +5,22 @@ import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey import androidx.room.Query import kotlinx.coroutines.flow.Flow import java.util.UUID @Entity(tableName = "expenses") data class Expense ( - val id: UUID, + @PrimaryKey val id: UUID, val payer_id: UUID, val amount: Int, val title: String, val description: String, - val attachments: List, + val attachments: List?, val created_at: Int, val last_updated_at: Int - ) +) @Dao interface ExpenseDao { 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 3ddc0a0..231a117 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 @@ -5,13 +5,14 @@ import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey import androidx.room.Query import kotlinx.coroutines.flow.Flow import java.util.UUID @Entity(tableName = "expense_shares") data class ExpenseShare( - val id: UUID, + @PrimaryKey val id: UUID, val expense_id: UUID, val user_id: UUID, val share_cents: Int @@ -23,7 +24,7 @@ interface ExpenseShareDao { fun getAllShares(): Flow> @Query("SELECT * FROM expense_shares WHERE id = :shareId") - fun getShareById(shareId: UUID) + fun getShareById(shareId: UUID): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertShare(share: ExpenseShare) diff --git a/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt b/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt index e59cd4d..0d37e36 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/entities/User.kt @@ -5,13 +5,14 @@ import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey import androidx.room.Query import kotlinx.coroutines.flow.Flow import java.util.UUID @Entity(tableName = "users") data class User ( - val id: UUID, + @PrimaryKey val id: UUID, val name: String, val avatar_url: String? ) diff --git a/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseRepository.kt b/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseRepository.kt new file mode 100644 index 0000000..93b37fc --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/repository/ExpenseRepository.kt @@ -0,0 +1,36 @@ +package de.miaurizius.shap_planner.repository + +import de.miaurizius.shap_planner.entities.Expense +import de.miaurizius.shap_planner.entities.ExpenseDao +import de.miaurizius.shap_planner.network.APIService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow + +class ExpenseRepository( + private val dao: ExpenseDao, + private val api: APIService +) { + fun getExpenses(token: String, forceRefresh: Boolean = false): Flow>> = flow { + val cachedExpense = dao.getAllExpenses().first() + println("CachedExpense: $cachedExpense") + emit(Resource.Loading(cachedExpense)) + + if(cachedExpense.isEmpty() || forceRefresh) { + try { + val response = api.expenseGet("Bearer $token") + if(response.isSuccessful) { + val remoteExpense = response.body()?.expenses ?: emptyList() + println("Fetched expenses: $remoteExpense") + remoteExpense.forEach { + dao.insertExpense(it) + println("Added $it") + } + } + } catch(e: Exception) { + emit(Resource.Error("Network Error: ${e.localizedMessage}", cachedExpense)) + } + } + dao.getAllExpenses().collect { emit(Resource.Success(it)) } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/repository/Resource.kt b/app/src/main/java/de/miaurizius/shap_planner/repository/Resource.kt new file mode 100644 index 0000000..3cc367f --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/repository/Resource.kt @@ -0,0 +1,7 @@ +package de.miaurizius.shap_planner.repository + +sealed class Resource(val data: T? = null, val message: String? = null) { + class Success(data: T): Resource(data) + class Loading(data: T? = null): Resource(data) + class Error(message: String, data: T? = null): Resource(data, message) +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt b/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt index 966057a..0a725cd 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt @@ -4,12 +4,31 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverter +import androidx.room.TypeConverters 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.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 -@Database(entities = [Account::class], version = 4) +class Converters { + @TypeConverter + fun fromList(list: List?): String? = list?.joinToString(",") + @TypeConverter + fun toList(data: String?): List? = data?.split(",")?.map { it.trim() } +} + +@Database(entities = [Account::class, Expense::class, ExpenseShare::class, User::class], version = 6) +@TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao + abstract fun expenseDao(): ExpenseDao + abstract fun expenseShareDao(): ExpenseShareDao + abstract fun userDao(): UserDao companion object { @Volatile private var INSTANCE: AppDatabase? = null 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 c693ed3..295fe5f 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 @@ -18,7 +18,6 @@ fun AppContent( onLogin: (String, String, String) -> Unit, // Expenses - expenses: List, onExpenseClick: (Expense) -> Unit, // Account @@ -41,7 +40,6 @@ fun AppContent( selectedAccount != null -> DashboardScreen( // Data and regarding Methods account = selectedAccount, - expenses = expenses, onExpenseClick = onExpenseClick, // Default Methods 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 038ab38..cc3b3e1 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 @@ -24,6 +24,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text 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 @@ -32,13 +34,13 @@ 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.repository.Resource import de.miaurizius.shap_planner.viewmodels.MainViewModel @Composable fun DashboardScreen( // Data and regarding Methods account: Account, - expenses: List, onExpenseClick: (Expense) -> Unit, // Default Methods @@ -49,8 +51,10 @@ fun DashboardScreen( onValidate: () -> Unit, onSessionInvalid: () -> Unit) { + val expenseResource by mainViewModel.expenseResource.collectAsState() + LaunchedEffect(Unit) { onValidate() } - mainViewModel.loadExpenses(account) + LaunchedEffect(account) { mainViewModel.loadExpenses(account, forceRefresh = false) } when (sessionState) { SessionState.Loading -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -102,6 +106,14 @@ fun DashboardScreen( Text("WG-Kosten", style = MaterialTheme.typography.titleLarge) Spacer(modifier = Modifier.height(8.dp)) + if(expenseResource is Resource.Loading && expenseResource.data?.isEmpty() == true) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + if(expenseResource is Resource.Error) { + Text("Fehler: ${expenseResource.message}", color = Color.Red) + } + LazyColumn( modifier = Modifier .fillMaxSize() @@ -109,7 +121,7 @@ fun DashboardScreen( contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(expenses) { expense -> + items(expenseResource.data ?: emptyList()) { expense -> ExpenseItem(expense = expense, onClick = { onExpenseClick(expense) }) } } diff --git a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt index 1638b5b..60058e8 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt @@ -9,9 +9,12 @@ 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.entities.ExpenseDao import de.miaurizius.shap_planner.network.RefreshRequest import de.miaurizius.shap_planner.network.RetrofitProvider import de.miaurizius.shap_planner.network.SessionState +import de.miaurizius.shap_planner.repository.ExpenseRepository +import de.miaurizius.shap_planner.repository.Resource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -19,7 +22,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlin.collections.emptyList -class MainViewModel(private val accountDao: AccountDao, private val tokenStorage: TokenStorage) : ViewModel() { +class MainViewModel( + private val accountDao: AccountDao, + private val expenseDao: ExpenseDao, + private val tokenStorage: TokenStorage +) : ViewModel() { var selectedAccount by mutableStateOf(null) private set @@ -28,22 +35,16 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage var sessionState by mutableStateOf(SessionState.Loading) private set - private val _expenses = MutableStateFlow>(emptyList()) - val expenses: StateFlow> = _expenses + private val _expenseResource = MutableStateFlow>>(Resource.Loading(emptyList())) + val expenseResource: StateFlow>> = _expenseResource - fun loadExpenses(account: Account) { + fun loadExpenses(account: Account, forceRefresh: Boolean = false) { viewModelScope.launch { - try { - val api = RetrofitProvider.create(account.serverUrl) - val accessToken = tokenStorage.getAccess(account.id.toString()) + val api = RetrofitProvider.create(account.serverUrl) + val repo = ExpenseRepository(expenseDao, api) + 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() - } + repo.getExpenses(accessToken, forceRefresh).collect { result -> _expenseResource.value = result } } } @@ -82,7 +83,7 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage sessionState = SessionState.Valid // Fetch data - loadExpenses(account) +// loadExpenses(account) println("All data fetched") return@launch