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<Resource<List<Expense>>>`.
- Enhanced Room entities (`Expense`, `ExpenseShare`, `User`) with `@PrimaryKey` annotations and added a `Converters` class to handle `List<String>` 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.
This commit is contained in:
2026-03-04 11:56:57 +01:00
parent 37d8e8cc74
commit 3d789c0352
10 changed files with 110 additions and 31 deletions

View File

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

View File

@@ -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<String>,
val attachments: List<String>?,
val created_at: Int,
val last_updated_at: Int
)
)
@Dao
interface ExpenseDao {

View File

@@ -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<List<ExpenseShare>>
@Query("SELECT * FROM expense_shares WHERE id = :shareId")
fun getShareById(shareId: UUID)
fun getShareById(shareId: UUID): Flow<ExpenseShare>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ExpenseShare)

View File

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

View File

@@ -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<Resource<List<Expense>>> = 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)) }
}
}

View File

@@ -0,0 +1,7 @@
package de.miaurizius.shap_planner.repository
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T): Resource<T>(data)
class Loading<T>(data: T? = null): Resource<T>(data)
class Error<T>(message: String, data: T? = null): Resource<T>(data, message)
}

View File

@@ -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>?): String? = list?.joinToString(",")
@TypeConverter
fun toList(data: String?): List<String>? = 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

View File

@@ -18,7 +18,6 @@ fun AppContent(
onLogin: (String, String, String) -> Unit,
// Expenses
expenses: List<Expense>,
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

View File

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

View File

@@ -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<Account?>(null)
private set
@@ -28,22 +35,16 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
private set
private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
val expenses: StateFlow<List<Expense>> = _expenses
private val _expenseResource = MutableStateFlow<Resource<List<Expense>>>(Resource.Loading(emptyList()))
val expenseResource: StateFlow<Resource<List<Expense>>> = _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 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