diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index b8c885d..61528da 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -33,6 +33,8 @@ import com.example.casera.fcm.FCMManager import com.example.casera.platform.BillingManager import com.example.casera.network.APILayer import com.example.casera.sharing.ContractorSharingManager +import com.example.casera.data.DataManager +import com.example.casera.data.PersistenceManager import kotlinx.coroutines.launch class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { @@ -55,6 +57,14 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { ThemeStorage.initialize(ThemeStorageManager.getInstance(applicationContext)) ThemeManager.initialize() + // Initialize DataManager with platform-specific managers + // This loads cached lookup data from disk for faster startup + DataManager.initialize( + tokenMgr = TokenManager.getInstance(applicationContext), + themeMgr = ThemeStorageManager.getInstance(applicationContext), + persistenceMgr = PersistenceManager.getInstance(applicationContext) + ) + // Initialize BillingManager for subscription management billingManager = BillingManager.getInstance(applicationContext) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index afdcf4b..a3844c3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -592,6 +592,7 @@ object DataManager { /** * Set all lookups from unified seeded data response. * Also stores the ETag for future conditional requests. + * Persists lookup data to disk for faster app startup. */ fun setAllLookupsFromSeededData(seededData: SeededDataResponse, etag: String?) { setResidenceTypes(seededData.residenceTypes) @@ -603,6 +604,8 @@ object DataManager { setTaskTemplatesGrouped(seededData.taskTemplates) setSeededDataETag(etag) _lookupsInitialized.value = true + // Persist lookups to disk for faster startup + persistLookupsToDisk() } /** @@ -723,32 +726,63 @@ object DataManager { /** * Persist current state to disk. - * Only persists user data - all other data is fetched fresh from API. - * No offline mode support - network required for app functionality. + * Persists user data and lookup data for faster app startup. */ private fun persistToDisk() { val manager = persistenceManager ?: return try { - // Only persist user data - everything else is fetched fresh from API _currentUser.value?.let { manager.save(KEY_CURRENT_USER, json.encodeToString(it)) } } catch (e: Exception) { - println("DataManager: Error persisting to disk: ${e.message}") + println("DataManager: Error persisting user to disk: ${e.message}") + } + } + + /** + * Persist lookup data to disk separately (called less frequently). + */ + private fun persistLookupsToDisk() { + val manager = persistenceManager ?: return + + try { + if (_residenceTypes.value.isNotEmpty()) { + manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value)) + } + if (_taskFrequencies.value.isNotEmpty()) { + manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value)) + } + if (_taskPriorities.value.isNotEmpty()) { + manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value)) + } + if (_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)) + } + _taskTemplatesGrouped.value?.let { + manager.save(KEY_TASK_TEMPLATES_GROUPED, json.encodeToString(it)) + } + println("DataManager: Lookup data persisted to disk") + } catch (e: Exception) { + println("DataManager: Error persisting lookups to disk: ${e.message}") } } /** * Load cached state from disk. - * Only loads user data - all other data is fetched fresh from API. - * No offline mode support - network required for app functionality. + * Loads user data and lookup data for faster app startup. */ private fun loadFromDisk() { val manager = persistenceManager ?: return try { - // Only load user data - everything else is fetched fresh from API + // Load user data manager.load(KEY_CURRENT_USER)?.let { data -> _currentUser.value = json.decodeFromString(data) } @@ -762,15 +796,86 @@ object DataManager { manager.load(KEY_SEEDED_DATA_ETAG)?.let { data -> _seededDataETag.value = data } + + // Load lookup data + loadLookupsFromDisk() } catch (e: Exception) { println("DataManager: Error loading from disk: ${e.message}") } } + /** + * Load lookup data from disk. + * If lookups are successfully loaded, mark as initialized. + */ + private fun loadLookupsFromDisk() { + val manager = persistenceManager ?: return + var lookupsLoaded = false + + try { + manager.load(KEY_RESIDENCE_TYPES)?.let { data -> + val types = json.decodeFromString>(data) + _residenceTypes.value = types + _residenceTypesMap.value = types.associateBy { it.id } + lookupsLoaded = true + } + + manager.load(KEY_TASK_FREQUENCIES)?.let { data -> + val frequencies = json.decodeFromString>(data) + _taskFrequencies.value = frequencies + _taskFrequenciesMap.value = frequencies.associateBy { it.id } + } + + manager.load(KEY_TASK_PRIORITIES)?.let { data -> + val priorities = json.decodeFromString>(data) + _taskPriorities.value = priorities + _taskPrioritiesMap.value = priorities.associateBy { it.id } + } + + manager.load(KEY_TASK_STATUSES)?.let { data -> + val statuses = json.decodeFromString>(data) + _taskStatuses.value = statuses + _taskStatusesMap.value = statuses.associateBy { it.id } + } + + manager.load(KEY_TASK_CATEGORIES)?.let { data -> + val categories = json.decodeFromString>(data) + _taskCategories.value = categories + _taskCategoriesMap.value = categories.associateBy { it.id } + } + + manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data -> + val specialties = json.decodeFromString>(data) + _contractorSpecialties.value = specialties + _contractorSpecialtiesMap.value = specialties.associateBy { it.id } + } + + manager.load(KEY_TASK_TEMPLATES_GROUPED)?.let { data -> + val grouped = json.decodeFromString(data) + _taskTemplatesGrouped.value = grouped + _taskTemplates.value = grouped.categories.flatMap { it.templates } + } + + // Mark lookups as initialized if we loaded any data + if (lookupsLoaded) { + _lookupsInitialized.value = true + println("DataManager: Lookup data loaded from disk") + } + } catch (e: Exception) { + println("DataManager: Error loading lookups from disk: ${e.message}") + } + } + // ==================== PERSISTENCE KEYS ==================== - // Only user data is persisted - all other data fetched fresh from API private const val KEY_CURRENT_USER = "dm_current_user" private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding" private const val KEY_SEEDED_DATA_ETAG = "dm_seeded_data_etag" + private const val KEY_RESIDENCE_TYPES = "dm_residence_types" + private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies" + private const val KEY_TASK_PRIORITIES = "dm_task_priorities" + private const val KEY_TASK_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_TASK_TEMPLATES_GROUPED = "dm_task_templates_grouped" } 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 af58449..3139f07 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -80,8 +80,12 @@ object APILayer { try { // Use seeded data endpoint with ETag support (PUBLIC - no auth required) - println("🔄 Fetching seeded data (all lookups + templates)...") - val seededDataResult = lookupsApi.getSeededData(currentETag, token) + // Only send ETag if lookups are already in memory - otherwise we need full data + // (ETag may be persisted from previous session but lookup data wasn't loaded) + val hasLookupsInMemory = DataManager.residenceTypes.value.isNotEmpty() + val etagToSend = if (hasLookupsInMemory) currentETag else null + println("🔄 Fetching seeded data (all lookups + templates)... ETag: $etagToSend (has data in memory: $hasLookupsInMemory)") + val seededDataResult = lookupsApi.getSeededData(etagToSend, token) println("📦 Seeded data result: $seededDataResult") when (seededDataResult) { @@ -146,9 +150,14 @@ object APILayer { val token = getToken() val currentETag = DataManager.seededDataETag.value - println("🔄 [APILayer] Checking if lookups have changed (ETag: $currentETag)...") + // Only send ETag if we actually have lookup data in memory + // Otherwise we need a full fetch to populate the cache + val hasLookupsInMemory = DataManager.residenceTypes.value.isNotEmpty() + val etagToSend = if (hasLookupsInMemory) currentETag else null - val seededDataResult = lookupsApi.getSeededData(currentETag, token) + println("🔄 [APILayer] Checking if lookups have changed (ETag: $etagToSend, has data: $hasLookupsInMemory)...") + + val seededDataResult = lookupsApi.getSeededData(etagToSend, token) when (seededDataResult) { is ConditionalResult.Success -> { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 64f9341..77abda0 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -1,5 +1,6 @@ import SwiftUI import ComposeApp +import WidgetKit @main struct iOSApp: App { @@ -44,6 +45,14 @@ struct iOSApp: App { if newPhase == .active { // Check and register device token when app becomes active PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded() + + // Refresh lookups/static data when app becomes active + Task { + _ = try? await APILayer.shared.initializeLookups() + } + } else if newPhase == .background { + // Refresh widget when app goes to background + WidgetCenter.shared.reloadAllTimelines() } } // Import confirmation dialog