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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user