package com.tt.honeyDue.data import com.tt.honeyDue.models.* import com.tt.honeyDue.storage.TokenManager import com.tt.honeyDue.storage.ThemeStorageManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.time.Clock import kotlin.time.ExperimentalTime /** * Unified DataManager - Single Source of Truth for all app data. * * Core Principles: * 1. All data is cached here - no other caches exist * 2. Every API response updates DataManager immediately * 3. All screens observe DataManager StateFlows directly * 4. Auth token and theme preferences are persisted via platform-specific managers * * Disk Persistence (survives app restart): * - Current user, auth token (via TokenManager), theme (via ThemeStorageManager) * - Lookup/reference data: categories, priorities, frequencies, specialties, residence types, task templates * - ETag values for conditional fetching, onboarding completion flag * * In-memory only (re-fetched on app launch via prefetchAllData): * - Residences, tasks, documents, contractors * - Subscription status, summaries, upgrade triggers, feature benefits, promotions * * Data Flow: * User Action → API Call → Server Response → DataManager Updated → All Screens React */ object DataManager : IDataManager { // ==================== CACHE CONFIGURATION ==================== /** * Default cache timeout in milliseconds. * Data older than this will be refreshed from the API. * Default: 1 hour (3600000ms) */ const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour /** * Per-entity cache TTLs for data with different freshness requirements. */ object CacheTTL { /** Lookups (categories, priorities, frequencies) — rarely change */ const val LOOKUPS_MS: Long = 24 * 60 * 60 * 1000L // 24 hours /** Entity data (residences, tasks, documents, contractors) */ const val ENTITIES_MS: Long = 60 * 60 * 1000L // 1 hour /** Subscription status — needs frequent refresh */ const val SUBSCRIPTION_MS: Long = 30 * 60 * 1000L // 30 minutes } // Cache timestamps for each data type (epoch milliseconds) var residencesCacheTime: Long = 0L private set var myResidencesCacheTime: Long = 0L private set var tasksCacheTime: Long = 0L private set var tasksByResidenceCacheTime: MutableMap = mutableMapOf() private set var contractorsCacheTime: Long = 0L private set var documentsCacheTime: Long = 0L private set var summaryCacheTime: Long = 0L private set /** * Check if cache for a given timestamp is still valid (not expired). * @param cacheTime Epoch milliseconds when the cache was last set. * @param ttlMs Optional TTL override. Defaults to CACHE_TIMEOUT_MS (1 hour). * Use CacheTTL.LOOKUPS_MS for lookups, CacheTTL.SUBSCRIPTION_MS for subscription. */ @OptIn(ExperimentalTime::class) fun isCacheValid(cacheTime: Long, ttlMs: Long = CACHE_TIMEOUT_MS): Boolean { if (cacheTime == 0L) return false val now = Clock.System.now().toEpochMilliseconds() return (now - cacheTime) < ttlMs } /** * Get current timestamp in milliseconds */ @OptIn(ExperimentalTime::class) private fun currentTimeMs(): Long = Clock.System.now().toEpochMilliseconds() // Platform-specific persistence managers (initialized at app start) private var tokenManager: TokenManager? = null private var themeManager: ThemeStorageManager? = null private var persistenceManager: PersistenceManager? = null private val json = Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = true } // ==================== AUTHENTICATION ==================== private val _authToken = MutableStateFlow(null) val authToken: StateFlow = _authToken.asStateFlow() private val _currentUser = MutableStateFlow(null) override val currentUser: StateFlow = _currentUser.asStateFlow() // ==================== APP PREFERENCES ==================== private val _themeId = MutableStateFlow("default") val themeId: StateFlow = _themeId.asStateFlow() // ==================== ONBOARDING ==================== private val _hasCompletedOnboarding = MutableStateFlow(false) val hasCompletedOnboarding: StateFlow = _hasCompletedOnboarding.asStateFlow() // ==================== RESIDENCES ==================== private val _residences = MutableStateFlow>(emptyList()) override val residences: StateFlow> = _residences.asStateFlow() private val _myResidences = MutableStateFlow(null) override val myResidences: StateFlow = _myResidences.asStateFlow() private val _totalSummary = MutableStateFlow(null) override val totalSummary: StateFlow = _totalSummary.asStateFlow() private val _residenceSummaries = MutableStateFlow>(emptyMap()) override val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() // ==================== TASKS ==================== private val _allTasks = MutableStateFlow(null) override val allTasks: StateFlow = _allTasks.asStateFlow() private val _tasksByResidence = MutableStateFlow>(emptyMap()) override val tasksByResidence: StateFlow> = _tasksByResidence.asStateFlow() private val _taskCompletions = MutableStateFlow>>(emptyMap()) override val taskCompletions: StateFlow>> = _taskCompletions.asStateFlow() // ==================== DOCUMENTS ==================== private val _documents = MutableStateFlow>(emptyList()) override val documents: StateFlow> = _documents.asStateFlow() private val _documentsByResidence = MutableStateFlow>>(emptyMap()) override val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() private val _documentDetail = MutableStateFlow>(emptyMap()) override val documentDetail: StateFlow> = _documentDetail.asStateFlow() // ==================== CONTRACTORS ==================== // Stores ContractorSummary for list views (lighter weight than full Contractor) private val _contractors = MutableStateFlow>(emptyList()) override val contractors: StateFlow> = _contractors.asStateFlow() private val _contractorsByResidence = MutableStateFlow>>(emptyMap()) override val contractorsByResidence: StateFlow>> = _contractorsByResidence.asStateFlow() private val _contractorDetail = MutableStateFlow>(emptyMap()) override val contractorDetail: StateFlow> = _contractorDetail.asStateFlow() // ==================== SUBSCRIPTION ==================== private val _subscription = MutableStateFlow(null) override val subscription: StateFlow = _subscription.asStateFlow() private val _upgradeTriggers = MutableStateFlow>(emptyMap()) override val upgradeTriggers: StateFlow> = _upgradeTriggers.asStateFlow() private val _featureBenefits = MutableStateFlow>(emptyList()) override val featureBenefits: StateFlow> = _featureBenefits.asStateFlow() private val _promotions = MutableStateFlow>(emptyList()) override val promotions: StateFlow> = _promotions.asStateFlow() // ==================== LOOKUPS (Reference Data) ==================== // List-based for dropdowns/pickers private val _residenceTypes = MutableStateFlow>(emptyList()) override val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() private val _taskFrequencies = MutableStateFlow>(emptyList()) override val taskFrequencies: StateFlow> = _taskFrequencies.asStateFlow() private val _taskPriorities = MutableStateFlow>(emptyList()) override val taskPriorities: StateFlow> = _taskPriorities.asStateFlow() private val _taskCategories = MutableStateFlow>(emptyList()) override val taskCategories: StateFlow> = _taskCategories.asStateFlow() private val _contractorSpecialties = MutableStateFlow>(emptyList()) override val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() // ==================== TASK TEMPLATES ==================== private val _taskTemplates = MutableStateFlow>(emptyList()) override val taskTemplates: StateFlow> = _taskTemplates.asStateFlow() private val _taskTemplatesGrouped = MutableStateFlow(null) override val taskTemplatesGrouped: StateFlow = _taskTemplatesGrouped.asStateFlow() // Map-based for O(1) ID resolution private val _residenceTypesMap = MutableStateFlow>(emptyMap()) val residenceTypesMap: StateFlow> = _residenceTypesMap.asStateFlow() private val _taskFrequenciesMap = MutableStateFlow>(emptyMap()) val taskFrequenciesMap: StateFlow> = _taskFrequenciesMap.asStateFlow() private val _taskPrioritiesMap = MutableStateFlow>(emptyMap()) val taskPrioritiesMap: StateFlow> = _taskPrioritiesMap.asStateFlow() private val _taskCategoriesMap = MutableStateFlow>(emptyMap()) val taskCategoriesMap: StateFlow> = _taskCategoriesMap.asStateFlow() private val _contractorSpecialtiesMap = MutableStateFlow>(emptyMap()) val contractorSpecialtiesMap: StateFlow> = _contractorSpecialtiesMap.asStateFlow() // ==================== STATE METADATA ==================== private val _isInitialized = MutableStateFlow(false) val isInitialized: StateFlow = _isInitialized.asStateFlow() private val _lookupsInitialized = MutableStateFlow(false) val lookupsInitialized: StateFlow = _lookupsInitialized.asStateFlow() private val _lastSyncTime = MutableStateFlow(0L) val lastSyncTime: StateFlow = _lastSyncTime.asStateFlow() // ==================== SEEDED DATA ETAG ==================== private val _seededDataETag = MutableStateFlow(null) val seededDataETag: StateFlow = _seededDataETag.asStateFlow() // ==================== INITIALIZATION ==================== /** * Initialize DataManager with platform-specific managers. * Call this once at app startup. */ fun initialize( tokenMgr: TokenManager, themeMgr: ThemeStorageManager, persistenceMgr: PersistenceManager ) { tokenManager = tokenMgr themeManager = themeMgr persistenceManager = persistenceMgr // Load auth token from secure storage _authToken.value = tokenMgr.getToken() // Load theme preference _themeId.value = themeMgr.getThemeId() ?: "default" // Load cached data from disk loadFromDisk() _isInitialized.value = true } /** * Check if user is authenticated (has valid token) */ fun isAuthenticated(): Boolean = _authToken.value != null // ==================== O(1) LOOKUP HELPERS ==================== override fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] } override fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] } override fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] } override fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] } override fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] } // ==================== AUTH UPDATE METHODS ==================== fun setAuthToken(token: String?) { _authToken.value = token if (token != null) { tokenManager?.saveToken(token) } else { tokenManager?.clearToken() } } fun setCurrentUser(user: User?) { _currentUser.value = user persistToDisk() } // ==================== THEME UPDATE METHODS ==================== fun setThemeId(id: String) { _themeId.value = id themeManager?.saveThemeId(id) } // ==================== ONBOARDING UPDATE METHODS ==================== fun setHasCompletedOnboarding(completed: Boolean) { _hasCompletedOnboarding.value = completed persistenceManager?.save(KEY_HAS_COMPLETED_ONBOARDING, completed.toString()) } // ==================== RESIDENCE UPDATE METHODS ==================== fun setResidences(residences: List) { _residences.value = residences residencesCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } fun setMyResidences(response: MyResidencesResponse) { _myResidences.value = response myResidencesCacheTime = currentTimeMs() summaryCacheTime = currentTimeMs() updateLastSyncTime() // Calculate summary from cached kanban data (API no longer returns summary stats) // This ensures summary is always up-to-date when residence view is shown refreshSummaryFromKanban() persistToDisk() } fun setTotalSummary(summary: TotalSummary) { _totalSummary.value = summary summaryCacheTime = currentTimeMs() persistToDisk() } /** * Calculate TotalSummary from cached kanban data. * This allows the client to compute summary stats without waiting for API responses * after CRUD operations (API now returns empty summaries for performance). * * Columns from API: overdue_tasks, in_progress_tasks, due_soon_tasks, upcoming_tasks, completed_tasks, cancelled_tasks */ fun calculateSummaryFromKanban(): TotalSummary { val kanban = _allTasks.value ?: return _totalSummary.value ?: TotalSummary() val residenceCount = _myResidences.value?.residences?.size ?: _residences.value.size var overdueCount = 0 var inProgressCount = 0 var dueSoonCount = 0 var upcomingCount = 0 var completedCount = 0 for (column in kanban.columns) { when (column.name) { "overdue_tasks" -> overdueCount = column.count "in_progress_tasks" -> inProgressCount = column.count "due_soon_tasks" -> dueSoonCount = column.count "upcoming_tasks" -> upcomingCount = column.count "completed_tasks" -> completedCount = column.count // cancelled_tasks is not counted in totals } } // totalTasks = all non-cancelled tasks val totalTasks = overdueCount + inProgressCount + dueSoonCount + upcomingCount + completedCount // totalPending = not completed (i.e., still needs attention) val totalPending = overdueCount + inProgressCount + dueSoonCount + upcomingCount return TotalSummary( totalResidences = residenceCount, totalTasks = totalTasks, totalPending = totalPending, totalOverdue = overdueCount, // due_soon_tasks column = tasks due within threshold (default 30 days) // upcoming_tasks column = tasks with dates beyond threshold OR no due date // Both "Due This Week" and "Next 30 Days" use due_soon since it represents the 30-day window tasksDueNextWeek = dueSoonCount, tasksDueNextMonth = dueSoonCount ) } /** * Update totalSummary from cached kanban data. * Call this after task CRUD operations to keep summary in sync without API call. */ fun refreshSummaryFromKanban() { val calculatedSummary = calculateSummaryFromKanban() _totalSummary.value = calculatedSummary } fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) { _residenceSummaries.value = _residenceSummaries.value + (residenceId to summary) persistToDisk() } /** * Add a new residence to the cache. * Caches affected: _residences, _myResidences * Invalidation trigger: createResidence API success */ fun addResidence(residence: Residence) { _residences.value = _residences.value + residence // Also append to myResidences if it has been loaded _myResidences.value?.let { myRes -> _myResidences.value = myRes.copy(residences = myRes.residences + residence) } updateLastSyncTime() persistToDisk() } fun updateResidence(residence: Residence) { _residences.value = _residences.value.map { if (it.id == residence.id) residence else it } // Also update myResidences if present (used by getResidence cache lookup) _myResidences.value?.let { myRes -> val updatedResidences = myRes.residences.map { if (it.id == residence.id) residence else it } _myResidences.value = myRes.copy(residences = updatedResidences) } persistToDisk() } fun removeResidence(residenceId: Int) { _residences.value = _residences.value.filter { it.id != residenceId } _tasksByResidence.value = _tasksByResidence.value - residenceId _documentsByResidence.value = _documentsByResidence.value - residenceId _residenceSummaries.value = _residenceSummaries.value - residenceId // Also update myResidences so the list view updates immediately _myResidences.value?.let { current -> _myResidences.value = current.copy( residences = current.residences.filter { it.id != residenceId } ) } persistToDisk() } // ==================== CACHE INVALIDATION ==================== /** Invalidate the tasks cache so the next loadTasks() fetches fresh from API. */ fun invalidateTasksCache() { tasksCacheTime = 0L } // ==================== TASK UPDATE METHODS ==================== fun setAllTasks(response: TaskColumnsResponse) { _allTasks.value = response tasksCacheTime = currentTimeMs() updateLastSyncTime() // Refresh summary from kanban data (API no longer returns summary stats) refreshSummaryFromKanban() persistToDisk() } fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) { _tasksByResidence.value = _tasksByResidence.value + (residenceId to response) tasksByResidenceCacheTime[residenceId] = currentTimeMs() persistToDisk() } /** Populate the per-task completion cache (used by TaskViewModel's derived flow). */ fun setTaskCompletions(taskId: Int, completions: List) { _taskCompletions.value = _taskCompletions.value + (taskId to completions) } /** * Filter cached allTasks by residence ID to avoid separate API call. * Returns null if allTasks not cached. * This enables client-side filtering when we already have all tasks loaded. */ fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? { val allTasksData = _allTasks.value ?: return null // Filter each column's tasks by residence ID val filteredColumns = allTasksData.columns.map { column -> column.copy( tasks = column.tasks.filter { it.residenceId == residenceId }, count = column.tasks.count { it.residenceId == residenceId } ) } return TaskColumnsResponse( columns = filteredColumns, daysThreshold = allTasksData.daysThreshold, residenceId = residenceId.toString() ) } /** * Update a single task - moves it to the correct kanban column based on kanban_column field. * This is called after task completion, status change, etc. * Also refreshes the summary from the updated kanban data. */ fun updateTask(task: TaskResponse) { // Update in allTasks _allTasks.value?.let { current -> val targetColumn = task.kanbanColumn ?: "upcoming_tasks" val newColumns = current.columns.map { column -> // Remove task from this column if present val filteredTasks = column.tasks.filter { it.id != task.id } // Add task if this is the target column val updatedTasks = if (column.name == targetColumn) { filteredTasks + task } else { filteredTasks } column.copy(tasks = updatedTasks, count = updatedTasks.size) } _allTasks.value = current.copy(columns = newColumns) } // Update in tasksByResidence if this task's residence is cached task.residenceId?.let { residenceId -> _tasksByResidence.value[residenceId]?.let { current -> val targetColumn = task.kanbanColumn ?: "upcoming_tasks" val newColumns = current.columns.map { column -> val filteredTasks = column.tasks.filter { it.id != task.id } val updatedTasks = if (column.name == targetColumn) { filteredTasks + task } else { filteredTasks } column.copy(tasks = updatedTasks, count = updatedTasks.size) } _tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns)) } } // Refresh summary from updated kanban data (API no longer returns summaries for CRUD) refreshSummaryFromKanban() persistToDisk() } fun removeTask(taskId: Int) { // Remove from allTasks _allTasks.value?.let { current -> val newColumns = current.columns.map { column -> val filteredTasks = column.tasks.filter { it.id != taskId } column.copy(tasks = filteredTasks, count = filteredTasks.size) } _allTasks.value = current.copy(columns = newColumns) } // Remove from all residence task caches _tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) -> val newColumns = tasks.columns.map { column -> val filteredTasks = column.tasks.filter { it.id != taskId } column.copy(tasks = filteredTasks, count = filteredTasks.size) } tasks.copy(columns = newColumns) } // Refresh summary from updated kanban data (API no longer returns summaries for CRUD) refreshSummaryFromKanban() persistToDisk() } // ==================== DOCUMENT UPDATE METHODS ==================== fun setDocuments(documents: List) { _documents.value = documents documentsCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } fun setDocumentsForResidence(residenceId: Int, documents: List) { _documentsByResidence.value = _documentsByResidence.value + (residenceId to documents) persistToDisk() } /** Populate the per-document detail cache (used by DocumentViewModel's derived flow). */ fun setDocumentDetail(document: Document) { val id = document.id ?: return _documentDetail.value = _documentDetail.value + (id to document) } /** * Add a new document to the cache. * Caches affected: _documents, _documentsByResidence[residenceId] * Invalidation trigger: createDocument API success */ fun addDocument(document: Document) { _documents.value = _documents.value + document // Also add to residence-specific cache if it exists val residenceId = document.residenceId ?: document.residence _documentsByResidence.value[residenceId]?.let { existing -> _documentsByResidence.value = _documentsByResidence.value + (residenceId to (existing + document)) } persistToDisk() } /** * Update an existing document in the cache. * Caches affected: _documents, _documentsByResidence (all maps containing the document) * Invalidation trigger: updateDocument / uploadDocumentImage / deleteDocumentImage API success */ fun updateDocument(document: Document) { _documents.value = _documents.value.map { if (it.id == document.id) document else it } // Also update in residence-specific caches _documentsByResidence.value = _documentsByResidence.value.mapValues { (_, docs) -> docs.map { if (it.id == document.id) document else it } } persistToDisk() } fun removeDocument(documentId: Int) { _documents.value = _documents.value.filter { it.id != documentId } // Also remove from residence-specific caches _documentsByResidence.value = _documentsByResidence.value.mapValues { (_, docs) -> docs.filter { it.id != documentId } } persistToDisk() } // ==================== CONTRACTOR UPDATE METHODS ==================== fun setContractors(contractors: List) { _contractors.value = contractors contractorsCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } /** Populate the per-residence contractor cache. */ fun setContractorsForResidence(residenceId: Int, contractors: List) { _contractorsByResidence.value = _contractorsByResidence.value + (residenceId to contractors) } /** Populate the per-contractor detail cache (used by ContractorViewModel's derived flow). */ fun setContractorDetail(contractor: com.tt.honeyDue.models.Contractor) { _contractorDetail.value = _contractorDetail.value + (contractor.id to contractor) } fun addContractor(contractor: ContractorSummary) { _contractors.value = _contractors.value + contractor persistToDisk() } /** Add a full Contractor (converts to summary for storage) */ fun addContractor(contractor: Contractor) { _contractors.value = _contractors.value + contractor.toSummary() persistToDisk() } fun updateContractor(contractor: ContractorSummary) { _contractors.value = _contractors.value.map { if (it.id == contractor.id) contractor else it } persistToDisk() } /** Update from a full Contractor (converts to summary for storage) */ fun updateContractor(contractor: Contractor) { val summary = contractor.toSummary() _contractors.value = _contractors.value.map { if (it.id == summary.id) summary else it } persistToDisk() } fun removeContractor(contractorId: Int) { _contractors.value = _contractors.value.filter { it.id != contractorId } persistToDisk() } // ==================== SUBSCRIPTION UPDATE METHODS ==================== fun setSubscription(subscription: SubscriptionStatus?) { _subscription.value = subscription persistToDisk() } fun setUpgradeTriggers(triggers: Map) { _upgradeTriggers.value = triggers persistToDisk() } fun setFeatureBenefits(benefits: List) { _featureBenefits.value = benefits persistToDisk() } fun setPromotions(promos: List) { _promotions.value = promos persistToDisk() } // ==================== LOOKUP UPDATE METHODS ==================== fun setResidenceTypes(types: List) { _residenceTypes.value = types _residenceTypesMap.value = types.associateBy { it.id } persistToDisk() } fun setTaskFrequencies(frequencies: List) { _taskFrequencies.value = frequencies _taskFrequenciesMap.value = frequencies.associateBy { it.id } persistToDisk() } fun setTaskPriorities(priorities: List) { _taskPriorities.value = priorities _taskPrioritiesMap.value = priorities.associateBy { it.id } persistToDisk() } fun setTaskCategories(categories: List) { _taskCategories.value = categories _taskCategoriesMap.value = categories.associateBy { it.id } persistToDisk() } fun setContractorSpecialties(specialties: List) { _contractorSpecialties.value = specialties _contractorSpecialtiesMap.value = specialties.associateBy { it.id } persistToDisk() } // ==================== TASK TEMPLATE UPDATE METHODS ==================== fun setTaskTemplates(templates: List) { _taskTemplates.value = templates // Don't persist - these are fetched fresh from API } fun setTaskTemplatesGrouped(response: TaskTemplatesGroupedResponse) { _taskTemplatesGrouped.value = response // Also extract flat list from grouped response val flatList = response.categories.flatMap { it.templates } _taskTemplates.value = flatList // Don't persist - these are fetched fresh from API } /** * Search task templates by query string (local search) */ fun searchTaskTemplates(query: String): List { if (query.length < 2) return emptyList() val lowercaseQuery = query.lowercase() return _taskTemplates.value.filter { template -> template.title.lowercase().contains(lowercaseQuery) || template.description.lowercase().contains(lowercaseQuery) || template.tags.any { it.lowercase().contains(lowercaseQuery) } }.take(10) } fun setAllLookups(staticData: StaticDataResponse) { setResidenceTypes(staticData.residenceTypes) setTaskFrequencies(staticData.taskFrequencies) setTaskPriorities(staticData.taskPriorities) setTaskCategories(staticData.taskCategories) setContractorSpecialties(staticData.contractorSpecialties) _lookupsInitialized.value = true } /** * Set all lookups from unified seeded data response. * Also stores the ETag for future conditional requests. * Persists lookup data to disk for faster app startup. */ fun setAllLookupsFromSeededData(seededData: SeededDataResponse, etag: String?) { setResidenceTypes(seededData.residenceTypes) setTaskFrequencies(seededData.taskFrequencies) setTaskPriorities(seededData.taskPriorities) setTaskCategories(seededData.taskCategories) setContractorSpecialties(seededData.contractorSpecialties) setTaskTemplatesGrouped(seededData.taskTemplates) setSeededDataETag(etag) _lookupsInitialized.value = true // Persist lookups to disk for faster startup persistLookupsToDisk() } /** * Set the ETag for seeded data. Used for conditional requests. */ fun setSeededDataETag(etag: String?) { _seededDataETag.value = etag if (etag != null) { persistenceManager?.save(KEY_SEEDED_DATA_ETAG, etag) } else { persistenceManager?.remove(KEY_SEEDED_DATA_ETAG) } } fun markLookupsInitialized() { _lookupsInitialized.value = true } // ==================== CLEAR METHODS ==================== /** * Clear all data - called on logout */ fun clear() { // Clear auth _authToken.value = null _currentUser.value = null tokenManager?.clearToken() // Clear user data _residences.value = emptyList() _myResidences.value = null _totalSummary.value = null _residenceSummaries.value = emptyMap() _allTasks.value = null _tasksByResidence.value = emptyMap() _documents.value = emptyList() _documentsByResidence.value = emptyMap() _contractors.value = emptyList() // Clear subscription _subscription.value = null _upgradeTriggers.value = emptyMap() _featureBenefits.value = emptyList() _promotions.value = emptyList() // Clear lookups _residenceTypes.value = emptyList() _residenceTypesMap.value = emptyMap() _taskFrequencies.value = emptyList() _taskFrequenciesMap.value = emptyMap() _taskPriorities.value = emptyList() _taskPrioritiesMap.value = emptyMap() _taskCategories.value = emptyList() _taskCategoriesMap.value = emptyMap() _contractorSpecialties.value = emptyList() _contractorSpecialtiesMap.value = emptyMap() _taskTemplates.value = emptyList() _taskTemplatesGrouped.value = null _lookupsInitialized.value = false _seededDataETag.value = null // Clear cache timestamps residencesCacheTime = 0L myResidencesCacheTime = 0L tasksCacheTime = 0L tasksByResidenceCacheTime.clear() contractorsCacheTime = 0L documentsCacheTime = 0L summaryCacheTime = 0L // Clear metadata _lastSyncTime.value = 0L // Clear persistent storage (except theme) persistenceManager?.clear() } /** * Clear only user-specific data (keep lookups and preferences) */ fun clearUserData() { _currentUser.value = null _residences.value = emptyList() _myResidences.value = null _totalSummary.value = null _residenceSummaries.value = emptyMap() _allTasks.value = null _tasksByResidence.value = emptyMap() _documents.value = emptyList() _documentsByResidence.value = emptyMap() _contractors.value = emptyList() _subscription.value = null _upgradeTriggers.value = emptyMap() _featureBenefits.value = emptyList() _promotions.value = emptyList() // Clear cache timestamps residencesCacheTime = 0L myResidencesCacheTime = 0L tasksCacheTime = 0L tasksByResidenceCacheTime.clear() contractorsCacheTime = 0L documentsCacheTime = 0L summaryCacheTime = 0L persistToDisk() } // ==================== PERSISTENCE ==================== @OptIn(ExperimentalTime::class) private fun updateLastSyncTime() { _lastSyncTime.value = Clock.System.now().toEpochMilliseconds() } /** * Persist current state to disk. * Persists user data and lookup data for faster app startup. */ private fun persistToDisk() { val manager = persistenceManager ?: return try { _currentUser.value?.let { manager.save(KEY_CURRENT_USER, json.encodeToString(it)) } } catch (e: Exception) { println("DataManager: Error persisting user to disk: ${e.message}") } } /** * Persist lookup data to disk separately (called less frequently). */ private fun persistLookupsToDisk() { val manager = persistenceManager ?: return try { if (_residenceTypes.value.isNotEmpty()) { manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value)) } if (_taskFrequencies.value.isNotEmpty()) { manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value)) } if (_taskPriorities.value.isNotEmpty()) { manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value)) } if (_taskCategories.value.isNotEmpty()) { manager.save(KEY_TASK_CATEGORIES, json.encodeToString(_taskCategories.value)) } if (_contractorSpecialties.value.isNotEmpty()) { manager.save(KEY_CONTRACTOR_SPECIALTIES, json.encodeToString(_contractorSpecialties.value)) } _taskTemplatesGrouped.value?.let { manager.save(KEY_TASK_TEMPLATES_GROUPED, json.encodeToString(it)) } println("DataManager: Lookup data persisted to disk") } catch (e: Exception) { println("DataManager: Error persisting lookups to disk: ${e.message}") } } /** * Load cached state from disk. * Loads user data and lookup data for faster app startup. */ private fun loadFromDisk() { val manager = persistenceManager ?: return try { // Load user data manager.load(KEY_CURRENT_USER)?.let { data -> _currentUser.value = json.decodeFromString(data) } // Load onboarding completion flag manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data -> _hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false } // Load seeded data ETag for conditional requests manager.load(KEY_SEEDED_DATA_ETAG)?.let { data -> _seededDataETag.value = data } // Load lookup data loadLookupsFromDisk() } catch (e: Exception) { println("DataManager: Error loading from disk: ${e.message}") } } /** * Load lookup data from disk. * If lookups are successfully loaded, mark as initialized. */ private fun loadLookupsFromDisk() { val manager = persistenceManager ?: return var lookupsLoaded = false try { manager.load(KEY_RESIDENCE_TYPES)?.let { data -> val types = json.decodeFromString>(data) _residenceTypes.value = types _residenceTypesMap.value = types.associateBy { it.id } lookupsLoaded = true } manager.load(KEY_TASK_FREQUENCIES)?.let { data -> val frequencies = json.decodeFromString>(data) _taskFrequencies.value = frequencies _taskFrequenciesMap.value = frequencies.associateBy { it.id } } manager.load(KEY_TASK_PRIORITIES)?.let { data -> val priorities = json.decodeFromString>(data) _taskPriorities.value = priorities _taskPrioritiesMap.value = priorities.associateBy { it.id } } manager.load(KEY_TASK_CATEGORIES)?.let { data -> val categories = json.decodeFromString>(data) _taskCategories.value = categories _taskCategoriesMap.value = categories.associateBy { it.id } } manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data -> val specialties = json.decodeFromString>(data) _contractorSpecialties.value = specialties _contractorSpecialtiesMap.value = specialties.associateBy { it.id } } manager.load(KEY_TASK_TEMPLATES_GROUPED)?.let { data -> val grouped = json.decodeFromString(data) _taskTemplatesGrouped.value = grouped _taskTemplates.value = grouped.categories.flatMap { it.templates } } // Mark lookups as initialized if we loaded any data if (lookupsLoaded) { _lookupsInitialized.value = true println("DataManager: Lookup data loaded from disk") } } catch (e: Exception) { println("DataManager: Error loading lookups from disk: ${e.message}") } } // ==================== PERSISTENCE KEYS ==================== private const val KEY_CURRENT_USER = "dm_current_user" private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding" private const val KEY_SEEDED_DATA_ETAG = "dm_seeded_data_etag" private const val KEY_RESIDENCE_TYPES = "dm_residence_types" private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies" private const val KEY_TASK_PRIORITIES = "dm_task_priorities" private const val KEY_TASK_CATEGORIES = "dm_task_categories" private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties" private const val KEY_TASK_TEMPLATES_GROUPED = "dm_task_templates_grouped" }