Add onboarding UI tests and improve app data management
- Add Suite0_OnboardingTests with fresh install and login test flows - Add accessibility identifiers to onboarding views for UI testing - Remove deprecated DataCache in favor of unified DataManager - Update API layer to support public upgrade-triggers endpoint - Improve onboarding first task view with better date handling - Update various views with accessibility identifiers for testing - Fix subscription feature comparison view layout - Update document detail view improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,301 +0,0 @@
|
||||
package com.example.casera.cache
|
||||
|
||||
import com.example.casera.models.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
//import kotlinx.datetime.Clock
|
||||
//import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Centralized data cache for the application.
|
||||
* This singleton holds all frequently accessed data in memory to avoid redundant API calls.
|
||||
*/
|
||||
object DataCache {
|
||||
|
||||
// User & Authentication
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
||||
|
||||
// Residences
|
||||
private val _residences = MutableStateFlow<List<Residence>>(emptyList())
|
||||
val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
|
||||
|
||||
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
||||
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
||||
|
||||
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
||||
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
||||
|
||||
// Tasks
|
||||
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||
|
||||
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
||||
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
||||
|
||||
// Documents
|
||||
private val _documents = MutableStateFlow<List<Document>>(emptyList())
|
||||
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
|
||||
|
||||
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
|
||||
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
||||
|
||||
// Contractors
|
||||
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
|
||||
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
|
||||
|
||||
// Lookups/Reference Data - List-based (for dropdowns/pickers)
|
||||
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
|
||||
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
|
||||
|
||||
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
|
||||
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
|
||||
|
||||
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
|
||||
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
|
||||
|
||||
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
|
||||
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses.asStateFlow()
|
||||
|
||||
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
||||
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
|
||||
|
||||
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
||||
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
|
||||
|
||||
// Lookups/Reference Data - Map-based (for O(1) ID resolution)
|
||||
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
|
||||
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
|
||||
|
||||
private val _taskFrequenciesMap = MutableStateFlow<Map<Int, TaskFrequency>>(emptyMap())
|
||||
val taskFrequenciesMap: StateFlow<Map<Int, TaskFrequency>> = _taskFrequenciesMap.asStateFlow()
|
||||
|
||||
private val _taskPrioritiesMap = MutableStateFlow<Map<Int, TaskPriority>>(emptyMap())
|
||||
val taskPrioritiesMap: StateFlow<Map<Int, TaskPriority>> = _taskPrioritiesMap.asStateFlow()
|
||||
|
||||
private val _taskStatusesMap = MutableStateFlow<Map<Int, TaskStatus>>(emptyMap())
|
||||
val taskStatusesMap: StateFlow<Map<Int, TaskStatus>> = _taskStatusesMap.asStateFlow()
|
||||
|
||||
private val _taskCategoriesMap = MutableStateFlow<Map<Int, TaskCategory>>(emptyMap())
|
||||
val taskCategoriesMap: StateFlow<Map<Int, TaskCategory>> = _taskCategoriesMap.asStateFlow()
|
||||
|
||||
private val _contractorSpecialtiesMap = MutableStateFlow<Map<Int, ContractorSpecialty>>(emptyMap())
|
||||
val contractorSpecialtiesMap: StateFlow<Map<Int, ContractorSpecialty>> = _contractorSpecialtiesMap.asStateFlow()
|
||||
|
||||
private val _lookupsInitialized = MutableStateFlow(false)
|
||||
val lookupsInitialized: StateFlow<Boolean> = _lookupsInitialized.asStateFlow()
|
||||
|
||||
// O(1) lookup helper methods - resolve ID to full object
|
||||
fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] }
|
||||
fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] }
|
||||
fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] }
|
||||
fun getTaskStatus(id: Int?): TaskStatus? = id?.let { _taskStatusesMap.value[it] }
|
||||
fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
|
||||
fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] }
|
||||
|
||||
// Cache metadata
|
||||
private val _lastRefreshTime = MutableStateFlow<Long>(0L)
|
||||
val lastRefreshTime: StateFlow<Long> = _lastRefreshTime.asStateFlow()
|
||||
|
||||
private val _isCacheInitialized = MutableStateFlow(false)
|
||||
val isCacheInitialized: StateFlow<Boolean> = _isCacheInitialized.asStateFlow()
|
||||
|
||||
// Update methods
|
||||
fun updateCurrentUser(user: User?) {
|
||||
_currentUser.value = user
|
||||
}
|
||||
|
||||
fun updateResidences(residences: List<Residence>) {
|
||||
_residences.value = residences
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
fun updateMyResidences(myResidences: MyResidencesResponse) {
|
||||
_myResidences.value = myResidences
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
fun updateResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
||||
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
||||
}
|
||||
|
||||
fun updateAllTasks(tasks: TaskColumnsResponse) {
|
||||
_allTasks.value = tasks
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
fun updateTasksByResidence(residenceId: Int, tasks: TaskColumnsResponse) {
|
||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to tasks)
|
||||
}
|
||||
|
||||
fun updateDocuments(documents: List<Document>) {
|
||||
_documents.value = documents
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
fun updateDocumentsByResidence(residenceId: Int, documents: List<Document>) {
|
||||
_documentsByResidence.value = _documentsByResidence.value + (residenceId to documents)
|
||||
}
|
||||
|
||||
fun updateContractors(contractors: List<Contractor>) {
|
||||
_contractors.value = contractors
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
// Lookup update methods removed - lookups are handled by LookupsViewModel
|
||||
|
||||
fun setCacheInitialized(initialized: Boolean) {
|
||||
_isCacheInitialized.value = initialized
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun updateLastRefreshTime() {
|
||||
_lastRefreshTime.value = Clock.System.now().toEpochMilliseconds()
|
||||
}
|
||||
|
||||
// Helper methods to add/update/remove individual items
|
||||
fun addResidence(residence: Residence) {
|
||||
_residences.value = _residences.value + residence
|
||||
}
|
||||
|
||||
fun updateResidence(residence: Residence) {
|
||||
_residences.value = _residences.value.map {
|
||||
if (it.id == residence.id) residence else it
|
||||
}
|
||||
}
|
||||
|
||||
fun removeResidence(residenceId: Int) {
|
||||
_residences.value = _residences.value.filter { it.id != residenceId }
|
||||
// Also clear related caches
|
||||
_tasksByResidence.value = _tasksByResidence.value - residenceId
|
||||
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
||||
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
||||
}
|
||||
|
||||
fun addDocument(document: Document) {
|
||||
_documents.value = _documents.value + document
|
||||
}
|
||||
|
||||
fun updateDocument(document: Document) {
|
||||
_documents.value = _documents.value.map {
|
||||
if (it.id == document.id) document else it
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDocument(documentId: Int) {
|
||||
_documents.value = _documents.value.filter { it.id != documentId }
|
||||
}
|
||||
|
||||
fun addContractor(contractor: Contractor) {
|
||||
_contractors.value = _contractors.value + contractor
|
||||
}
|
||||
|
||||
fun updateContractor(contractor: Contractor) {
|
||||
_contractors.value = _contractors.value.map {
|
||||
if (it.id == contractor.id) contractor else it
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContractor(contractorId: Int) {
|
||||
_contractors.value = _contractors.value.filter { it.id != contractorId }
|
||||
}
|
||||
|
||||
// Lookup update methods - update both list and map versions
|
||||
fun updateResidenceTypes(types: List<ResidenceType>) {
|
||||
_residenceTypes.value = types
|
||||
_residenceTypesMap.value = types.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskFrequencies(frequencies: List<TaskFrequency>) {
|
||||
_taskFrequencies.value = frequencies
|
||||
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskPriorities(priorities: List<TaskPriority>) {
|
||||
_taskPriorities.value = priorities
|
||||
_taskPrioritiesMap.value = priorities.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskStatuses(statuses: List<TaskStatus>) {
|
||||
_taskStatuses.value = statuses
|
||||
_taskStatusesMap.value = statuses.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskCategories(categories: List<TaskCategory>) {
|
||||
_taskCategories.value = categories
|
||||
_taskCategoriesMap.value = categories.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateContractorSpecialties(specialties: List<ContractorSpecialty>) {
|
||||
_contractorSpecialties.value = specialties
|
||||
_contractorSpecialtiesMap.value = specialties.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateAllLookups(staticData: StaticDataResponse) {
|
||||
_residenceTypes.value = staticData.residenceTypes
|
||||
_residenceTypesMap.value = staticData.residenceTypes.associateBy { it.id }
|
||||
_taskFrequencies.value = staticData.taskFrequencies
|
||||
_taskFrequenciesMap.value = staticData.taskFrequencies.associateBy { it.id }
|
||||
_taskPriorities.value = staticData.taskPriorities
|
||||
_taskPrioritiesMap.value = staticData.taskPriorities.associateBy { it.id }
|
||||
_taskStatuses.value = staticData.taskStatuses
|
||||
_taskStatusesMap.value = staticData.taskStatuses.associateBy { it.id }
|
||||
_taskCategories.value = staticData.taskCategories
|
||||
_taskCategoriesMap.value = staticData.taskCategories.associateBy { it.id }
|
||||
_contractorSpecialties.value = staticData.contractorSpecialties
|
||||
_contractorSpecialtiesMap.value = staticData.contractorSpecialties.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun markLookupsInitialized() {
|
||||
_lookupsInitialized.value = true
|
||||
}
|
||||
|
||||
// Clear methods
|
||||
fun clearAll() {
|
||||
_currentUser.value = null
|
||||
_residences.value = emptyList()
|
||||
_myResidences.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
_documents.value = emptyList()
|
||||
_documentsByResidence.value = emptyMap()
|
||||
_contractors.value = emptyList()
|
||||
clearLookups()
|
||||
_lastRefreshTime.value = 0L
|
||||
_isCacheInitialized.value = false
|
||||
}
|
||||
|
||||
fun clearLookups() {
|
||||
_residenceTypes.value = emptyList()
|
||||
_residenceTypesMap.value = emptyMap()
|
||||
_taskFrequencies.value = emptyList()
|
||||
_taskFrequenciesMap.value = emptyMap()
|
||||
_taskPriorities.value = emptyList()
|
||||
_taskPrioritiesMap.value = emptyMap()
|
||||
_taskStatuses.value = emptyList()
|
||||
_taskStatusesMap.value = emptyMap()
|
||||
_taskCategories.value = emptyList()
|
||||
_taskCategoriesMap.value = emptyMap()
|
||||
_contractorSpecialties.value = emptyList()
|
||||
_contractorSpecialtiesMap.value = emptyMap()
|
||||
_lookupsInitialized.value = false
|
||||
}
|
||||
|
||||
fun clearUserData() {
|
||||
_currentUser.value = null
|
||||
_residences.value = emptyList()
|
||||
_myResidences.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
_documents.value = emptyList()
|
||||
_documentsByResidence.value = emptyMap()
|
||||
_contractors.value = emptyList()
|
||||
_isCacheInitialized.value = false
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package com.example.casera.cache
|
||||
|
||||
import com.example.casera.network.*
|
||||
import com.example.casera.storage.TokenStorage
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* Manager responsible for prefetching and caching data when the app launches.
|
||||
* This ensures all screens have immediate access to data without making API calls.
|
||||
*/
|
||||
class DataPrefetchManager {
|
||||
|
||||
private val residenceApi = ResidenceApi()
|
||||
private val taskApi = TaskApi()
|
||||
private val documentApi = DocumentApi()
|
||||
private val contractorApi = ContractorApi()
|
||||
private val lookupsApi = LookupsApi()
|
||||
|
||||
/**
|
||||
* Prefetch all essential data on app launch.
|
||||
* This runs asynchronously and populates the DataCache.
|
||||
*/
|
||||
suspend fun prefetchAllData(): Result<Unit> = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val token = TokenStorage.getToken()
|
||||
if (token == null) {
|
||||
return@withContext Result.failure(Exception("Not authenticated"))
|
||||
}
|
||||
|
||||
println("DataPrefetchManager: Starting data prefetch...")
|
||||
|
||||
// Launch all prefetch operations in parallel
|
||||
val jobs = listOf(
|
||||
async { prefetchResidences(token) },
|
||||
async { prefetchMyResidences(token) },
|
||||
async { prefetchTasks(token) },
|
||||
async { prefetchDocuments(token) },
|
||||
async { prefetchContractors(token) },
|
||||
async { prefetchLookups(token) }
|
||||
)
|
||||
|
||||
// Wait for all jobs to complete
|
||||
jobs.awaitAll()
|
||||
|
||||
// Mark cache as initialized
|
||||
DataCache.setCacheInitialized(true)
|
||||
|
||||
println("DataPrefetchManager: Data prefetch completed successfully")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error during prefetch: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh specific data types.
|
||||
* Useful for pull-to-refresh functionality.
|
||||
*/
|
||||
suspend fun refreshResidences(): Result<Unit> = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
|
||||
prefetchResidences(token)
|
||||
prefetchMyResidences(token)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshTasks(): Result<Unit> = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
|
||||
prefetchTasks(token)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshDocuments(): Result<Unit> = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
|
||||
prefetchDocuments(token)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshContractors(): Result<Unit> = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated"))
|
||||
prefetchContractors(token)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Private prefetch methods
|
||||
private suspend fun prefetchResidences(token: String) {
|
||||
try {
|
||||
println("DataPrefetchManager: Fetching residences...")
|
||||
val result = residenceApi.getResidences(token)
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.updateResidences(result.data)
|
||||
println("DataPrefetchManager: Cached ${result.data.size} residences")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error fetching residences: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prefetchMyResidences(token: String) {
|
||||
try {
|
||||
println("DataPrefetchManager: Fetching my residences...")
|
||||
val result = residenceApi.getMyResidences(token)
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.updateMyResidences(result.data)
|
||||
println("DataPrefetchManager: Cached my residences")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error fetching my residences: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prefetchTasks(token: String) {
|
||||
try {
|
||||
println("DataPrefetchManager: Fetching tasks...")
|
||||
val result = taskApi.getTasks(token)
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.updateAllTasks(result.data)
|
||||
println("DataPrefetchManager: Cached tasks")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error fetching tasks: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prefetchDocuments(token: String) {
|
||||
try {
|
||||
println("DataPrefetchManager: Fetching documents...")
|
||||
val result = documentApi.getDocuments(
|
||||
token = token,
|
||||
residenceId = null,
|
||||
documentType = null,
|
||||
category = null,
|
||||
contractorId = null,
|
||||
isActive = null,
|
||||
expiringSoon = null,
|
||||
tags = null,
|
||||
search = null
|
||||
)
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.updateDocuments(result.data)
|
||||
println("DataPrefetchManager: Cached ${result.data.size} documents")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error fetching documents: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prefetchContractors(token: String) {
|
||||
try {
|
||||
println("DataPrefetchManager: Fetching contractors...")
|
||||
val result = contractorApi.getContractors(
|
||||
token = token,
|
||||
specialty = null,
|
||||
isFavorite = null,
|
||||
isActive = null,
|
||||
search = null
|
||||
)
|
||||
if (result is ApiResult.Success) {
|
||||
// API returns List<ContractorSummary>, not List<Contractor>
|
||||
// Skip caching for now - full Contractor objects will be cached when fetched individually
|
||||
println("DataPrefetchManager: Fetched ${result.data.size} contractor summaries")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataPrefetchManager: Error fetching contractors: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prefetchLookups(token: String) {
|
||||
// Lookups are handled separately by LookupsViewModel with their own caching
|
||||
println("DataPrefetchManager: Skipping lookups prefetch (handled by LookupsViewModel)")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: DataPrefetchManager? = null
|
||||
|
||||
fun getInstance(): DataPrefetchManager {
|
||||
if (instance == null) {
|
||||
instance = DataPrefetchManager()
|
||||
}
|
||||
return instance!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
# Data Caching Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This app now uses a centralized caching system to avoid redundant API calls when navigating between screens.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **App Launch**: When the app launches and the user is authenticated, `DataPrefetchManager` automatically loads all essential data in parallel:
|
||||
- Residences (all + my residences)
|
||||
- Tasks (all tasks)
|
||||
- Documents
|
||||
- Contractors
|
||||
- Lookup data (categories, priorities, frequencies, statuses)
|
||||
|
||||
2. **Data Access**: ViewModels check the `DataCache` first before making API calls:
|
||||
- If cache has data and `forceRefresh=false`: Use cached data immediately
|
||||
- If cache is empty or `forceRefresh=true`: Fetch from API and update cache
|
||||
|
||||
3. **Cache Updates**: When create/update/delete operations succeed, the cache is automatically updated
|
||||
|
||||
## Usage in ViewModels
|
||||
|
||||
### Load Data (with caching)
|
||||
```kotlin
|
||||
// In ViewModel
|
||||
fun loadResidences(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
// Check cache first
|
||||
val cachedData = DataCache.residences.value
|
||||
if (!forceRefresh && cachedData.isNotEmpty()) {
|
||||
_residencesState.value = ApiResult.Success(cachedData)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Fetch from API if needed
|
||||
_residencesState.value = ApiResult.Loading
|
||||
val result = residenceApi.getResidences(token)
|
||||
_residencesState.value = result
|
||||
|
||||
// Update cache on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.updateResidences(result.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Cache After Mutations
|
||||
```kotlin
|
||||
fun createResidence(request: ResidenceCreateRequest) {
|
||||
viewModelScope.launch {
|
||||
val result = residenceApi.createResidence(token, request)
|
||||
_createState.value = result
|
||||
|
||||
// Update cache on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataCache.addResidence(result.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## iOS Integration
|
||||
|
||||
In your iOS app's main initialization (e.g., `iOSApp.swift`):
|
||||
|
||||
```swift
|
||||
import ComposeApp
|
||||
|
||||
@main
|
||||
struct MyCribApp: App {
|
||||
init() {
|
||||
// After successful login, prefetch data
|
||||
Task {
|
||||
await prefetchData()
|
||||
}
|
||||
}
|
||||
|
||||
func prefetchData() async {
|
||||
let prefetchManager = DataPrefetchManager.Companion().getInstance()
|
||||
_ = try? await prefetchManager.prefetchAllData()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Android Integration
|
||||
|
||||
In your Android `MainActivity`:
|
||||
|
||||
```kotlin
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Check if authenticated
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
// Prefetch data in background
|
||||
lifecycleScope.launch {
|
||||
DataPrefetchManager.getInstance().prefetchAllData()
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
// Your compose content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pull to Refresh
|
||||
|
||||
To refresh data manually (e.g., pull-to-refresh):
|
||||
|
||||
```kotlin
|
||||
// In ViewModel
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
prefetchManager.refreshResidences()
|
||||
loadResidences(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Instant Screen Load**: Screens show data immediately from cache
|
||||
2. **Reduced API Calls**: No redundant calls when navigating between screens
|
||||
3. **Better UX**: No loading spinners on every screen transition
|
||||
4. **Offline Support**: Data remains available even with poor connectivity
|
||||
5. **Consistent State**: All screens see the same data from cache
|
||||
|
||||
## Cache Lifecycle
|
||||
|
||||
- **Initialization**: App launch (after authentication)
|
||||
- **Updates**: After successful create/update/delete operations
|
||||
- **Clear**: On logout or authentication error
|
||||
- **Refresh**: Manual pull-to-refresh or `forceRefresh=true`
|
||||
|
||||
## ViewModels Updated
|
||||
|
||||
The following ViewModels now use caching:
|
||||
|
||||
- ✅ `ResidenceViewModel`
|
||||
- ✅ `TaskViewModel`
|
||||
- ⏳ `DocumentViewModel` (TODO)
|
||||
- ⏳ `ContractorViewModel` (TODO)
|
||||
- ⏳ `LookupsViewModel` (TODO)
|
||||
|
||||
## Note
|
||||
|
||||
For DocumentViewModel and ContractorViewModel, follow the same pattern as shown in ResidenceViewModel and TaskViewModel.
|
||||
@@ -58,23 +58,28 @@ object APILayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all lookup data. Should be called once after login.
|
||||
* Initialize all lookup data. Can be called at app start even without authentication.
|
||||
* Loads all reference data (residence types, task categories, priorities, etc.) into DataManager.
|
||||
*
|
||||
* - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required)
|
||||
* - /subscription/status/ requires auth and is only called if user is authenticated
|
||||
*/
|
||||
suspend fun initializeLookups(): ApiResult<Unit> {
|
||||
val token = getToken()
|
||||
|
||||
if (DataManager.lookupsInitialized.value) {
|
||||
// Lookups already initialized, but refresh subscription status
|
||||
// Lookups already initialized, but refresh subscription status if authenticated
|
||||
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
|
||||
refreshSubscriptionStatus()
|
||||
if (token != null) {
|
||||
refreshSubscriptionStatus()
|
||||
}
|
||||
return ApiResult.Success(Unit)
|
||||
}
|
||||
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
|
||||
try {
|
||||
// Load all lookups in a single API call using static_data endpoint
|
||||
// Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required)
|
||||
println("🔄 Fetching static data (all lookups)...")
|
||||
val staticDataResult = lookupsApi.getStaticData(token)
|
||||
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
|
||||
println("📦 Static data result: $staticDataResult")
|
||||
|
||||
// Update DataManager with all lookups at once
|
||||
@@ -86,24 +91,11 @@ object APILayer {
|
||||
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
||||
}
|
||||
|
||||
// Load subscription status to get limitationsEnabled, usage, and limits from backend
|
||||
println("🔄 Fetching subscription status...")
|
||||
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
|
||||
println("📦 Subscription status result: $subscriptionStatusResult")
|
||||
|
||||
// Load upgrade triggers
|
||||
// Load upgrade triggers (PUBLIC - no auth required)
|
||||
println("🔄 Fetching upgrade triggers...")
|
||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
|
||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
|
||||
println("📦 Upgrade triggers result: $upgradeTriggersResult")
|
||||
|
||||
if (subscriptionStatusResult is ApiResult.Success) {
|
||||
println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}")
|
||||
DataManager.setSubscription(subscriptionStatusResult.data)
|
||||
println("✅ Subscription updated successfully")
|
||||
} else if (subscriptionStatusResult is ApiResult.Error) {
|
||||
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
|
||||
}
|
||||
|
||||
if (upgradeTriggersResult is ApiResult.Success) {
|
||||
println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers")
|
||||
DataManager.setUpgradeTriggers(upgradeTriggersResult.data)
|
||||
@@ -112,6 +104,23 @@ object APILayer {
|
||||
println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}")
|
||||
}
|
||||
|
||||
// Load subscription status only if authenticated (requires auth for user-specific data)
|
||||
if (token != null) {
|
||||
println("🔄 Fetching subscription status...")
|
||||
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
|
||||
println("📦 Subscription status result: $subscriptionStatusResult")
|
||||
|
||||
if (subscriptionStatusResult is ApiResult.Success) {
|
||||
println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}")
|
||||
DataManager.setSubscription(subscriptionStatusResult.data)
|
||||
println("✅ Subscription updated successfully")
|
||||
} else if (subscriptionStatusResult is ApiResult.Error) {
|
||||
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
|
||||
}
|
||||
} else {
|
||||
println("⏭️ Skipping subscription status (not authenticated)")
|
||||
}
|
||||
|
||||
DataManager.markLookupsInitialized()
|
||||
return ApiResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
@@ -887,7 +896,17 @@ object APILayer {
|
||||
}
|
||||
|
||||
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
|
||||
return authApi.register(request)
|
||||
val result = authApi.register(request)
|
||||
|
||||
// Update DataManager on success (same as login)
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setAuthToken(result.data.token)
|
||||
DataManager.setCurrentUser(result.data.user)
|
||||
// Initialize lookups after successful registration
|
||||
initializeLookups()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun logout(): ApiResult<Unit> {
|
||||
|
||||
@@ -121,10 +121,11 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStaticData(token: String): ApiResult<StaticDataResponse> {
|
||||
suspend fun getStaticData(token: String? = null): ApiResult<StaticDataResponse> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/static_data/") {
|
||||
header("Authorization", "Token $token")
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
|
||||
@@ -25,10 +25,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUpgradeTriggers(token: String): ApiResult<Map<String, UpgradeTriggerData>> {
|
||||
suspend fun getUpgradeTriggers(token: String? = null): ApiResult<Map<String, UpgradeTriggerData>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
|
||||
header("Authorization", "Token $token")
|
||||
// Token is optional - endpoint is public
|
||||
token?.let { header("Authorization", "Token $it") }
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
|
||||
@@ -153,7 +153,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
|
||||
// DEPRECATED: These methods now use PATCH internally.
|
||||
// They're kept for backward compatibility with existing ViewModel calls.
|
||||
// New code should use patchTask directly with status IDs from DataCache.
|
||||
// New code should use patchTask directly with status IDs from DataManager.
|
||||
|
||||
suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult<TaskResponse> {
|
||||
return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId))
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.casera.cache.DataCache
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.ui.components.AddContractorDialog
|
||||
import com.example.casera.ui.components.ApiResultHandler
|
||||
import com.example.casera.ui.components.HandleErrors
|
||||
@@ -117,7 +117,7 @@ fun ContractorDetailScreen(
|
||||
.background(Color(0xFFF9FAFB))
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val residences = DataCache.residences.value
|
||||
val residences = DataManager.residences.value
|
||||
|
||||
ApiResultHandler(
|
||||
state = contractorState,
|
||||
|
||||
@@ -34,7 +34,7 @@ import com.example.casera.network.ApiResult
|
||||
import com.example.casera.utils.SubscriptionHelper
|
||||
import com.example.casera.ui.subscription.UpgradePromptDialog
|
||||
import com.example.casera.cache.SubscriptionCache
|
||||
import com.example.casera.cache.DataCache
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.util.DateUtils
|
||||
import casera.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -77,7 +77,7 @@ fun ResidenceDetailScreen(
|
||||
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Get current user for ownership checks
|
||||
val currentUser by DataCache.currentUser.collectAsState()
|
||||
val currentUser by DataManager.currentUser.collectAsState()
|
||||
|
||||
// Check if tasks are blocked (limit=0) - this hides the FAB
|
||||
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
|
||||
|
||||
@@ -28,14 +28,14 @@ import com.example.casera.ui.theme.AppRadius
|
||||
import com.example.casera.ui.theme.AppSpacing
|
||||
import com.example.casera.viewmodel.OnboardingViewModel
|
||||
import casera.composeapp.generated.resources.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import com.example.casera.util.DateUtils
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
private fun generateId(): String = Random.nextLong().toString(36)
|
||||
|
||||
data class OnboardingTaskTemplate(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val id: String = generateId(),
|
||||
val icon: ImageVector,
|
||||
val title: String,
|
||||
val category: String,
|
||||
@@ -43,7 +43,7 @@ data class OnboardingTaskTemplate(
|
||||
)
|
||||
|
||||
data class OnboardingTaskCategory(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val id: String = generateId(),
|
||||
val name: String,
|
||||
val icon: ImageVector,
|
||||
val color: Color,
|
||||
@@ -56,8 +56,8 @@ fun OnboardingFirstTaskContent(
|
||||
onTasksAdded: () -> Unit
|
||||
) {
|
||||
val maxTasksAllowed = 5
|
||||
var selectedTaskIds by remember { mutableStateOf(setOf<UUID>()) }
|
||||
var expandedCategoryId by remember { mutableStateOf<UUID?>(null) }
|
||||
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var isCreatingTasks by remember { mutableStateOf(false) }
|
||||
|
||||
val createTasksState by viewModel.createTasksState.collectAsState()
|
||||
@@ -328,10 +328,7 @@ fun OnboardingFirstTaskContent(
|
||||
val residences = DataManager.residences.value
|
||||
val residence = residences.firstOrNull()
|
||||
if (residence != null) {
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
.date
|
||||
.toString()
|
||||
val today = DateUtils.getTodayString()
|
||||
|
||||
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
|
||||
val taskRequests = selectedTemplates.map { template ->
|
||||
@@ -397,11 +394,11 @@ fun OnboardingFirstTaskContent(
|
||||
@Composable
|
||||
private fun TaskCategorySection(
|
||||
category: OnboardingTaskCategory,
|
||||
selectedTaskIds: Set<UUID>,
|
||||
selectedTaskIds: Set<String>,
|
||||
isExpanded: Boolean,
|
||||
isAtMaxSelection: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onToggleTask: (UUID) -> Unit
|
||||
onToggleTask: (String) -> Unit
|
||||
) {
|
||||
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ object DateUtils {
|
||||
return instant.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date as an ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
fun getTodayString(): String {
|
||||
return getToday().toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string (YYYY-MM-DD) to a human-readable format
|
||||
* Returns "Today", "Tomorrow", "Yesterday", or "Mon, Dec 15" format
|
||||
|
||||
Reference in New Issue
Block a user