From cf0cd1cda23f0a11a9f56b83ec8ffab7fdb9ee96 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 3 Dec 2025 00:21:24 -0600 Subject: [PATCH] Add unified DataManager as single source of truth for all app data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create DataManager.kt with StateFlows for all cached data: - Authentication (token, user) - Residences, tasks, documents, contractors - Subscription status and upgrade triggers - All lookup data (residence types, task categories, etc.) - Theme preferences and state metadata - Add PersistenceManager with platform-specific implementations: - Android: SharedPreferences - iOS: NSUserDefaults - JVM: Properties file - WasmJS: localStorage - Migrate APILayer to update DataManager on every API response - Update Kotlin ViewModels to use DataManager for token access - Deprecate LookupsRepository (delegates to DataManager) - Create iOS DataManagerObservable Swift wrapper for SwiftUI - Update iOS auth flow to use DataManager.isAuthenticated() Data flow: User Action → API Call → DataManager Updated → All Screens React 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../casera/data/PersistenceManager.android.kt | 43 ++ .../kotlin/com/example/casera/App.kt | 45 +- .../com/example/casera/data/DataManager.kt | 707 ++++++++++++++++++ .../example/casera/data/PersistenceManager.kt | 34 + .../com/example/casera/network/APILayer.kt | 463 ++++++------ .../com/example/casera/network/ApiConfig.kt | 2 +- .../casera/repository/LookupsRepository.kt | 159 +--- .../example/casera/viewmodel/AuthViewModel.kt | 21 +- .../casera/viewmodel/LookupsViewModel.kt | 121 ++- .../viewmodel/TaskCompletionViewModel.kt | 6 +- .../casera/data/PersistenceManager.ios.kt | 42 ++ .../casera/data/PersistenceManager.jvm.kt | 65 ++ .../casera/data/PersistenceManager.wasmJs.kt | 38 + .../iosApp/Data/DataManagerObservable.swift | 429 +++++++++++ iosApp/iosApp/RootView.swift | 21 +- iosApp/iosApp/Task/TaskViewModel.swift | 2 +- iosApp/iosApp/iOSApp.swift | 12 +- 17 files changed, 1721 insertions(+), 489 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/example/casera/data/PersistenceManager.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/data/PersistenceManager.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/casera/data/PersistenceManager.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/casera/data/PersistenceManager.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/example/casera/data/PersistenceManager.wasmJs.kt create mode 100644 iosApp/iosApp/Data/DataManagerObservable.swift diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/data/PersistenceManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/data/PersistenceManager.android.kt new file mode 100644 index 0000000..209c12b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/data/PersistenceManager.android.kt @@ -0,0 +1,43 @@ +package com.example.casera.data + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android implementation of PersistenceManager using SharedPreferences. + */ +actual class PersistenceManager(context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + actual fun save(key: String, value: String) { + prefs.edit().putString(key, value).apply() + } + + actual fun load(key: String): String? { + return prefs.getString(key, null) + } + + actual fun remove(key: String) { + prefs.edit().remove(key).apply() + } + + actual fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS_NAME = "casera_data_manager" + + @Volatile + private var instance: PersistenceManager? = null + + fun getInstance(context: Context): PersistenceManager { + return instance ?: synchronized(this) { + instance ?: PersistenceManager(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index e1176c5..fa7f1eb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -53,7 +53,8 @@ import com.example.casera.models.TaskPriority import com.example.casera.models.TaskStatus import com.example.casera.network.ApiResult import com.example.casera.network.AuthApi -import com.example.casera.storage.TokenStorage +import com.example.casera.data.DataManager +import com.example.casera.network.APILayer import casera.composeapp.generated.resources.Res import casera.composeapp.generated.resources.compose_multiplatform @@ -64,32 +65,27 @@ fun App( deepLinkResetToken: String? = null, onClearDeepLinkToken: () -> Unit = {} ) { - var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) } + var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) } var isVerified by remember { mutableStateOf(false) } var isCheckingAuth by remember { mutableStateOf(true) } val navController = rememberNavController() // Check for stored token and verification status on app start LaunchedEffect(Unit) { - val hasToken = TokenStorage.hasToken() + val hasToken = DataManager.authToken.value != null isLoggedIn = hasToken if (hasToken) { // Fetch current user to check verification status - val authApi = AuthApi() - val token = TokenStorage.getToken() - - if (token != null) { - when (val result = authApi.getCurrentUser(token)) { - is ApiResult.Success -> { - isVerified = result.data.verified - LookupsRepository.initialize() - } - else -> { - // If fetching user fails, clear token and logout - TokenStorage.clearToken() - isLoggedIn = false - } + when (val result = APILayer.getCurrentUser(forceRefresh = true)) { + is ApiResult.Success -> { + isVerified = result.data.verified + APILayer.initializeLookups() + } + else -> { + // If fetching user fails, clear DataManager and logout + DataManager.clear() + isLoggedIn = false } } } @@ -251,8 +247,7 @@ fun App( }, onLogout = { // Clear token and lookups on logout - TokenStorage.clearToken() - LookupsRepository.clear() + DataManager.clear() isLoggedIn = false isVerified = false navController.navigate(LoginRoute) { @@ -266,8 +261,7 @@ fun App( MainScreen( onLogout = { // Clear token and lookups on logout - TokenStorage.clearToken() - LookupsRepository.clear() + DataManager.clear() isLoggedIn = false isVerified = false navController.navigate(LoginRoute) { @@ -346,8 +340,7 @@ fun App( }, onLogout = { // Clear token and lookups on logout - TokenStorage.clearToken() - LookupsRepository.clear() + DataManager.clear() isLoggedIn = false isVerified = false navController.navigate(LoginRoute) { @@ -374,8 +367,7 @@ fun App( shouldRefresh = shouldRefresh, onLogout = { // Clear token and lookups on logout - TokenStorage.clearToken() - LookupsRepository.clear() + DataManager.clear() isLoggedIn = false isVerified = false navController.navigate(LoginRoute) { @@ -541,8 +533,7 @@ fun App( }, onLogout = { // Clear token and lookups on logout - TokenStorage.clearToken() - LookupsRepository.clear() + DataManager.clear() isLoggedIn = false isVerified = false navController.navigate(LoginRoute) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt new file mode 100644 index 0000000..13fff57 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -0,0 +1,707 @@ +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 { + + // 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) + val currentUser: StateFlow = _currentUser.asStateFlow() + + // ==================== APP PREFERENCES ==================== + + private val _themeId = MutableStateFlow("default") + val themeId: StateFlow = _themeId.asStateFlow() + + // ==================== RESIDENCES ==================== + + private val _residences = MutableStateFlow>(emptyList()) + val residences: StateFlow> = _residences.asStateFlow() + + private val _myResidences = MutableStateFlow(null) + val myResidences: StateFlow = _myResidences.asStateFlow() + + private val _residenceSummaries = MutableStateFlow>(emptyMap()) + val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() + + // ==================== TASKS ==================== + + private val _allTasks = MutableStateFlow(null) + val allTasks: StateFlow = _allTasks.asStateFlow() + + private val _tasksByResidence = MutableStateFlow>(emptyMap()) + val tasksByResidence: StateFlow> = _tasksByResidence.asStateFlow() + + // ==================== DOCUMENTS ==================== + + private val _documents = MutableStateFlow>(emptyList()) + val documents: StateFlow> = _documents.asStateFlow() + + private val _documentsByResidence = MutableStateFlow>>(emptyMap()) + val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() + + // ==================== CONTRACTORS ==================== + + private val _contractors = MutableStateFlow>(emptyList()) + val contractors: StateFlow> = _contractors.asStateFlow() + + // ==================== SUBSCRIPTION ==================== + + private val _subscription = MutableStateFlow(null) + val subscription: StateFlow = _subscription.asStateFlow() + + private val _upgradeTriggers = MutableStateFlow>(emptyMap()) + val upgradeTriggers: StateFlow> = _upgradeTriggers.asStateFlow() + + private val _featureBenefits = MutableStateFlow>(emptyList()) + val featureBenefits: StateFlow> = _featureBenefits.asStateFlow() + + private val _promotions = MutableStateFlow>(emptyList()) + val promotions: StateFlow> = _promotions.asStateFlow() + + // ==================== LOOKUPS (Reference Data) ==================== + + // List-based for dropdowns/pickers + private val _residenceTypes = MutableStateFlow>(emptyList()) + val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() + + private val _taskFrequencies = MutableStateFlow>(emptyList()) + val taskFrequencies: StateFlow> = _taskFrequencies.asStateFlow() + + private val _taskPriorities = MutableStateFlow>(emptyList()) + val taskPriorities: StateFlow> = _taskPriorities.asStateFlow() + + private val _taskStatuses = MutableStateFlow>(emptyList()) + val taskStatuses: StateFlow> = _taskStatuses.asStateFlow() + + private val _taskCategories = MutableStateFlow>(emptyList()) + val taskCategories: StateFlow> = _taskCategories.asStateFlow() + + private val _contractorSpecialties = MutableStateFlow>(emptyList()) + val contractorSpecialties: StateFlow> = _contractorSpecialties.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 _taskStatusesMap = MutableStateFlow>(emptyMap()) + val taskStatusesMap: StateFlow> = _taskStatusesMap.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() + + // ==================== 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 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] } + + // ==================== 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) + } + + // ==================== RESIDENCE UPDATE METHODS ==================== + + fun setResidences(residences: List) { + _residences.value = residences + updateLastSyncTime() + persistToDisk() + } + + fun setMyResidences(response: MyResidencesResponse) { + _myResidences.value = response + updateLastSyncTime() + persistToDisk() + } + + 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 + persistToDisk() + } + + // ==================== TASK UPDATE METHODS ==================== + + fun setAllTasks(response: TaskColumnsResponse) { + _allTasks.value = response + updateLastSyncTime() + persistToDisk() + } + + fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) { + _tasksByResidence.value = _tasksByResidence.value + (residenceId to response) + persistToDisk() + } + + /** + * 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. + */ + 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)) + } + } + + 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) + } + + persistToDisk() + } + + // ==================== DOCUMENT UPDATE METHODS ==================== + + fun setDocuments(documents: List) { + _documents.value = documents + updateLastSyncTime() + persistToDisk() + } + + fun setDocumentsForResidence(residenceId: Int, documents: List) { + _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) { + _contractors.value = contractors + updateLastSyncTime() + persistToDisk() + } + + fun addContractor(contractor: Contractor) { + _contractors.value = _contractors.value + contractor + persistToDisk() + } + + fun updateContractor(contractor: Contractor) { + _contractors.value = _contractors.value.map { + if (it.id == contractor.id) contractor 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 setTaskStatuses(statuses: List) { + _taskStatuses.value = statuses + _taskStatusesMap.value = statuses.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() + } + + fun setAllLookups(staticData: StaticDataResponse) { + setResidenceTypes(staticData.residenceTypes) + setTaskFrequencies(staticData.taskFrequencies) + setTaskPriorities(staticData.taskPriorities) + setTaskStatuses(staticData.taskStatuses) + setTaskCategories(staticData.taskCategories) + setContractorSpecialties(staticData.contractorSpecialties) + _lookupsInitialized.value = true + } + + 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 + _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() + _taskStatuses.value = emptyList() + _taskStatusesMap.value = emptyMap() + _taskCategories.value = emptyList() + _taskCategoriesMap.value = emptyMap() + _contractorSpecialties.value = emptyList() + _contractorSpecialtiesMap.value = emptyMap() + _lookupsInitialized.value = false + + // 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 + _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() + persistToDisk() + } + + // ==================== PERSISTENCE ==================== + + @OptIn(ExperimentalTime::class) + private fun updateLastSyncTime() { + _lastSyncTime.value = Clock.System.now().toEpochMilliseconds() + } + + /** + * Persist current state to disk. + * Called automatically after each update. + */ + private fun persistToDisk() { + val manager = persistenceManager ?: return + + try { + // Persist each data type + _currentUser.value?.let { + manager.save(KEY_CURRENT_USER, json.encodeToString(it)) + } + + if (_residences.value.isNotEmpty()) { + manager.save(KEY_RESIDENCES, json.encodeToString(_residences.value)) + } + + _myResidences.value?.let { + manager.save(KEY_MY_RESIDENCES, json.encodeToString(it)) + } + + _allTasks.value?.let { + manager.save(KEY_ALL_TASKS, json.encodeToString(it)) + } + + if (_documents.value.isNotEmpty()) { + manager.save(KEY_DOCUMENTS, json.encodeToString(_documents.value)) + } + + if (_contractors.value.isNotEmpty()) { + manager.save(KEY_CONTRACTORS, json.encodeToString(_contractors.value)) + } + + _subscription.value?.let { + manager.save(KEY_SUBSCRIPTION, json.encodeToString(it)) + } + + // Persist lookups + 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 (_taskStatuses.value.isNotEmpty()) { + manager.save(KEY_TASK_STATUSES, json.encodeToString(_taskStatuses.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)) + } + + manager.save(KEY_LAST_SYNC_TIME, _lastSyncTime.value.toString()) + } catch (e: Exception) { + // Log error but don't crash - persistence is best-effort + println("DataManager: Error persisting to disk: ${e.message}") + } + } + + /** + * Load cached state from disk. + * Called during initialization. + */ + private fun loadFromDisk() { + val manager = persistenceManager ?: return + + try { + manager.load(KEY_CURRENT_USER)?.let { data -> + _currentUser.value = json.decodeFromString(data) + } + + manager.load(KEY_RESIDENCES)?.let { data -> + _residences.value = json.decodeFromString>(data) + } + + manager.load(KEY_MY_RESIDENCES)?.let { data -> + _myResidences.value = json.decodeFromString(data) + } + + manager.load(KEY_ALL_TASKS)?.let { data -> + _allTasks.value = json.decodeFromString(data) + } + + manager.load(KEY_DOCUMENTS)?.let { data -> + _documents.value = json.decodeFromString>(data) + } + + manager.load(KEY_CONTRACTORS)?.let { data -> + _contractors.value = json.decodeFromString>(data) + } + + manager.load(KEY_SUBSCRIPTION)?.let { data -> + _subscription.value = json.decodeFromString(data) + } + + // Load lookups + manager.load(KEY_RESIDENCE_TYPES)?.let { data -> + val types = json.decodeFromString>(data) + _residenceTypes.value = types + _residenceTypesMap.value = types.associateBy { it.id } + } + + manager.load(KEY_TASK_FREQUENCIES)?.let { data -> + val items = json.decodeFromString>(data) + _taskFrequencies.value = items + _taskFrequenciesMap.value = items.associateBy { it.id } + } + + manager.load(KEY_TASK_PRIORITIES)?.let { data -> + val items = json.decodeFromString>(data) + _taskPriorities.value = items + _taskPrioritiesMap.value = items.associateBy { it.id } + } + + manager.load(KEY_TASK_STATUSES)?.let { data -> + val items = json.decodeFromString>(data) + _taskStatuses.value = items + _taskStatusesMap.value = items.associateBy { it.id } + } + + manager.load(KEY_TASK_CATEGORIES)?.let { data -> + val items = json.decodeFromString>(data) + _taskCategories.value = items + _taskCategoriesMap.value = items.associateBy { it.id } + } + + manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data -> + val items = json.decodeFromString>(data) + _contractorSpecialties.value = items + _contractorSpecialtiesMap.value = items.associateBy { it.id } + } + + manager.load(KEY_LAST_SYNC_TIME)?.let { data -> + _lastSyncTime.value = data.toLongOrNull() ?: 0L + } + + // Mark lookups initialized if we have data + if (_residenceTypes.value.isNotEmpty()) { + _lookupsInitialized.value = true + } + } catch (e: Exception) { + // Log error but don't crash - cache miss is OK + println("DataManager: Error loading from disk: ${e.message}") + } + } + + // ==================== PERSISTENCE KEYS ==================== + + private const val KEY_CURRENT_USER = "dm_current_user" + private const val KEY_RESIDENCES = "dm_residences" + private const val KEY_MY_RESIDENCES = "dm_my_residences" + private const val KEY_ALL_TASKS = "dm_all_tasks" + private const val KEY_DOCUMENTS = "dm_documents" + private const val KEY_CONTRACTORS = "dm_contractors" + private const val KEY_SUBSCRIPTION = "dm_subscription" + 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_STATUSES = "dm_task_statuses" + private const val KEY_TASK_CATEGORIES = "dm_task_categories" + private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties" + private const val KEY_LAST_SYNC_TIME = "dm_last_sync_time" +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/PersistenceManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/PersistenceManager.kt new file mode 100644 index 0000000..4d004d5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/PersistenceManager.kt @@ -0,0 +1,34 @@ +package com.example.casera.data + +/** + * Platform-specific persistence manager for storing app data to disk. + * Each platform implements this using their native storage mechanisms. + * + * Android: SharedPreferences + * iOS: UserDefaults + * JVM: Properties file + * Wasm: LocalStorage + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect class PersistenceManager { + /** + * Save a string value to persistent storage. + */ + fun save(key: String, value: String) + + /** + * Load a string value from persistent storage. + * Returns null if the key doesn't exist. + */ + fun load(key: String): String? + + /** + * Remove a specific key from storage. + */ + fun remove(key: String) + + /** + * Clear all stored data. + */ + fun clear() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 9780d7a..a932184 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -1,21 +1,18 @@ package com.example.casera.network -import com.example.casera.cache.DataCache -import com.example.casera.cache.DataPrefetchManager -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.models.* import com.example.casera.network.* -import com.example.casera.storage.TokenStorage /** - * Unified API Layer that manages all network calls and cache operations. + * Unified API Layer that manages all network calls and DataManager updates. * This is the single entry point for all data operations in the app. * - * Benefits: - * - Centralized cache management - * - Consistent error handling - * - Automatic cache updates on mutations - * - Cache-first reads with optional force refresh + * Core Principle: Every API response updates DataManager immediately. + * All screens observe DataManager StateFlows directly. + * + * Data Flow: + * User Action → API Call → Server Response → DataManager Updated → All Screens React */ object APILayer { @@ -28,7 +25,10 @@ object APILayer { private val lookupsApi = LookupsApi() private val notificationApi = NotificationApi() private val subscriptionApi = SubscriptionApi() - private val prefetchManager = DataPrefetchManager.getInstance() + + // ==================== Authentication Helper ==================== + + private fun getToken(): String? = DataManager.authToken.value // ==================== Lookups Operations ==================== @@ -37,7 +37,7 @@ object APILayer { * Call this when app comes to foreground or when limits might have changed. */ suspend fun refreshSubscriptionStatus(): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) println("🔄 [APILayer] Force refreshing subscription status from backend...") val result = subscriptionApi.getSubscriptionStatus(token) @@ -46,10 +46,7 @@ object APILayer { is ApiResult.Success -> { println("✅ [APILayer] Subscription status refreshed successfully") println(" 📊 Limitations Enabled: ${result.data.limitationsEnabled}") - println(" 📊 Free Tier Limits - Properties: ${result.data.limits["free"]?.properties}, Tasks: ${result.data.limits["free"]?.tasks}, Contractors: ${result.data.limits["free"]?.contractors}, Documents: ${result.data.limits["free"]?.documents}") - println(" 📊 Pro Tier Limits - Properties: ${result.data.limits["pro"]?.properties}, Tasks: ${result.data.limits["pro"]?.tasks}, Contractors: ${result.data.limits["pro"]?.contractors}, Documents: ${result.data.limits["pro"]?.documents}") - println(" 📊 Usage - Properties: ${result.data.usage.propertiesCount}, Tasks: ${result.data.usage.tasksCount}, Contractors: ${result.data.usage.contractorsCount}, Documents: ${result.data.usage.documentsCount}") - SubscriptionCache.updateSubscriptionStatus(result.data) + DataManager.setSubscription(result.data) } is ApiResult.Error -> { println("❌ [APILayer] Failed to refresh subscription status: ${result.message}") @@ -62,18 +59,17 @@ object APILayer { /** * Initialize all lookup data. Should be called once after login. - * Loads all reference data (residence types, task categories, priorities, etc.) into cache. + * Loads all reference data (residence types, task categories, priorities, etc.) into DataManager. */ suspend fun initializeLookups(): ApiResult { - if (DataCache.lookupsInitialized.value) { + if (DataManager.lookupsInitialized.value) { // Lookups already initialized, but refresh subscription status - // since limits/usage may have changed on the backend println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...") refreshSubscriptionStatus() return ApiResult.Success(Unit) } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) try { // Load all lookups in a single API call using static_data endpoint @@ -81,9 +77,9 @@ object APILayer { val staticDataResult = lookupsApi.getStaticData(token) println("📦 Static data result: $staticDataResult") - // Update cache with all lookups at once + // Update DataManager with all lookups at once if (staticDataResult is ApiResult.Success) { - DataCache.updateAllLookups(staticDataResult.data) + DataManager.setAllLookups(staticDataResult.data) println("✅ All lookups loaded successfully") } else if (staticDataResult is ApiResult.Error) { println("❌ Failed to fetch static data: ${staticDataResult.message}") @@ -91,33 +87,32 @@ object APILayer { } // Load subscription status to get limitationsEnabled, usage, and limits from backend - // Note: tier (free/pro) will be updated by StoreKit after receipt verification println("🔄 Fetching subscription status...") val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token) println("📦 Subscription status result: $subscriptionStatusResult") - // Load upgrade triggers (subscription status comes from StoreKit, not backend) + // Load upgrade triggers println("🔄 Fetching upgrade triggers...") val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) println("📦 Upgrade triggers result: $upgradeTriggersResult") if (subscriptionStatusResult is ApiResult.Success) { - println("✅ Updating subscription cache with: ${subscriptionStatusResult.data}") - SubscriptionCache.updateSubscriptionStatus(subscriptionStatusResult.data) - println("✅ Subscription cache updated successfully") + 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 cache with ${upgradeTriggersResult.data.size} triggers") - SubscriptionCache.updateUpgradeTriggers(upgradeTriggersResult.data) - println("✅ Upgrade triggers cache updated successfully") + println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers") + DataManager.setUpgradeTriggers(upgradeTriggersResult.data) + println("✅ Upgrade triggers updated successfully") } else if (upgradeTriggersResult is ApiResult.Error) { println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}") } - DataCache.markLookupsInitialized() + DataManager.markLookupsInitialized() return ApiResult.Success(Unit) } catch (e: Exception) { return ApiResult.Error("Failed to initialize lookups: ${e.message}") @@ -125,126 +120,126 @@ object APILayer { } /** - * Get residence types from cache. If cache is empty, fetch from API. + * Get residence types from DataManager. If cache is empty, fetch from API. */ suspend fun getResidenceTypes(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.residenceTypes.value + val cached = DataManager.residenceTypes.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getResidenceTypes(token) if (result is ApiResult.Success) { - DataCache.updateResidenceTypes(result.data) + DataManager.setResidenceTypes(result.data) } return result } /** - * Get task frequencies from cache. If cache is empty, fetch from API. + * Get task frequencies from DataManager. If cache is empty, fetch from API. */ suspend fun getTaskFrequencies(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.taskFrequencies.value + val cached = DataManager.taskFrequencies.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getTaskFrequencies(token) if (result is ApiResult.Success) { - DataCache.updateTaskFrequencies(result.data) + DataManager.setTaskFrequencies(result.data) } return result } /** - * Get task priorities from cache. If cache is empty, fetch from API. + * Get task priorities from DataManager. If cache is empty, fetch from API. */ suspend fun getTaskPriorities(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.taskPriorities.value + val cached = DataManager.taskPriorities.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getTaskPriorities(token) if (result is ApiResult.Success) { - DataCache.updateTaskPriorities(result.data) + DataManager.setTaskPriorities(result.data) } return result } /** - * Get task statuses from cache. If cache is empty, fetch from API. + * Get task statuses from DataManager. If cache is empty, fetch from API. */ suspend fun getTaskStatuses(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.taskStatuses.value + val cached = DataManager.taskStatuses.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getTaskStatuses(token) if (result is ApiResult.Success) { - DataCache.updateTaskStatuses(result.data) + DataManager.setTaskStatuses(result.data) } return result } /** - * Get task categories from cache. If cache is empty, fetch from API. + * Get task categories from DataManager. If cache is empty, fetch from API. */ suspend fun getTaskCategories(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.taskCategories.value + val cached = DataManager.taskCategories.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getTaskCategories(token) if (result is ApiResult.Success) { - DataCache.updateTaskCategories(result.data) + DataManager.setTaskCategories(result.data) } return result } /** - * Get contractor specialties from cache. If cache is empty, fetch from API. + * Get contractor specialties from DataManager. If cache is empty, fetch from API. */ suspend fun getContractorSpecialties(forceRefresh: Boolean = false): ApiResult> { if (!forceRefresh) { - val cached = DataCache.contractorSpecialties.value + val cached = DataManager.contractorSpecialties.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = lookupsApi.getContractorSpecialties(token) if (result is ApiResult.Success) { - DataCache.updateContractorSpecialties(result.data) + DataManager.setContractorSpecialties(result.data) } return result @@ -253,306 +248,306 @@ object APILayer { // ==================== Residence Operations ==================== suspend fun getResidences(forceRefresh: Boolean = false): ApiResult> { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.residences.value + val cached = DataManager.residences.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.getResidences(token) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateResidences(result.data) + DataManager.setResidences(result.data) } return result } suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.myResidences.value + val cached = DataManager.myResidences.value if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.getMyResidences(token) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateMyResidences(result.data) + DataManager.setMyResidences(result.data) } return result } suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.residences.value.find { it.id == id } + val cached = DataManager.residences.value.find { it.id == id } if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.getResidence(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateResidence(result.data) + DataManager.updateResidence(result.data) } return result } suspend fun getResidenceSummary(): ApiResult { - // Note: This returns a summary of ALL residences, not cached per-residence - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.getResidenceSummary(token) } suspend fun createResidence(request: ResidenceCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.createResidence(token, request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.addResidence(result.data) + DataManager.addResidence(result.data) + // Also refresh my-residences to get updated list + refreshMyResidences() } return result } suspend fun updateResidence(id: Int, request: ResidenceCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.updateResidence(token, id, request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateResidence(result.data) + DataManager.updateResidence(result.data) + // Also refresh my-residences to get updated list + refreshMyResidences() } return result } suspend fun deleteResidence(id: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.deleteResidence(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.removeResidence(id) + DataManager.removeResidence(id) + // Also refresh my-residences to get updated list + refreshMyResidences() } return result } suspend fun generateTasksReport(residenceId: Int, email: String? = null): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.generateTasksReport(token, residenceId, email) } suspend fun joinWithCode(code: String): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = residenceApi.joinWithCode(token, code) - // Note: We don't update cache here because the response doesn't include the full residence list - // The caller should manually refresh residences after joining + // Refresh residences after joining + if (result is ApiResult.Success) { + refreshMyResidences() + } return result } suspend fun getResidenceUsers(residenceId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.getResidenceUsers(token, residenceId) } suspend fun getShareCode(residenceId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.getShareCode(token, residenceId) } suspend fun generateShareCode(residenceId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.generateShareCode(token, residenceId) } suspend fun removeUser(residenceId: Int, userId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return residenceApi.removeUser(token, residenceId, userId) } // ==================== Task Operations ==================== suspend fun getTasks(forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.allTasks.value + val cached = DataManager.allTasks.value if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.getTasks(token) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateAllTasks(result.data) + DataManager.setAllTasks(result.data) } return result } suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.tasksByResidence.value[residenceId] + val cached = DataManager.tasksByResidence.value[residenceId] if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.getTasksByResidence(token, residenceId) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateTasksByResidence(residenceId, result.data) + DataManager.setTasksForResidence(residenceId, result.data) } return result } suspend fun createTask(request: TaskCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.createTask(token, request) - // Refresh tasks cache on success + // Refresh tasks on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + refreshTasks() } return result } suspend fun updateTask(id: Int, request: TaskCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.updateTask(token, id, request) - // Refresh tasks cache on success + // Refresh tasks on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + refreshTasks() } return result } /** - * Get status ID by name from DataCache. - * Falls back to a default ID if status not found. + * Get status ID by name from DataManager. */ private fun getStatusIdByName(name: String): Int? { - return DataCache.taskStatuses.value.find { + return DataManager.taskStatuses.value.find { it.name.equals(name, ignoreCase = true) }?.id } suspend fun cancelTask(taskId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - // Look up 'cancelled' status ID from cache val cancelledStatusId = getStatusIdByName("cancelled") ?: return ApiResult.Error("Cancelled status not found in cache") val result = taskApi.cancelTask(token, taskId, cancelledStatusId) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + DataManager.updateTask(result.data) } return result } suspend fun uncancelTask(taskId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - // Look up 'pending' status ID from cache val pendingStatusId = getStatusIdByName("pending") ?: return ApiResult.Error("Pending status not found in cache") val result = taskApi.uncancelTask(token, taskId, pendingStatusId) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + DataManager.updateTask(result.data) } return result } suspend fun markInProgress(taskId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - // Look up 'in progress' status ID from cache val inProgressStatusId = getStatusIdByName("in progress") - ?: getStatusIdByName("In Progress") // Try alternate casing + ?: getStatusIdByName("In Progress") ?: return ApiResult.Error("In Progress status not found in cache") val result = taskApi.markInProgress(token, taskId, inProgressStatusId) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + DataManager.updateTask(result.data) } return result } suspend fun archiveTask(taskId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.archiveTask(token, taskId) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + DataManager.removeTask(taskId) } return result } suspend fun unarchiveTask(taskId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.unarchiveTask(token, taskId) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + DataManager.updateTask(result.data) } return result } suspend fun createTaskCompletion(request: TaskCompletionCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskCompletionApi.createCompletion(token, request) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + // The response includes the updated task, update it in DataManager + result.data.updatedTask?.let { updatedTask -> + DataManager.updateTask(updatedTask) + } } return result @@ -563,12 +558,14 @@ object APILayer { images: List, imageFileNames: List ): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames) - // Refresh tasks cache on success if (result is ApiResult.Success) { - prefetchManager.refreshTasks() + // The response includes the updated task, update it in DataManager + result.data.updatedTask?.let { updatedTask -> + DataManager.updateTask(updatedTask) + } } return result @@ -578,7 +575,7 @@ object APILayer { * Get all completions for a specific task */ suspend fun getTaskCompletions(taskId: Int): ApiResult> { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return taskApi.getTaskCompletions(token, taskId) } @@ -599,45 +596,45 @@ object APILayer { contractorId != null || isActive != null || expiringSoon != null || tags != null || search != null - // Check cache first if no filters + // Check DataManager first if no filters if (!forceRefresh && !hasFilters) { - val cached = DataCache.documents.value + val cached = DataManager.documents.value if (cached.isNotEmpty()) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = documentApi.getDocuments( token, residenceId, documentType, category, contractorId, isActive, expiringSoon, tags, search ) - // Update cache on success if no filters + // Update DataManager on success if no filters if (result is ApiResult.Success && !hasFilters) { - DataCache.updateDocuments(result.data) + DataManager.setDocuments(result.data) } return result } suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.documents.value.find { it.id == id } + val cached = DataManager.documents.value.find { it.id == id } if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = documentApi.getDocument(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateDocument(result.data) + DataManager.updateDocument(result.data) } return result @@ -671,7 +668,7 @@ object APILayer { fileNamesList: List? = null, mimeTypesList: List? = null ): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = documentApi.createDocument( token, title, documentType, residenceId, description, category, tags, notes, contractorId, isActive, itemName, modelNumber, @@ -680,9 +677,9 @@ object APILayer { mimeType, fileBytesList, fileNamesList, mimeTypesList ) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.addDocument(result.data) + DataManager.addDocument(result.data) } return result @@ -710,7 +707,7 @@ object APILayer { startDate: String? = null, endDate: String? = null ): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = documentApi.updateDocument( token, id, title, documentType, description, category, tags, notes, contractorId, isActive, itemName, modelNumber, serialNumber, provider, @@ -718,21 +715,21 @@ object APILayer { startDate, endDate ) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateDocument(result.data) + DataManager.updateDocument(result.data) } return result } suspend fun deleteDocument(id: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = documentApi.deleteDocument(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.removeDocument(id) + DataManager.removeDocument(id) } return result @@ -744,17 +741,17 @@ object APILayer { fileName: String, mimeType: String ): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return documentApi.uploadDocumentImage(token, documentId, imageBytes, fileName, mimeType) } suspend fun deleteDocumentImage(imageId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return documentApi.deleteDocumentImage(token, imageId) } suspend fun downloadDocument(url: String): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return documentApi.downloadDocument(token, url) } @@ -767,96 +764,82 @@ object APILayer { search: String? = null, forceRefresh: Boolean = false ): ApiResult> { - val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null - - // Note: Cannot use cache here because API returns List - // but DataCache stores List. Cache is only used for individual contractor lookups. - - // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = contractorApi.getContractors(token, specialty, isFavorite, isActive, search) - - // Update cache on success if no filters - if (result is ApiResult.Success && !hasFilters) { - // API returns List, but we need List for cache - // For now, we'll skip caching from this endpoint since it returns summaries - // Cache will be populated from getContractor() or create/update operations - } - - return result + // Fetch from API (API returns summaries, not full contractors) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + return contractorApi.getContractors(token, specialty, isFavorite, isActive, search) } suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.contractors.value.find { it.id == id } + val cached = DataManager.contractors.value.find { it.id == id } if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.getContractor(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateContractor(result.data) + DataManager.updateContractor(result.data) } return result } suspend fun createContractor(request: ContractorCreateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.createContractor(token, request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.addContractor(result.data) + DataManager.addContractor(result.data) } return result } suspend fun updateContractor(id: Int, request: ContractorUpdateRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.updateContractor(token, id, request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateContractor(result.data) + DataManager.updateContractor(result.data) } return result } suspend fun deleteContractor(id: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.deleteContractor(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.removeContractor(id) + DataManager.removeContractor(id) } return result } suspend fun toggleFavorite(id: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.toggleFavorite(token, id) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateContractor(result.data) + DataManager.updateContractor(result.data) } return result } suspend fun getContractorsByResidence(residenceId: Int): ApiResult> { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return contractorApi.getContractorsByResidence(token, residenceId) } @@ -865,11 +848,14 @@ object APILayer { suspend fun login(request: LoginRequest): ApiResult { val result = authApi.login(request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateCurrentUser(result.data.user) - // Prefetch all data after successful login - prefetchManager.prefetchAllData() + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) + // Initialize lookups after successful login + initializeLookups() + // Prefetch all data + prefetchAllData() } return result @@ -880,31 +866,31 @@ object APILayer { } suspend fun logout(): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = authApi.logout(token) - // Clear cache on logout (success or failure) - DataCache.clearAll() + // Clear DataManager on logout (success or failure) + DataManager.clear() return result } suspend fun getCurrentUser(forceRefresh: Boolean = false): ApiResult { - // Check cache first + // Check DataManager first if (!forceRefresh) { - val cached = DataCache.currentUser.value + val cached = DataManager.currentUser.value if (cached != null) { return ApiResult.Success(cached) } } // Fetch from API - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = authApi.getCurrentUser(token) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateCurrentUser(result.data) + DataManager.setCurrentUser(result.data) } return result @@ -929,12 +915,14 @@ object APILayer { suspend fun appleSignIn(request: AppleSignInRequest): ApiResult { val result = authApi.appleSignIn(request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - TokenStorage.saveToken(result.data.token) - DataCache.updateCurrentUser(result.data.user) - // Prefetch all data after successful Apple sign in - prefetchManager.prefetchAllData() + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) + // Initialize lookups after successful Apple sign in + initializeLookups() + // Prefetch all data + prefetchAllData() } return result @@ -943,9 +931,9 @@ object APILayer { suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult { val result = authApi.updateProfile(token, request) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - DataCache.updateCurrentUser(result.data) + DataManager.setCurrentUser(result.data) } return result @@ -954,42 +942,42 @@ object APILayer { // ==================== Notification Operations ==================== suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.registerDevice(token, request) } suspend fun unregisterDevice(registrationId: String): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.unregisterDevice(token, registrationId) } suspend fun getNotificationPreferences(): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.getNotificationPreferences(token) } suspend fun updateNotificationPreferences(request: UpdateNotificationPreferencesRequest): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.updateNotificationPreferences(token, request) } suspend fun getNotificationHistory(): ApiResult> { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.getNotificationHistory(token) } suspend fun markNotificationAsRead(notificationId: Int): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.markNotificationAsRead(token, notificationId) } suspend fun markAllNotificationsAsRead(): ApiResult> { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.markAllNotificationsAsRead(token) } suspend fun getUnreadCount(): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return notificationApi.getUnreadCount(token) } @@ -999,12 +987,12 @@ object APILayer { * Get subscription status from backend */ suspend fun getSubscriptionStatus(forceRefresh: Boolean = false): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = subscriptionApi.getSubscriptionStatus(token) - // Update cache on success + // Update DataManager on success if (result is ApiResult.Success) { - SubscriptionCache.updateSubscriptionStatus(result.data) + DataManager.setSubscription(result.data) } return result @@ -1014,7 +1002,7 @@ object APILayer { * Verify Android purchase with backend */ suspend fun verifyAndroidPurchase(purchaseToken: String, productId: String): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return subscriptionApi.verifyAndroidPurchase(token, purchaseToken, productId) } @@ -1022,7 +1010,36 @@ object APILayer { * Verify iOS receipt with backend */ suspend fun verifyIOSReceipt(receiptData: String, transactionId: String): ApiResult { - val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId) } + + // ==================== Helper Methods ==================== + + /** + * Refresh all tasks from API + */ + private suspend fun refreshTasks() { + getTasks(forceRefresh = true) + } + + /** + * Refresh my-residences from API + */ + private suspend fun refreshMyResidences() { + getMyResidences(forceRefresh = true) + } + + /** + * Prefetch all data after login + */ + private suspend fun prefetchAllData() { + try { + // Fetch key data in parallel - these all update DataManager + getMyResidences(forceRefresh = true) + getTasks(forceRefresh = true) + } catch (e: Exception) { + println("Error prefetching data: ${e.message}") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index 45cffa3..a19e50e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -9,7 +9,7 @@ package com.example.casera.network */ object ApiConfig { // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ - val CURRENT_ENV = Environment.LOCAL + val CURRENT_ENV = Environment.DEV enum class Environment { LOCAL, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/repository/LookupsRepository.kt b/composeApp/src/commonMain/kotlin/com/example/casera/repository/LookupsRepository.kt index 78238cf..31d5e80 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/repository/LookupsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/repository/LookupsRepository.kt @@ -1,175 +1,62 @@ package com.example.casera.repository -import com.example.casera.cache.SubscriptionCache +import com.example.casera.data.DataManager import com.example.casera.models.* import com.example.casera.network.ApiResult -import com.example.casera.network.LookupsApi -import com.example.casera.network.SubscriptionApi -import com.example.casera.storage.TokenStorage -import com.example.casera.storage.TaskCacheStorage +import com.example.casera.network.APILayer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch /** * Singleton repository for managing lookup data across the entire app. - * Fetches data once on initialization and caches it for the app session. + * + * @deprecated Use DataManager directly. This class is kept for backwards compatibility + * and simply delegates to DataManager. */ object LookupsRepository { - private val lookupsApi = LookupsApi() - private val subscriptionApi = SubscriptionApi() private val scope = CoroutineScope(Dispatchers.Default) - private val _residenceTypes = MutableStateFlow>(emptyList()) - val residenceTypes: StateFlow> = _residenceTypes - - private val _taskFrequencies = MutableStateFlow>(emptyList()) - val taskFrequencies: StateFlow> = _taskFrequencies - - private val _taskPriorities = MutableStateFlow>(emptyList()) - val taskPriorities: StateFlow> = _taskPriorities - - private val _taskStatuses = MutableStateFlow>(emptyList()) - val taskStatuses: StateFlow> = _taskStatuses - - private val _taskCategories = MutableStateFlow>(emptyList()) - val taskCategories: StateFlow> = _taskCategories - - private val _contractorSpecialties = MutableStateFlow>(emptyList()) - val contractorSpecialties: StateFlow> = _contractorSpecialties - - private val _allTasks = MutableStateFlow>(emptyList()) - val allTasks: StateFlow> = _allTasks - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading - - private val _isInitialized = MutableStateFlow(false) - val isInitialized: StateFlow = _isInitialized + // Delegate to DataManager + val residenceTypes: StateFlow> = DataManager.residenceTypes + val taskFrequencies: StateFlow> = DataManager.taskFrequencies + val taskPriorities: StateFlow> = DataManager.taskPriorities + val taskStatuses: StateFlow> = DataManager.taskStatuses + val taskCategories: StateFlow> = DataManager.taskCategories + val contractorSpecialties: StateFlow> = DataManager.contractorSpecialties + val isInitialized: StateFlow = DataManager.lookupsInitialized /** - * Load all lookups from the API. + * Load all lookups from the API via DataManager. * This should be called once when the user logs in. */ fun initialize() { - // Only initialize once per app session - if (_isInitialized.value) { + // DataManager handles initialization via APILayer.initializeLookups() + if (DataManager.lookupsInitialized.value) { return } scope.launch { - _isLoading.value = true - - // Load cached tasks from disk immediately for offline access - val cachedTasks = TaskCacheStorage.getTasks() - if (cachedTasks != null) { - _allTasks.value = cachedTasks - println("Loaded ${cachedTasks.size} tasks from cache") - } - - val token = TokenStorage.getToken() - - if (token != null) { - // Load all static data in a single API call - launch { - when (val result = lookupsApi.getStaticData(token)) { - is ApiResult.Success -> { - _residenceTypes.value = result.data.residenceTypes - _taskFrequencies.value = result.data.taskFrequencies - _taskPriorities.value = result.data.taskPriorities - _taskStatuses.value = result.data.taskStatuses - _taskCategories.value = result.data.taskCategories - _contractorSpecialties.value = result.data.contractorSpecialties - println("Loaded all static data successfully") - } - else -> { - println("Failed to fetch static data") - } - } - } - - launch { - when (val result = lookupsApi.getAllTasks(token)) { - is ApiResult.Success -> { - _allTasks.value = result.data - // Save to disk cache for offline access - TaskCacheStorage.saveTasks(result.data) - println("Fetched and cached ${result.data.size} tasks from API") - } - else -> { - println("Failed to fetch tasks from API, using cached data if available") - } - } - } - - // Load subscription status for limitation checks - launch { - println("🔄 [LookupsRepository] Fetching subscription status...") - when (val result = subscriptionApi.getSubscriptionStatus(token)) { - is ApiResult.Success -> { - println("✅ [LookupsRepository] Subscription status loaded: limitationsEnabled=${result.data.limitationsEnabled}") - println(" Limits: ${result.data.limits}") - SubscriptionCache.updateSubscriptionStatus(result.data) - } - is ApiResult.Error -> { - println("❌ [LookupsRepository] Failed to fetch subscription status: ${result.message}") - } - else -> { - println("❌ [LookupsRepository] Unexpected subscription result") - } - } - } - - // Load upgrade triggers for subscription prompts - launch { - println("🔄 [LookupsRepository] Fetching upgrade triggers...") - when (val result = subscriptionApi.getUpgradeTriggers(token)) { - is ApiResult.Success -> { - println("✅ [LookupsRepository] Upgrade triggers loaded: ${result.data.size} triggers") - SubscriptionCache.updateUpgradeTriggers(result.data) - } - is ApiResult.Error -> { - println("❌ [LookupsRepository] Failed to fetch upgrade triggers: ${result.message}") - } - else -> { - println("❌ [LookupsRepository] Unexpected upgrade triggers result") - } - } - } - } - - _isInitialized.value = true - _isLoading.value = false + APILayer.initializeLookups() } } /** - * Clear all cached data. + * Clear all cached data via DataManager. * This should be called when the user logs out. */ fun clear() { - _residenceTypes.value = emptyList() - _taskFrequencies.value = emptyList() - _taskPriorities.value = emptyList() - _taskStatuses.value = emptyList() - _taskCategories.value = emptyList() - _contractorSpecialties.value = emptyList() - _allTasks.value = emptyList() - // Clear disk cache on logout - TaskCacheStorage.clearTasks() - // Clear subscription cache on logout - SubscriptionCache.clear() - _isInitialized.value = false - _isLoading.value = false + // DataManager.clear() is called by APILayer.logout() + // This method is kept for backwards compatibility } /** * Force refresh all lookups from the API. */ fun refresh() { - _isInitialized.value = false - initialize() + scope.launch { + APILayer.initializeLookups() + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt index 16c6969..78f10b3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt @@ -2,6 +2,7 @@ package com.example.casera.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.casera.data.DataManager import com.example.casera.models.AppleSignInRequest import com.example.casera.models.AppleSignInResponse import com.example.casera.models.AuthResponse @@ -19,7 +20,6 @@ import com.example.casera.models.VerifyResetCodeRequest import com.example.casera.models.VerifyResetCodeResponse import com.example.casera.network.ApiResult import com.example.casera.network.APILayer -import com.example.casera.storage.TokenStorage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -57,12 +57,9 @@ class AuthViewModel : ViewModel() { viewModelScope.launch { _loginState.value = ApiResult.Loading val result = APILayer.login(LoginRequest(username, password)) + // APILayer.login already stores token in DataManager _loginState.value = when (result) { - is ApiResult.Success -> { - // Store token for future API calls - TokenStorage.saveToken(result.data.token) - ApiResult.Success(result.data) - } + is ApiResult.Success -> ApiResult.Success(result.data) is ApiResult.Error -> result else -> ApiResult.Error("Unknown error") } @@ -81,8 +78,9 @@ class AuthViewModel : ViewModel() { ) _registerState.value = when (result) { is ApiResult.Success -> { - // Store token for future API calls - TokenStorage.saveToken(result.data.token) + // Store token in DataManager for future API calls + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) ApiResult.Success(result.data) } is ApiResult.Error -> result @@ -98,7 +96,7 @@ class AuthViewModel : ViewModel() { fun verifyEmail(code: String) { viewModelScope.launch { _verifyEmailState.value = ApiResult.Loading - val token = TokenStorage.getToken() ?: run { + val token = DataManager.authToken.value ?: run { _verifyEmailState.value = ApiResult.Error("Not authenticated") return@launch } @@ -118,7 +116,7 @@ class AuthViewModel : ViewModel() { fun updateProfile(firstName: String?, lastName: String?, email: String?) { viewModelScope.launch { _updateProfileState.value = ApiResult.Loading - val token = TokenStorage.getToken() ?: run { + val token = DataManager.authToken.value ?: run { _updateProfileState.value = ApiResult.Error("Not authenticated") return@launch } @@ -230,6 +228,7 @@ class AuthViewModel : ViewModel() { lastName = lastName ) ) + // APILayer.appleSignIn already stores token in DataManager _appleSignInState.value = when (result) { is ApiResult.Success -> ApiResult.Success(result.data) is ApiResult.Error -> result @@ -244,8 +243,8 @@ class AuthViewModel : ViewModel() { fun logout() { viewModelScope.launch { + // APILayer.logout clears DataManager APILayer.logout() - TokenStorage.clearToken() } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/LookupsViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/LookupsViewModel.kt index 7f0b234..29ab72c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/LookupsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/LookupsViewModel.kt @@ -2,17 +2,30 @@ package com.example.casera.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.casera.data.DataManager import com.example.casera.models.* import com.example.casera.network.ApiResult -import com.example.casera.network.LookupsApi -import com.example.casera.storage.TokenStorage +import com.example.casera.network.APILayer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +/** + * ViewModel for lookup data. + * Now uses DataManager as the single source of truth for all lookups. + * Lookups are loaded once via APILayer.initializeLookups() after login. + */ class LookupsViewModel : ViewModel() { - private val lookupsApi = LookupsApi() + // Expose DataManager's lookup StateFlows directly + val residenceTypes: StateFlow> = DataManager.residenceTypes + val taskFrequencies: StateFlow> = DataManager.taskFrequencies + val taskPriorities: StateFlow> = DataManager.taskPriorities + val taskStatuses: StateFlow> = DataManager.taskStatuses + val taskCategories: StateFlow> = DataManager.taskCategories + val contractorSpecialties: StateFlow> = DataManager.contractorSpecialties + + // Keep legacy state flows for compatibility during migration private val _residenceTypesState = MutableStateFlow>>(ApiResult.Idle) val residenceTypesState: StateFlow>> = _residenceTypesState @@ -28,100 +41,68 @@ class LookupsViewModel : ViewModel() { private val _taskCategoriesState = MutableStateFlow>>(ApiResult.Idle) val taskCategoriesState: StateFlow>> = _taskCategoriesState - // Cache flags to avoid refetching - private var residenceTypesFetched = false - private var taskFrequenciesFetched = false - private var taskPrioritiesFetched = false - private var taskStatusesFetched = false - private var taskCategoriesFetched = false - fun loadResidenceTypes() { - if (residenceTypesFetched && _residenceTypesState.value is ApiResult.Success) { - return // Already loaded - } viewModelScope.launch { - _residenceTypesState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _residenceTypesState.value = lookupsApi.getResidenceTypes(token) - if (_residenceTypesState.value is ApiResult.Success) { - residenceTypesFetched = true - } - } else { - _residenceTypesState.value = ApiResult.Error("Not authenticated", 401) + val cached = DataManager.residenceTypes.value + if (cached.isNotEmpty()) { + _residenceTypesState.value = ApiResult.Success(cached) + return@launch } + _residenceTypesState.value = ApiResult.Loading + val result = APILayer.getResidenceTypes() + _residenceTypesState.value = result } } fun loadTaskFrequencies() { - if (taskFrequenciesFetched && _taskFrequenciesState.value is ApiResult.Success) { - return // Already loaded - } viewModelScope.launch { - _taskFrequenciesState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _taskFrequenciesState.value = lookupsApi.getTaskFrequencies(token) - if (_taskFrequenciesState.value is ApiResult.Success) { - taskFrequenciesFetched = true - } - } else { - _taskFrequenciesState.value = ApiResult.Error("Not authenticated", 401) + val cached = DataManager.taskFrequencies.value + if (cached.isNotEmpty()) { + _taskFrequenciesState.value = ApiResult.Success(cached) + return@launch } + _taskFrequenciesState.value = ApiResult.Loading + val result = APILayer.getTaskFrequencies() + _taskFrequenciesState.value = result } } fun loadTaskPriorities() { - if (taskPrioritiesFetched && _taskPrioritiesState.value is ApiResult.Success) { - return // Already loaded - } viewModelScope.launch { - _taskPrioritiesState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _taskPrioritiesState.value = lookupsApi.getTaskPriorities(token) - if (_taskPrioritiesState.value is ApiResult.Success) { - taskPrioritiesFetched = true - } - } else { - _taskPrioritiesState.value = ApiResult.Error("Not authenticated", 401) + val cached = DataManager.taskPriorities.value + if (cached.isNotEmpty()) { + _taskPrioritiesState.value = ApiResult.Success(cached) + return@launch } + _taskPrioritiesState.value = ApiResult.Loading + val result = APILayer.getTaskPriorities() + _taskPrioritiesState.value = result } } fun loadTaskStatuses() { - if (taskStatusesFetched && _taskStatusesState.value is ApiResult.Success) { - return // Already loaded - } viewModelScope.launch { - _taskStatusesState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _taskStatusesState.value = lookupsApi.getTaskStatuses(token) - if (_taskStatusesState.value is ApiResult.Success) { - taskStatusesFetched = true - } - } else { - _taskStatusesState.value = ApiResult.Error("Not authenticated", 401) + val cached = DataManager.taskStatuses.value + if (cached.isNotEmpty()) { + _taskStatusesState.value = ApiResult.Success(cached) + return@launch } + _taskStatusesState.value = ApiResult.Loading + val result = APILayer.getTaskStatuses() + _taskStatusesState.value = result } } fun loadTaskCategories() { - if (taskCategoriesFetched && _taskCategoriesState.value is ApiResult.Success) { - return // Already loaded - } viewModelScope.launch { - _taskCategoriesState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _taskCategoriesState.value = lookupsApi.getTaskCategories(token) - if (_taskCategoriesState.value is ApiResult.Success) { - taskCategoriesFetched = true - } - } else { - _taskCategoriesState.value = ApiResult.Error("Not authenticated", 401) + val cached = DataManager.taskCategories.value + if (cached.isNotEmpty()) { + _taskCategoriesState.value = ApiResult.Success(cached) + return@launch } + _taskCategoriesState.value = ApiResult.Loading + val result = APILayer.getTaskCategories() + _taskCategoriesState.value = result } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt index bdb1ff4..209ed98 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt @@ -2,11 +2,11 @@ package com.example.casera.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.casera.data.DataManager import com.example.casera.models.TaskCompletion import com.example.casera.models.TaskCompletionCreateRequest import com.example.casera.network.ApiResult import com.example.casera.network.TaskCompletionApi -import com.example.casera.storage.TokenStorage import com.example.casera.util.ImageCompressor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,7 +21,7 @@ class TaskCompletionViewModel : ViewModel() { fun createTaskCompletion(request: TaskCompletionCreateRequest) { viewModelScope.launch { _createCompletionState.value = ApiResult.Loading - val token = TokenStorage.getToken() + val token = DataManager.authToken.value if (token != null) { _createCompletionState.value = taskCompletionApi.createCompletion(token, request) } else { @@ -42,7 +42,7 @@ class TaskCompletionViewModel : ViewModel() { ) { viewModelScope.launch { _createCompletionState.value = ApiResult.Loading - val token = TokenStorage.getToken() + val token = DataManager.authToken.value if (token != null) { // Compress images and prepare for upload val compressedImages = images.map { ImageCompressor.compressImage(it) } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/data/PersistenceManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/data/PersistenceManager.ios.kt new file mode 100644 index 0000000..b7659a8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/data/PersistenceManager.ios.kt @@ -0,0 +1,42 @@ +package com.example.casera.data + +import platform.Foundation.NSUserDefaults + +/** + * iOS implementation of PersistenceManager using NSUserDefaults. + */ +actual class PersistenceManager { + private val defaults = NSUserDefaults.standardUserDefaults + + actual fun save(key: String, value: String) { + defaults.setObject(value, forKey = key) + defaults.synchronize() + } + + actual fun load(key: String): String? { + return defaults.stringForKey(key) + } + + actual fun remove(key: String) { + defaults.removeObjectForKey(key) + defaults.synchronize() + } + + actual fun clear() { + // Get all keys with our prefix and remove them + val dict = defaults.dictionaryRepresentation() + dict.keys.forEach { key -> + val keyStr = key as? String ?: return@forEach + if (keyStr.startsWith("dm_")) { + defaults.removeObjectForKey(keyStr) + } + } + defaults.synchronize() + } + + companion object { + private val instance by lazy { PersistenceManager() } + + fun getInstance(): PersistenceManager = instance + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/data/PersistenceManager.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/data/PersistenceManager.jvm.kt new file mode 100644 index 0000000..37e2306 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/data/PersistenceManager.jvm.kt @@ -0,0 +1,65 @@ +package com.example.casera.data + +import java.io.File +import java.util.Properties + +/** + * JVM/Desktop implementation of PersistenceManager using Properties file. + */ +actual class PersistenceManager { + private val properties = Properties() + private val storageFile: File + + init { + val userHome = System.getProperty("user.home") + val appDir = File(userHome, ".casera") + if (!appDir.exists()) { + appDir.mkdirs() + } + storageFile = File(appDir, "data.properties") + loadProperties() + } + + private fun loadProperties() { + if (storageFile.exists()) { + try { + storageFile.inputStream().use { properties.load(it) } + } catch (e: Exception) { + // Ignore load errors + } + } + } + + private fun saveProperties() { + try { + storageFile.outputStream().use { properties.store(it, "Casera Data Manager") } + } catch (e: Exception) { + // Ignore save errors + } + } + + actual fun save(key: String, value: String) { + properties.setProperty(key, value) + saveProperties() + } + + actual fun load(key: String): String? { + return properties.getProperty(key) + } + + actual fun remove(key: String) { + properties.remove(key) + saveProperties() + } + + actual fun clear() { + properties.clear() + saveProperties() + } + + companion object { + private val instance by lazy { PersistenceManager() } + + fun getInstance(): PersistenceManager = instance + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/data/PersistenceManager.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/data/PersistenceManager.wasmJs.kt new file mode 100644 index 0000000..b92b606 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/data/PersistenceManager.wasmJs.kt @@ -0,0 +1,38 @@ +package com.example.casera.data + +import kotlinx.browser.localStorage + +/** + * WasmJS implementation of PersistenceManager using browser localStorage. + */ +actual class PersistenceManager { + actual fun save(key: String, value: String) { + localStorage.setItem(key, value) + } + + actual fun load(key: String): String? { + return localStorage.getItem(key) + } + + actual fun remove(key: String) { + localStorage.removeItem(key) + } + + actual fun clear() { + // Remove all items with our prefix + val keysToRemove = mutableListOf() + for (i in 0 until localStorage.length) { + val key = localStorage.key(i) ?: continue + if (key.startsWith("dm_")) { + keysToRemove.add(key) + } + } + keysToRemove.forEach { localStorage.removeItem(it) } + } + + companion object { + private val instance by lazy { PersistenceManager() } + + fun getInstance(): PersistenceManager = instance + } +} diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift new file mode 100644 index 0000000..0c19409 --- /dev/null +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -0,0 +1,429 @@ +import Foundation +import ComposeApp +import Combine + +/// SwiftUI-compatible wrapper for Kotlin DataManager. +/// Observes all DataManager StateFlows and publishes changes via @Published properties. +/// +/// Usage in SwiftUI views: +/// ```swift +/// @StateObject private var dataManager = DataManagerObservable.shared +/// // or +/// @EnvironmentObject var dataManager: DataManagerObservable +/// ``` +/// +/// This is the Swift-side Single Source of Truth that mirrors Kotlin's DataManager. +/// All screens should observe this instead of making duplicate API calls. +@MainActor +class DataManagerObservable: ObservableObject { + + // MARK: - Singleton + + static let shared = DataManagerObservable() + + // MARK: - Authentication + + @Published var authToken: String? + @Published var currentUser: User? + @Published var isAuthenticated: Bool = false + + // MARK: - App Preferences + + @Published var themeId: String = "default" + + // MARK: - Residences + + @Published var residences: [ResidenceResponse] = [] + @Published var myResidences: MyResidencesResponse? + @Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:] + + // MARK: - Tasks + + @Published var allTasks: TaskColumnsResponse? + @Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:] + + // MARK: - Documents + + @Published var documents: [Document] = [] + @Published var documentsByResidence: [Int32: [Document]] = [:] + + // MARK: - Contractors + + @Published var contractors: [Contractor] = [] + + // MARK: - Subscription + + @Published var subscription: SubscriptionStatus? + @Published var upgradeTriggers: [String: UpgradeTriggerData] = [:] + @Published var featureBenefits: [FeatureBenefit] = [] + @Published var promotions: [Promotion] = [] + + // MARK: - Lookups (Reference Data) + + @Published var residenceTypes: [ResidenceType] = [] + @Published var taskFrequencies: [TaskFrequency] = [] + @Published var taskPriorities: [TaskPriority] = [] + @Published var taskStatuses: [TaskStatus] = [] + @Published var taskCategories: [TaskCategory] = [] + @Published var contractorSpecialties: [ContractorSpecialty] = [] + + // MARK: - State Metadata + + @Published var isInitialized: Bool = false + @Published var lookupsInitialized: Bool = false + @Published var lastSyncTime: Int64 = 0 + + // MARK: - Private Properties + + private var observationTasks: [Task] = [] + + // MARK: - Initialization + + private init() { + startObserving() + } + + // MARK: - Observation Setup + + /// Start observing all DataManager StateFlows + private func startObserving() { + // Authentication - authToken + let authTokenTask = Task { + for await token in DataManager.shared.authToken { + await MainActor.run { + self.authToken = token + self.isAuthenticated = token != nil + } + } + } + observationTasks.append(authTokenTask) + + // Authentication - currentUser + let currentUserTask = Task { + for await user in DataManager.shared.currentUser { + await MainActor.run { + self.currentUser = user + } + } + } + observationTasks.append(currentUserTask) + + // Theme + let themeIdTask = Task { + for await id in DataManager.shared.themeId { + await MainActor.run { + self.themeId = id + } + } + } + observationTasks.append(themeIdTask) + + // Residences + let residencesTask = Task { + for await list in DataManager.shared.residences { + await MainActor.run { + self.residences = list + } + } + } + observationTasks.append(residencesTask) + + // MyResidences + let myResidencesTask = Task { + for await response in DataManager.shared.myResidences { + await MainActor.run { + self.myResidences = response + } + } + } + observationTasks.append(myResidencesTask) + + // ResidenceSummaries + let residenceSummariesTask = Task { + for await summaries in DataManager.shared.residenceSummaries { + await MainActor.run { + self.residenceSummaries = self.convertIntMap(summaries) + } + } + } + observationTasks.append(residenceSummariesTask) + + // AllTasks + let allTasksTask = Task { + for await tasks in DataManager.shared.allTasks { + await MainActor.run { + self.allTasks = tasks + } + } + } + observationTasks.append(allTasksTask) + + // TasksByResidence + let tasksByResidenceTask = Task { + for await tasks in DataManager.shared.tasksByResidence { + await MainActor.run { + self.tasksByResidence = self.convertIntMap(tasks) + } + } + } + observationTasks.append(tasksByResidenceTask) + + // Documents + let documentsTask = Task { + for await docs in DataManager.shared.documents { + await MainActor.run { + self.documents = docs + } + } + } + observationTasks.append(documentsTask) + + // DocumentsByResidence + let documentsByResidenceTask = Task { + for await docs in DataManager.shared.documentsByResidence { + await MainActor.run { + self.documentsByResidence = self.convertIntArrayMap(docs) + } + } + } + observationTasks.append(documentsByResidenceTask) + + // Contractors + let contractorsTask = Task { + for await list in DataManager.shared.contractors { + await MainActor.run { + self.contractors = list + } + } + } + observationTasks.append(contractorsTask) + + // Subscription + let subscriptionTask = Task { + for await sub in DataManager.shared.subscription { + await MainActor.run { + self.subscription = sub + } + } + } + observationTasks.append(subscriptionTask) + + // UpgradeTriggers + let upgradeTriggersTask = Task { + for await triggers in DataManager.shared.upgradeTriggers { + await MainActor.run { + self.upgradeTriggers = self.convertStringMap(triggers) + } + } + } + observationTasks.append(upgradeTriggersTask) + + // FeatureBenefits + let featureBenefitsTask = Task { + for await benefits in DataManager.shared.featureBenefits { + await MainActor.run { + self.featureBenefits = benefits + } + } + } + observationTasks.append(featureBenefitsTask) + + // Promotions + let promotionsTask = Task { + for await promos in DataManager.shared.promotions { + await MainActor.run { + self.promotions = promos + } + } + } + observationTasks.append(promotionsTask) + + // Lookups - ResidenceTypes + let residenceTypesTask = Task { + for await types in DataManager.shared.residenceTypes { + await MainActor.run { + self.residenceTypes = types + } + } + } + observationTasks.append(residenceTypesTask) + + // Lookups - TaskFrequencies + let taskFrequenciesTask = Task { + for await items in DataManager.shared.taskFrequencies { + await MainActor.run { + self.taskFrequencies = items + } + } + } + observationTasks.append(taskFrequenciesTask) + + // Lookups - TaskPriorities + let taskPrioritiesTask = Task { + for await items in DataManager.shared.taskPriorities { + await MainActor.run { + self.taskPriorities = items + } + } + } + observationTasks.append(taskPrioritiesTask) + + // Lookups - TaskStatuses + let taskStatusesTask = Task { + for await items in DataManager.shared.taskStatuses { + await MainActor.run { + self.taskStatuses = items + } + } + } + observationTasks.append(taskStatusesTask) + + // Lookups - TaskCategories + let taskCategoriesTask = Task { + for await items in DataManager.shared.taskCategories { + await MainActor.run { + self.taskCategories = items + } + } + } + observationTasks.append(taskCategoriesTask) + + // Lookups - ContractorSpecialties + let contractorSpecialtiesTask = Task { + for await items in DataManager.shared.contractorSpecialties { + await MainActor.run { + self.contractorSpecialties = items + } + } + } + observationTasks.append(contractorSpecialtiesTask) + + // Metadata - isInitialized + let isInitializedTask = Task { + for await initialized in DataManager.shared.isInitialized { + await MainActor.run { + self.isInitialized = initialized.boolValue + } + } + } + observationTasks.append(isInitializedTask) + + // Metadata - lookupsInitialized + let lookupsInitializedTask = Task { + for await initialized in DataManager.shared.lookupsInitialized { + await MainActor.run { + self.lookupsInitialized = initialized.boolValue + } + } + } + observationTasks.append(lookupsInitializedTask) + + // Metadata - lastSyncTime + let lastSyncTimeTask = Task { + for await time in DataManager.shared.lastSyncTime { + await MainActor.run { + self.lastSyncTime = time.int64Value + } + } + } + observationTasks.append(lastSyncTimeTask) + } + + /// Stop all observations + func stopObserving() { + observationTasks.forEach { $0.cancel() } + observationTasks.removeAll() + } + + // MARK: - Map Conversion Helpers + + /// Convert Kotlin Map to Swift [Int32: V] + private func convertIntMap(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] { + var result: [Int32: V] = [:] + for (key, value) in kotlinMap { + result[key.int32Value] = value + } + return result + } + + /// Convert Kotlin Map> to Swift [Int32: [V]] + private func convertIntArrayMap(_ kotlinMap: [KotlinInt: [V]]) -> [Int32: [V]] { + var result: [Int32: [V]] = [:] + for (key, value) in kotlinMap { + result[key.int32Value] = value + } + return result + } + + /// Convert Kotlin Map to Swift [String: V] + private func convertStringMap(_ kotlinMap: [String: V]) -> [String: V] { + return kotlinMap + } + + // MARK: - Convenience Lookup Methods + + /// Get residence type by ID + func getResidenceType(id: Int32?) -> ResidenceType? { + guard let id = id else { return nil } + return residenceTypes.first { $0.id == id } + } + + /// Get task frequency by ID + func getTaskFrequency(id: Int32?) -> TaskFrequency? { + guard let id = id else { return nil } + return taskFrequencies.first { $0.id == id } + } + + /// Get task priority by ID + func getTaskPriority(id: Int32?) -> TaskPriority? { + guard let id = id else { return nil } + return taskPriorities.first { $0.id == id } + } + + /// Get task status by ID + func getTaskStatus(id: Int32?) -> TaskStatus? { + guard let id = id else { return nil } + return taskStatuses.first { $0.id == id } + } + + /// Get task category by ID + func getTaskCategory(id: Int32?) -> TaskCategory? { + guard let id = id else { return nil } + return taskCategories.first { $0.id == id } + } + + /// Get contractor specialty by ID + func getContractorSpecialty(id: Int32?) -> ContractorSpecialty? { + guard let id = id else { return nil } + return contractorSpecialties.first { $0.id == id } + } + + // MARK: - Task Helpers + + /// Get tasks for a specific residence + func tasks(for residenceId: Int32) -> TaskColumnsResponse? { + return tasksByResidence[residenceId] + } + + /// Get documents for a specific residence + func documents(for residenceId: Int32) -> [Document] { + return documentsByResidence[residenceId] ?? [] + } + + /// Get residence summary + func summary(for residenceId: Int32) -> ResidenceSummaryResponse? { + return residenceSummaries[residenceId] + } + + /// Total task count across all columns + var totalTaskCount: Int { + guard let response = allTasks else { return 0 } + return response.columns.reduce(0) { $0 + Int($1.count) } + } + + /// Check if there are no tasks + var hasNoTasks: Bool { + guard let response = allTasks else { return true } + return response.columns.allSatisfy { $0.tasks.isEmpty } + } +} diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index e02262e..1ed640c 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -18,8 +18,8 @@ class AuthenticationManager: ObservableObject { func checkAuthenticationStatus() { isCheckingAuth = true - // Check if token exists - guard let token = TokenStorage.shared.getToken(), !token.isEmpty else { + // Check if token exists via DataManager (single source of truth) + guard DataManager.shared.isAuthenticated() else { isAuthenticated = false isVerified = false isCheckingAuth = false @@ -45,15 +45,15 @@ class AuthenticationManager: ObservableObject { await StoreKitManager.shared.verifyEntitlementsOnLaunch() } } else if result is ApiResultError { - // Token is invalid, clear it - TokenStorage.shared.clearToken() + // Token is invalid, clear all data via DataManager + DataManager.shared.clear() self.isAuthenticated = false self.isVerified = false } } catch { print("❌ Failed to check auth status: \(error)") // On error, assume token is invalid - TokenStorage.shared.clearToken() + DataManager.shared.clear() self.isAuthenticated = false self.isVerified = false } @@ -85,18 +85,9 @@ class AuthenticationManager: ObservableObject { } func logout() { - // Call shared ViewModel logout + // Call shared ViewModel logout which clears DataManager sharedViewModel.logout() - // Clear token from storage - TokenStorage.shared.clearToken() - - // Clear lookups data on logout via DataCache - DataCache.shared.clearLookups() - - // Clear all cached data - DataCache.shared.clearAll() - // Clear widget task data WidgetDataManager.shared.clearCache() diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index f87523b..aa21a30 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -252,7 +252,7 @@ class TaskViewModel: ObservableObject { /// - residenceId: Optional residence ID to filter by. If nil, loads all tasks. /// - forceRefresh: Whether to bypass cache func loadTasks(residenceId: Int32? = nil, forceRefresh: Bool = false) { - guard TokenStorage.shared.getToken() != nil else { return } + guard DataManager.shared.isAuthenticated() else { return } currentResidenceId = residenceId isLoadingTasks = true diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 52ac8d7..c51ae05 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -8,8 +8,16 @@ struct iOSApp: App { @State private var deepLinkResetToken: String? init() { - // Initialize TokenStorage once at app startup - TokenStorage.shared.initialize(manager: TokenManager()) + // Initialize DataManager with platform-specific managers + // This must be done before any other operations that access DataManager + DataManager.shared.initialize( + tokenMgr: TokenManager.Companion.shared.getInstance(), + themeMgr: ThemeStorageManager.Companion.shared.getInstance(), + persistenceMgr: PersistenceManager() + ) + + // Initialize TokenStorage once at app startup (legacy support) + TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance()) } var body: some Scene {