The setAllTasks() function was not calling refreshSummaryFromKanban() after loading kanban data, so the summary statistics (totalTasks, totalOverdue, etc.) were never calculated - they stayed at zero. Also switch API environment back to DEV. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
952 lines
36 KiB
Kotlin
952 lines
36 KiB
Kotlin
package com.example.casera.data
|
|
|
|
import com.example.casera.models.*
|
|
import com.example.casera.storage.TokenManager
|
|
import com.example.casera.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. All data is persisted to disk for offline access
|
|
* 5. Includes auth token and theme preferences
|
|
*
|
|
* Data Flow:
|
|
* User Action → API Call → Server Response → DataManager Updated → All Screens React
|
|
*/
|
|
object DataManager {
|
|
|
|
// ==================== CACHE CONFIGURATION ====================
|
|
|
|
/**
|
|
* 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
|
|
|
|
// 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<Int, Long> = 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)
|
|
*/
|
|
@OptIn(ExperimentalTime::class)
|
|
fun isCacheValid(cacheTime: Long): Boolean {
|
|
if (cacheTime == 0L) return false
|
|
val now = Clock.System.now().toEpochMilliseconds()
|
|
return (now - cacheTime) < CACHE_TIMEOUT_MS
|
|
}
|
|
|
|
/**
|
|
* 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<String?>(null)
|
|
val authToken: StateFlow<String?> = _authToken.asStateFlow()
|
|
|
|
private val _currentUser = MutableStateFlow<User?>(null)
|
|
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
|
|
|
// ==================== APP PREFERENCES ====================
|
|
|
|
private val _themeId = MutableStateFlow("default")
|
|
val themeId: StateFlow<String> = _themeId.asStateFlow()
|
|
|
|
// ==================== ONBOARDING ====================
|
|
|
|
private val _hasCompletedOnboarding = MutableStateFlow(false)
|
|
val hasCompletedOnboarding: StateFlow<Boolean> = _hasCompletedOnboarding.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 _totalSummary = MutableStateFlow<TotalSummary?>(null)
|
|
val totalSummary: StateFlow<TotalSummary?> = _totalSummary.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 ====================
|
|
// Stores ContractorSummary for list views (lighter weight than full Contractor)
|
|
|
|
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
|
|
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
|
|
|
|
// ==================== SUBSCRIPTION ====================
|
|
|
|
private val _subscription = MutableStateFlow<SubscriptionStatus?>(null)
|
|
val subscription: StateFlow<SubscriptionStatus?> = _subscription.asStateFlow()
|
|
|
|
private val _upgradeTriggers = MutableStateFlow<Map<String, UpgradeTriggerData>>(emptyMap())
|
|
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
|
|
|
|
private val _featureBenefits = MutableStateFlow<List<FeatureBenefit>>(emptyList())
|
|
val featureBenefits: StateFlow<List<FeatureBenefit>> = _featureBenefits.asStateFlow()
|
|
|
|
private val _promotions = MutableStateFlow<List<Promotion>>(emptyList())
|
|
val promotions: StateFlow<List<Promotion>> = _promotions.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 _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()
|
|
|
|
// ==================== TASK TEMPLATES ====================
|
|
|
|
private val _taskTemplates = MutableStateFlow<List<TaskTemplate>>(emptyList())
|
|
val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
|
|
|
|
private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null)
|
|
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
|
|
|
|
// 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 _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()
|
|
|
|
// ==================== STATE METADATA ====================
|
|
|
|
private val _isInitialized = MutableStateFlow(false)
|
|
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
|
|
|
private val _lookupsInitialized = MutableStateFlow(false)
|
|
val lookupsInitialized: StateFlow<Boolean> = _lookupsInitialized.asStateFlow()
|
|
|
|
private val _lastSyncTime = MutableStateFlow(0L)
|
|
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
|
|
|
|
// ==================== SEEDED DATA ETAG ====================
|
|
|
|
private val _seededDataETag = MutableStateFlow<String?>(null)
|
|
val seededDataETag: StateFlow<String?> = _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 ====================
|
|
|
|
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 getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
|
|
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<Residence>) {
|
|
_residences.value = residences
|
|
residencesCacheTime = currentTimeMs()
|
|
updateLastSyncTime()
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setMyResidences(response: MyResidencesResponse) {
|
|
_myResidences.value = response
|
|
// Also update totalSummary from myResidences response
|
|
_totalSummary.value = response.summary
|
|
myResidencesCacheTime = currentTimeMs()
|
|
summaryCacheTime = currentTimeMs()
|
|
updateLastSyncTime()
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setTotalSummary(summary: TotalSummary) {
|
|
_totalSummary.value = summary
|
|
// Also update the summary in myResidences if it exists
|
|
_myResidences.value?.let { current ->
|
|
_myResidences.value = current.copy(summary = 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
|
|
// Also update myResidences summary if present
|
|
_myResidences.value?.let { current ->
|
|
_myResidences.value = current.copy(summary = calculatedSummary)
|
|
}
|
|
}
|
|
|
|
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
|
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
|
persistToDisk()
|
|
}
|
|
|
|
fun addResidence(residence: Residence) {
|
|
_residences.value = _residences.value + residence
|
|
updateLastSyncTime()
|
|
persistToDisk()
|
|
}
|
|
|
|
fun updateResidence(residence: Residence) {
|
|
_residences.value = _residences.value.map {
|
|
if (it.id == residence.id) residence else it
|
|
}
|
|
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()
|
|
}
|
|
|
|
// ==================== 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()
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
summary = null // Summary is global; residence-specific not available client-side
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<Document>) {
|
|
_documents.value = documents
|
|
documentsCacheTime = currentTimeMs()
|
|
updateLastSyncTime()
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setDocumentsForResidence(residenceId: Int, documents: List<Document>) {
|
|
_documentsByResidence.value = _documentsByResidence.value + (residenceId to documents)
|
|
persistToDisk()
|
|
}
|
|
|
|
fun addDocument(document: Document) {
|
|
_documents.value = _documents.value + document
|
|
persistToDisk()
|
|
}
|
|
|
|
fun updateDocument(document: Document) {
|
|
_documents.value = _documents.value.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<ContractorSummary>) {
|
|
_contractors.value = contractors
|
|
contractorsCacheTime = currentTimeMs()
|
|
updateLastSyncTime()
|
|
persistToDisk()
|
|
}
|
|
|
|
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<String, UpgradeTriggerData>) {
|
|
_upgradeTriggers.value = triggers
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setFeatureBenefits(benefits: List<FeatureBenefit>) {
|
|
_featureBenefits.value = benefits
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setPromotions(promos: List<Promotion>) {
|
|
_promotions.value = promos
|
|
persistToDisk()
|
|
}
|
|
|
|
// ==================== LOOKUP UPDATE METHODS ====================
|
|
|
|
fun setResidenceTypes(types: List<ResidenceType>) {
|
|
_residenceTypes.value = types
|
|
_residenceTypesMap.value = types.associateBy { it.id }
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setTaskFrequencies(frequencies: List<TaskFrequency>) {
|
|
_taskFrequencies.value = frequencies
|
|
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setTaskPriorities(priorities: List<TaskPriority>) {
|
|
_taskPriorities.value = priorities
|
|
_taskPrioritiesMap.value = priorities.associateBy { it.id }
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setTaskCategories(categories: List<TaskCategory>) {
|
|
_taskCategories.value = categories
|
|
_taskCategoriesMap.value = categories.associateBy { it.id }
|
|
persistToDisk()
|
|
}
|
|
|
|
fun setContractorSpecialties(specialties: List<ContractorSpecialty>) {
|
|
_contractorSpecialties.value = specialties
|
|
_contractorSpecialtiesMap.value = specialties.associateBy { it.id }
|
|
persistToDisk()
|
|
}
|
|
|
|
// ==================== TASK TEMPLATE UPDATE METHODS ====================
|
|
|
|
fun setTaskTemplates(templates: List<TaskTemplate>) {
|
|
_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<TaskTemplate> {
|
|
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<User>(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<List<ResidenceType>>(data)
|
|
_residenceTypes.value = types
|
|
_residenceTypesMap.value = types.associateBy { it.id }
|
|
lookupsLoaded = true
|
|
}
|
|
|
|
manager.load(KEY_TASK_FREQUENCIES)?.let { data ->
|
|
val frequencies = json.decodeFromString<List<TaskFrequency>>(data)
|
|
_taskFrequencies.value = frequencies
|
|
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
|
|
}
|
|
|
|
manager.load(KEY_TASK_PRIORITIES)?.let { data ->
|
|
val priorities = json.decodeFromString<List<TaskPriority>>(data)
|
|
_taskPriorities.value = priorities
|
|
_taskPrioritiesMap.value = priorities.associateBy { it.id }
|
|
}
|
|
|
|
manager.load(KEY_TASK_CATEGORIES)?.let { data ->
|
|
val categories = json.decodeFromString<List<TaskCategory>>(data)
|
|
_taskCategories.value = categories
|
|
_taskCategoriesMap.value = categories.associateBy { it.id }
|
|
}
|
|
|
|
manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data ->
|
|
val specialties = json.decodeFromString<List<ContractorSpecialty>>(data)
|
|
_contractorSpecialties.value = specialties
|
|
_contractorSpecialtiesMap.value = specialties.associateBy { it.id }
|
|
}
|
|
|
|
manager.load(KEY_TASK_TEMPLATES_GROUPED)?.let { data ->
|
|
val grouped = json.decodeFromString<TaskTemplatesGroupedResponse>(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"
|
|
}
|