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 prefs = UserPreferences(this)
|
||||||
val loginViewModel = LoginViewModel(prefs, applicationContext)
|
val loginViewModel = LoginViewModel(prefs, applicationContext)
|
||||||
val database = AppDatabase.getDatabase(applicationContext)
|
val database = AppDatabase.getDatabase(applicationContext)
|
||||||
val dao = database.accountDao()
|
val accountDao = database.accountDao()
|
||||||
|
val expenseDao = database.expenseDao()
|
||||||
val tokenStorage = TokenStorage(applicationContext)
|
val tokenStorage = TokenStorage(applicationContext)
|
||||||
val mainViewModel = MainViewModel(dao, tokenStorage)
|
val mainViewModel = MainViewModel(
|
||||||
|
accountDao,
|
||||||
|
expenseDao,
|
||||||
|
tokenStorage
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -33,7 +38,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
val accountList by mainViewModel.accounts.collectAsState()
|
val accountList by mainViewModel.accounts.collectAsState()
|
||||||
val selectedAccount = mainViewModel.selectedAccount
|
val selectedAccount = mainViewModel.selectedAccount
|
||||||
val showLoginForNewAccount = remember { mutableStateOf(false) }
|
val showLoginForNewAccount = remember { mutableStateOf(false) }
|
||||||
val expenses by mainViewModel.expenses.collectAsState()
|
|
||||||
|
|
||||||
BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
|
BackHandler(enabled = showLoginForNewAccount.value && accountList.isNotEmpty()) {
|
||||||
showLoginForNewAccount.value = false
|
showLoginForNewAccount.value = false
|
||||||
@@ -54,7 +58,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
sessionState = mainViewModel.sessionState,
|
sessionState = mainViewModel.sessionState,
|
||||||
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
|
onValidateSession = { mainViewModel.validateSession(selectedAccount!!) },
|
||||||
onSessionInvalid = { mainViewModel.logoutFromAccount() },
|
onSessionInvalid = { mainViewModel.logoutFromAccount() },
|
||||||
expenses = expenses,
|
|
||||||
onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
|
onExpenseClick = { expense -> println("Clicked: ${expense.title}") },
|
||||||
viewModel = mainViewModel
|
viewModel = mainViewModel
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ import androidx.room.Delete
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(tableName = "expenses")
|
@Entity(tableName = "expenses")
|
||||||
data class Expense (
|
data class Expense (
|
||||||
val id: UUID,
|
@PrimaryKey val id: UUID,
|
||||||
val payer_id: UUID,
|
val payer_id: UUID,
|
||||||
val amount: Int,
|
val amount: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val attachments: List<String>,
|
val attachments: List<String>?,
|
||||||
val created_at: Int,
|
val created_at: Int,
|
||||||
val last_updated_at: Int
|
val last_updated_at: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ExpenseDao {
|
interface ExpenseDao {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import androidx.room.Delete
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(tableName = "expense_shares")
|
@Entity(tableName = "expense_shares")
|
||||||
data class ExpenseShare(
|
data class ExpenseShare(
|
||||||
val id: UUID,
|
@PrimaryKey val id: UUID,
|
||||||
val expense_id: UUID,
|
val expense_id: UUID,
|
||||||
val user_id: UUID,
|
val user_id: UUID,
|
||||||
val share_cents: Int
|
val share_cents: Int
|
||||||
@@ -23,7 +24,7 @@ interface ExpenseShareDao {
|
|||||||
fun getAllShares(): Flow<List<ExpenseShare>>
|
fun getAllShares(): Flow<List<ExpenseShare>>
|
||||||
|
|
||||||
@Query("SELECT * FROM expense_shares WHERE id = :shareId")
|
@Query("SELECT * FROM expense_shares WHERE id = :shareId")
|
||||||
fun getShareById(shareId: UUID)
|
fun getShareById(shareId: UUID): Flow<ExpenseShare>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertShare(share: ExpenseShare)
|
suspend fun insertShare(share: ExpenseShare)
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import androidx.room.Delete
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(tableName = "users")
|
@Entity(tableName = "users")
|
||||||
data class User (
|
data class User (
|
||||||
val id: UUID,
|
@PrimaryKey val id: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
val avatar_url: 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.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.Account
|
||||||
import de.miaurizius.shap_planner.entities.AccountDao
|
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 class AppDatabase : RoomDatabase() {
|
||||||
abstract fun accountDao(): AccountDao
|
abstract fun accountDao(): AccountDao
|
||||||
|
abstract fun expenseDao(): ExpenseDao
|
||||||
|
abstract fun expenseShareDao(): ExpenseShareDao
|
||||||
|
abstract fun userDao(): UserDao
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: AppDatabase? = null
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ fun AppContent(
|
|||||||
onLogin: (String, String, String) -> Unit,
|
onLogin: (String, String, String) -> Unit,
|
||||||
|
|
||||||
// Expenses
|
// Expenses
|
||||||
expenses: List<Expense>,
|
|
||||||
onExpenseClick: (Expense) -> Unit,
|
onExpenseClick: (Expense) -> Unit,
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
@@ -41,7 +40,6 @@ fun AppContent(
|
|||||||
selectedAccount != null -> DashboardScreen(
|
selectedAccount != null -> DashboardScreen(
|
||||||
// Data and regarding Methods
|
// Data and regarding Methods
|
||||||
account = selectedAccount,
|
account = selectedAccount,
|
||||||
expenses = expenses,
|
|
||||||
onExpenseClick = onExpenseClick,
|
onExpenseClick = onExpenseClick,
|
||||||
|
|
||||||
// Default Methods
|
// Default Methods
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.Account
|
||||||
import de.miaurizius.shap_planner.entities.Expense
|
import de.miaurizius.shap_planner.entities.Expense
|
||||||
import de.miaurizius.shap_planner.network.SessionState
|
import de.miaurizius.shap_planner.network.SessionState
|
||||||
|
import de.miaurizius.shap_planner.repository.Resource
|
||||||
import de.miaurizius.shap_planner.viewmodels.MainViewModel
|
import de.miaurizius.shap_planner.viewmodels.MainViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
// Data and regarding Methods
|
// Data and regarding Methods
|
||||||
account: Account,
|
account: Account,
|
||||||
expenses: List<Expense>,
|
|
||||||
onExpenseClick: (Expense) -> Unit,
|
onExpenseClick: (Expense) -> Unit,
|
||||||
|
|
||||||
// Default Methods
|
// Default Methods
|
||||||
@@ -49,8 +51,10 @@ fun DashboardScreen(
|
|||||||
onValidate: () -> Unit,
|
onValidate: () -> Unit,
|
||||||
onSessionInvalid: () -> Unit) {
|
onSessionInvalid: () -> Unit) {
|
||||||
|
|
||||||
|
val expenseResource by mainViewModel.expenseResource.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(Unit) { onValidate() }
|
LaunchedEffect(Unit) { onValidate() }
|
||||||
mainViewModel.loadExpenses(account)
|
LaunchedEffect(account) { mainViewModel.loadExpenses(account, forceRefresh = false) }
|
||||||
when (sessionState) {
|
when (sessionState) {
|
||||||
SessionState.Loading -> {
|
SessionState.Loading -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -102,6 +106,14 @@ fun DashboardScreen(
|
|||||||
Text("WG-Kosten", style = MaterialTheme.typography.titleLarge)
|
Text("WG-Kosten", style = MaterialTheme.typography.titleLarge)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -109,7 +121,7 @@ fun DashboardScreen(
|
|||||||
contentPadding = PaddingValues(8.dp),
|
contentPadding = PaddingValues(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(expenses) { expense ->
|
items(expenseResource.data ?: emptyList()) { expense ->
|
||||||
ExpenseItem(expense = expense, onClick = { onExpenseClick(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.Account
|
||||||
import de.miaurizius.shap_planner.entities.AccountDao
|
import de.miaurizius.shap_planner.entities.AccountDao
|
||||||
import de.miaurizius.shap_planner.entities.Expense
|
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.RefreshRequest
|
||||||
import de.miaurizius.shap_planner.network.RetrofitProvider
|
import de.miaurizius.shap_planner.network.RetrofitProvider
|
||||||
import de.miaurizius.shap_planner.network.SessionState
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -19,7 +22,11 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.collections.emptyList
|
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)
|
var selectedAccount by mutableStateOf<Account?>(null)
|
||||||
private set
|
private set
|
||||||
@@ -28,22 +35,16 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
|
|||||||
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
|
var sessionState by mutableStateOf<SessionState>(SessionState.Loading)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val _expenses = MutableStateFlow<List<Expense>>(emptyList())
|
private val _expenseResource = MutableStateFlow<Resource<List<Expense>>>(Resource.Loading(emptyList()))
|
||||||
val expenses: StateFlow<List<Expense>> = _expenses
|
val expenseResource: StateFlow<Resource<List<Expense>>> = _expenseResource
|
||||||
|
|
||||||
fun loadExpenses(account: Account) {
|
fun loadExpenses(account: Account, forceRefresh: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val api = RetrofitProvider.create(account.serverUrl)
|
||||||
val api = RetrofitProvider.create(account.serverUrl)
|
val repo = ExpenseRepository(expenseDao, api)
|
||||||
val accessToken = tokenStorage.getAccess(account.id.toString())
|
val accessToken = tokenStorage.getAccess(account.id.toString()) ?: ""
|
||||||
|
|
||||||
val response = api.expenseGet("Bearer $accessToken")
|
repo.getExpenses(accessToken, forceRefresh).collect { result -> _expenseResource.value = result }
|
||||||
if (response.isSuccessful) {
|
|
||||||
_expenses.value = response.body()?.expenses ?: emptyList()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_expenses.value = emptyList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
|
|||||||
sessionState = SessionState.Valid
|
sessionState = SessionState.Valid
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data
|
||||||
loadExpenses(account)
|
// loadExpenses(account)
|
||||||
println("All data fetched")
|
println("All data fetched")
|
||||||
|
|
||||||
return@launch
|
return@launch
|
||||||
|
|||||||
Reference in New Issue
Block a user