From 63a54434edb7408322b951c20158eb3f993b1b25 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 3 Dec 2025 09:50:57 -0600 Subject: [PATCH] Add 1-hour cache timeout and fix pull-to-refresh across iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable cache timeout (CACHE_TIMEOUT_MS) to DataManager - Fix cache to work with empty results (contractors, documents, residences) - Change Documents/Warranties view to use client-side filtering for cache efficiency - Add pull-to-refresh support for empty state views in ListAsyncContentView - Fix ContractorsListView to pass forceRefresh parameter correctly - Fix TaskViewModel loading spinner not stopping after refresh completes - Remove duplicate cache checks in iOS ViewModels, delegate to Kotlin APILayer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/example/casera/data/DataManager.kt | 251 +++++++--------- .../com/example/casera/models/Contractor.kt | 14 +- .../com/example/casera/network/APILayer.kt | 104 ++++--- .../com/example/casera/network/ApiConfig.kt | 2 +- .../example/casera/network/ResidenceApi.kt | 4 +- .../viewmodel/PasswordResetViewModel.kt | 9 +- .../casera/viewmodel/ResidenceViewModel.kt | 12 +- .../viewmodel/TaskCompletionViewModel.kt | 60 ++-- .../Contractor/ContractorViewModel.swift | 211 +++++++------ .../Contractor/ContractorsListView.swift | 2 +- iosApp/iosApp/Core/AsyncContentView.swift | 14 +- .../iosApp/Data/DataManagerObservable.swift | 34 ++- .../iosApp/Documents/DocumentViewModel.swift | 257 ++++++++-------- .../Documents/DocumentViewModelWrapper.swift | 65 +--- .../Documents/DocumentsWarrantiesView.swift | 65 ++-- .../iosApp/Login/AppleSignInViewModel.swift | 89 ++---- iosApp/iosApp/Login/LoginViewModel.swift | 281 +++++++----------- .../OnboardingJoinResidenceView.swift | 27 +- .../PasswordResetViewModel.swift | 128 ++++---- .../Profile/NotificationPreferencesView.swift | 110 +++---- iosApp/iosApp/Profile/ProfileViewModel.swift | 113 ++++--- .../iosApp/Register/RegisterViewModel.swift | 50 ++-- .../iosApp/Residence/JoinResidenceView.swift | 25 +- .../iosApp/Residence/ResidenceViewModel.swift | 240 ++++++++++----- .../iosApp/Residence/ResidencesListView.swift | 6 +- iosApp/iosApp/RootView.swift | 8 +- .../iosApp/Subscription/StoreKitManager.swift | 23 +- iosApp/iosApp/Task/TaskViewModel.swift | 255 ++++++++++------ .../VerifyEmail/VerifyEmailViewModel.swift | 55 ++-- 29 files changed, 1284 insertions(+), 1230 deletions(-) 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 13fff57..aead6ea 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -26,6 +26,47 @@ import kotlin.time.ExperimentalTime */ object DataManager { + // ==================== CACHE CONFIGURATION ==================== + + /** + * Cache timeout in milliseconds. + * Data older than this will be refreshed from the API. + * Default: 1 hour (3600000ms) + */ + const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour + + // Cache timestamps for each data type (epoch milliseconds) + var residencesCacheTime: Long = 0L + private set + var myResidencesCacheTime: Long = 0L + private set + var tasksCacheTime: Long = 0L + private set + var tasksByResidenceCacheTime: MutableMap = mutableMapOf() + private set + var contractorsCacheTime: Long = 0L + private set + var documentsCacheTime: Long = 0L + private set + var summaryCacheTime: Long = 0L + private set + + /** + * Check if cache for a given timestamp is still valid (not expired) + */ + @OptIn(ExperimentalTime::class) + fun isCacheValid(cacheTime: Long): Boolean { + if (cacheTime == 0L) return false + val now = Clock.System.now().toEpochMilliseconds() + return (now - cacheTime) < CACHE_TIMEOUT_MS + } + + /** + * Get current timestamp in milliseconds + */ + @OptIn(ExperimentalTime::class) + private fun currentTimeMs(): Long = Clock.System.now().toEpochMilliseconds() + // Platform-specific persistence managers (initialized at app start) private var tokenManager: TokenManager? = null private var themeManager: ThemeStorageManager? = null @@ -58,6 +99,9 @@ object DataManager { private val _myResidences = MutableStateFlow(null) val myResidences: StateFlow = _myResidences.asStateFlow() + private val _totalSummary = MutableStateFlow(null) + val totalSummary: StateFlow = _totalSummary.asStateFlow() + private val _residenceSummaries = MutableStateFlow>(emptyMap()) val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() @@ -78,9 +122,10 @@ object DataManager { val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() // ==================== CONTRACTORS ==================== + // Stores ContractorSummary for list views (lighter weight than full Contractor) - private val _contractors = MutableStateFlow>(emptyList()) - val contractors: StateFlow> = _contractors.asStateFlow() + private val _contractors = MutableStateFlow>(emptyList()) + val contractors: StateFlow> = _contractors.asStateFlow() // ==================== SUBSCRIPTION ==================== @@ -215,16 +260,31 @@ object DataManager { fun setResidences(residences: List) { _residences.value = residences + residencesCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } fun setMyResidences(response: MyResidencesResponse) { _myResidences.value = response + // Also update totalSummary from myResidences response + _totalSummary.value = response.summary + myResidencesCacheTime = currentTimeMs() + summaryCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } + fun setTotalSummary(summary: TotalSummary) { + _totalSummary.value = summary + // Also update the summary in myResidences if it exists + _myResidences.value?.let { current -> + _myResidences.value = current.copy(summary = summary) + } + summaryCacheTime = currentTimeMs() + persistToDisk() + } + fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) { _residenceSummaries.value = _residenceSummaries.value + (residenceId to summary) persistToDisk() @@ -255,12 +315,14 @@ object DataManager { fun setAllTasks(response: TaskColumnsResponse) { _allTasks.value = response + tasksCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) { _tasksByResidence.value = _tasksByResidence.value + (residenceId to response) + tasksByResidenceCacheTime[residenceId] = currentTimeMs() persistToDisk() } @@ -332,6 +394,7 @@ object DataManager { fun setDocuments(documents: List) { _documents.value = documents + documentsCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } @@ -364,24 +427,40 @@ object DataManager { // ==================== CONTRACTOR UPDATE METHODS ==================== - fun setContractors(contractors: List) { + fun setContractors(contractors: List) { _contractors.value = contractors + contractorsCacheTime = currentTimeMs() updateLastSyncTime() persistToDisk() } - fun addContractor(contractor: Contractor) { + fun addContractor(contractor: ContractorSummary) { _contractors.value = _contractors.value + contractor persistToDisk() } - fun updateContractor(contractor: Contractor) { + /** Add a full Contractor (converts to summary for storage) */ + fun addContractor(contractor: Contractor) { + _contractors.value = _contractors.value + contractor.toSummary() + persistToDisk() + } + + fun updateContractor(contractor: ContractorSummary) { _contractors.value = _contractors.value.map { if (it.id == contractor.id) contractor else it } persistToDisk() } + /** Update from a full Contractor (converts to summary for storage) */ + fun updateContractor(contractor: Contractor) { + val summary = contractor.toSummary() + _contractors.value = _contractors.value.map { + if (it.id == summary.id) summary else it + } + persistToDisk() + } + fun removeContractor(contractorId: Int) { _contractors.value = _contractors.value.filter { it.id != contractorId } persistToDisk() @@ -475,6 +554,7 @@ object DataManager { // Clear user data _residences.value = emptyList() _myResidences.value = null + _totalSummary.value = null _residenceSummaries.value = emptyMap() _allTasks.value = null _tasksByResidence.value = emptyMap() @@ -503,6 +583,15 @@ object DataManager { _contractorSpecialtiesMap.value = emptyMap() _lookupsInitialized.value = false + // Clear cache timestamps + residencesCacheTime = 0L + myResidencesCacheTime = 0L + tasksCacheTime = 0L + tasksByResidenceCacheTime.clear() + contractorsCacheTime = 0L + documentsCacheTime = 0L + summaryCacheTime = 0L + // Clear metadata _lastSyncTime.value = 0L @@ -517,6 +606,7 @@ object DataManager { _currentUser.value = null _residences.value = emptyList() _myResidences.value = null + _totalSummary.value = null _residenceSummaries.value = emptyMap() _allTasks.value = null _tasksByResidence.value = emptyMap() @@ -527,6 +617,16 @@ object DataManager { _upgradeTriggers.value = emptyMap() _featureBenefits.value = emptyList() _promotions.value = emptyList() + + // Clear cache timestamps + residencesCacheTime = 0L + myResidencesCacheTime = 0L + tasksCacheTime = 0L + tasksByResidenceCacheTime.clear() + contractorsCacheTime = 0L + documentsCacheTime = 0L + summaryCacheTime = 0L + persistToDisk() } @@ -539,169 +639,42 @@ object DataManager { /** * Persist current state to disk. - * Called automatically after each update. + * Only persists user data - all other data is fetched fresh from API. + * No offline mode support - network required for app functionality. */ private fun persistToDisk() { val manager = persistenceManager ?: return try { - // Persist each data type + // Only persist user data - everything else is fetched fresh from API _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. + * Only loads user data - all other data is fetched fresh from API. + * No offline mode support - network required for app functionality. */ private fun loadFromDisk() { val manager = persistenceManager ?: return try { + // Only load user data - everything else is fetched fresh from API 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 ==================== + // 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_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/models/Contractor.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Contractor.kt index a4fe559..69906b9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Contractor.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Contractor.kt @@ -86,5 +86,15 @@ data class ContractorSummary( @SerialName("task_count") val taskCount: Int = 0 ) -// Note: API returns full Contractor objects for list endpoints -// ContractorSummary kept for backward compatibility +// Extension to convert full Contractor to ContractorSummary +fun Contractor.toSummary() = ContractorSummary( + id = id, + residenceId = residenceId, + name = name, + company = company, + phone = phone, + specialties = specialties, + rating = rating, + isFavorite = isFavorite, + taskCount = taskCount +) 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 a932184..7ff15f3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -248,12 +248,10 @@ object APILayer { // ==================== Residence Operations ==================== suspend fun getResidences(forceRefresh: Boolean = false): ApiResult> { - // Check DataManager first - if (!forceRefresh) { - val cached = DataManager.residences.value - if (cached.isNotEmpty()) { - return ApiResult.Success(cached) - } + // Check DataManager first - return cached if valid and not forcing refresh + // Cache is valid even if empty (user has no residences) + if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) { + return ApiResult.Success(DataManager.residences.value) } // Fetch from API @@ -269,8 +267,8 @@ object APILayer { } suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) { val cached = DataManager.myResidences.value if (cached != null) { return ApiResult.Success(cached) @@ -290,8 +288,8 @@ object APILayer { } suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) { val cached = DataManager.residences.value.find { it.id == id } if (cached != null) { return ApiResult.Success(cached) @@ -310,9 +308,27 @@ object APILayer { return result } - suspend fun getResidenceSummary(): ApiResult { + /** + * Get total summary (task counts across all residences). + * This is a lightweight endpoint for refreshing summary counts. + */ + suspend fun getSummary(forceRefresh: Boolean = false): ApiResult { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.summaryCacheTime)) { + val cached = DataManager.totalSummary.value + if (cached != null) { + return ApiResult.Success(cached) + } + } + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - return residenceApi.getResidenceSummary(token) + val result = residenceApi.getSummary(token) + + if (result is ApiResult.Success) { + DataManager.setTotalSummary(result.data) + } + + return result } suspend fun createResidence(request: ResidenceCreateRequest): ApiResult { @@ -397,8 +413,8 @@ object APILayer { // ==================== Task Operations ==================== suspend fun getTasks(forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) { val cached = DataManager.allTasks.value if (cached != null) { return ApiResult.Success(cached) @@ -418,8 +434,8 @@ object APILayer { } suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) { val cached = DataManager.tasksByResidence.value[residenceId] if (cached != null) { return ApiResult.Success(cached) @@ -548,6 +564,8 @@ object APILayer { result.data.updatedTask?.let { updatedTask -> DataManager.updateTask(updatedTask) } + // Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call + refreshSummary() } return result @@ -566,6 +584,8 @@ object APILayer { result.data.updatedTask?.let { updatedTask -> DataManager.updateTask(updatedTask) } + // Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call + refreshSummary() } return result @@ -596,12 +616,10 @@ object APILayer { contractorId != null || isActive != null || expiringSoon != null || tags != null || search != null - // Check DataManager first if no filters - if (!forceRefresh && !hasFilters) { - val cached = DataManager.documents.value - if (cached.isNotEmpty()) { - return ApiResult.Success(cached) - } + // Check DataManager first if no filters - return cached if valid and not forcing refresh + // Cache is valid even if empty (user has no documents) + if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) { + return ApiResult.Success(DataManager.documents.value) } // Fetch from API @@ -620,8 +638,8 @@ object APILayer { } suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { + // Check DataManager first - return cached if valid and not forcing refresh + if (!forceRefresh && DataManager.isCacheValid(DataManager.documentsCacheTime)) { val cached = DataManager.documents.value.find { it.id == id } if (cached != null) { return ApiResult.Success(cached) @@ -764,25 +782,32 @@ object APILayer { search: String? = null, forceRefresh: Boolean = false ): ApiResult> { - // 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) - } + val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null - suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - if (!forceRefresh) { - val cached = DataManager.contractors.value.find { it.id == id } - if (cached != null) { - return ApiResult.Success(cached) - } + // Check cache first (only if no filters applied) - return cached if valid and not forcing refresh + // Cache is valid even if empty (user has no contractors) + if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.contractorsCacheTime)) { + return ApiResult.Success(DataManager.contractors.value) } // Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = contractorApi.getContractors(token, specialty, isFavorite, isActive, search) + + // Update DataManager on success (only for unfiltered results) + if (result is ApiResult.Success && !hasFilters) { + DataManager.setContractors(result.data) + } + + return result + } + + suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult { + // Fetch from API (summaries don't have full detail, always fetch) + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = contractorApi.getContractor(token, id) - // Update DataManager on success + // Update the summary in DataManager on success if (result is ApiResult.Success) { DataManager.updateContractor(result.data) } @@ -1030,6 +1055,13 @@ object APILayer { getMyResidences(forceRefresh = true) } + /** + * Refresh just the summary counts (lightweight) + */ + private suspend fun refreshSummary() { + getSummary(forceRefresh = true) + } + /** * Prefetch all data after login */ 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 a19e50e..45cffa3 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.DEV + val CURRENT_ENV = Environment.LOCAL enum class Environment { LOCAL, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt index 42a00dc..5c77df0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ResidenceApi.kt @@ -93,7 +93,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getResidenceSummary(token: String): ApiResult { + suspend fun getSummary(token: String): ApiResult { return try { val response = client.get("$baseUrl/residences/summary/") { header("Authorization", "Token $token") @@ -102,7 +102,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { if (response.status.isSuccess()) { ApiResult.Success(response.body()) } else { - ApiResult.Error("Failed to fetch residence summary", response.status.value) + ApiResult.Error("Failed to fetch summary", response.status.value) } } catch (e: Exception) { ApiResult.Error(e.message ?: "Unknown error occurred") diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt index 2a1b20f..315c5f3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.casera.models.* import com.example.casera.network.ApiResult -import com.example.casera.network.AuthApi +import com.example.casera.network.APILayer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -19,7 +19,6 @@ enum class PasswordResetStep { class PasswordResetViewModel( private val deepLinkToken: String? = null ) : ViewModel() { - private val authApi = AuthApi() private val _forgotPasswordState = MutableStateFlow>(ApiResult.Idle) val forgotPasswordState: StateFlow> = _forgotPasswordState @@ -48,7 +47,7 @@ class PasswordResetViewModel( fun requestPasswordReset(email: String) { viewModelScope.launch { _forgotPasswordState.value = ApiResult.Loading - val result = authApi.forgotPassword(ForgotPasswordRequest(email)) + val result = APILayer.forgotPassword(ForgotPasswordRequest(email)) _forgotPasswordState.value = when (result) { is ApiResult.Success -> { _email.value = email @@ -66,7 +65,7 @@ class PasswordResetViewModel( fun verifyResetCode(email: String, code: String) { viewModelScope.launch { _verifyCodeState.value = ApiResult.Loading - val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code)) + val result = APILayer.verifyResetCode(VerifyResetCodeRequest(email, code)) _verifyCodeState.value = when (result) { is ApiResult.Success -> { _resetToken.value = result.data.resetToken @@ -91,7 +90,7 @@ class PasswordResetViewModel( viewModelScope.launch { _resetPasswordState.value = ApiResult.Loading // Note: confirmPassword is for UI validation only, not sent to API - val result = authApi.resetPassword( + val result = APILayer.resetPassword( ResetPasswordRequest( resetToken = token, newPassword = newPassword diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt index 472aa30..3a780aa 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.casera.models.Residence import com.example.casera.models.ResidenceCreateRequest -import com.example.casera.models.ResidenceSummaryResponse +import com.example.casera.models.TotalSummary import com.example.casera.models.MyResidencesResponse import com.example.casera.models.TaskColumnsResponse import com.example.casera.models.ContractorSummary @@ -19,8 +19,8 @@ class ResidenceViewModel : ViewModel() { private val _residencesState = MutableStateFlow>>(ApiResult.Idle) val residencesState: StateFlow>> = _residencesState - private val _residenceSummaryState = MutableStateFlow>(ApiResult.Idle) - val residenceSummaryState: StateFlow> = _residenceSummaryState + private val _summaryState = MutableStateFlow>(ApiResult.Idle) + val summaryState: StateFlow> = _summaryState private val _createResidenceState = MutableStateFlow>(ApiResult.Idle) val createResidenceState: StateFlow> = _createResidenceState @@ -63,10 +63,10 @@ class ResidenceViewModel : ViewModel() { } } - fun loadResidenceSummary() { + fun loadSummary(forceRefresh: Boolean = false) { viewModelScope.launch { - _residenceSummaryState.value = ApiResult.Loading - _residenceSummaryState.value = APILayer.getResidenceSummary() + _summaryState.value = ApiResult.Loading + _summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh) } } 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 209ed98..3236aa9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/TaskCompletionViewModel.kt @@ -2,31 +2,25 @@ 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.models.TaskCompletionResponse import com.example.casera.network.ApiResult -import com.example.casera.network.TaskCompletionApi +import com.example.casera.network.APILayer import com.example.casera.util.ImageCompressor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class TaskCompletionViewModel : ViewModel() { - private val taskCompletionApi = TaskCompletionApi() - private val _createCompletionState = MutableStateFlow>(ApiResult.Idle) - val createCompletionState: StateFlow> = _createCompletionState + private val _createCompletionState = MutableStateFlow>(ApiResult.Idle) + val createCompletionState: StateFlow> = _createCompletionState fun createTaskCompletion(request: TaskCompletionCreateRequest) { viewModelScope.launch { _createCompletionState.value = ApiResult.Loading - val token = DataManager.authToken.value - if (token != null) { - _createCompletionState.value = taskCompletionApi.createCompletion(token, request) - } else { - _createCompletionState.value = ApiResult.Error("Not authenticated", 401) - } + // Use APILayer which handles DataManager updates and summary refresh + _createCompletionState.value = APILayer.createTaskCompletion(request) } } @@ -42,31 +36,27 @@ class TaskCompletionViewModel : ViewModel() { ) { viewModelScope.launch { _createCompletionState.value = ApiResult.Loading - val token = DataManager.authToken.value - if (token != null) { - // Compress images and prepare for upload - val compressedImages = images.map { ImageCompressor.compressImage(it) } - val imageFileNames = images.mapIndexed { index, image -> - // Always use .jpg extension since we compress to JPEG - val baseName = image.fileName.ifBlank { "completion_$index" } - if (baseName.endsWith(".jpg", ignoreCase = true) || - baseName.endsWith(".jpeg", ignoreCase = true)) { - baseName - } else { - // Remove any existing extension and add .jpg - baseName.substringBeforeLast('.', baseName) + ".jpg" - } - } - _createCompletionState.value = taskCompletionApi.createCompletionWithImages( - token = token, - request = request, - images = compressedImages, - imageFileNames = imageFileNames - ) - } else { - _createCompletionState.value = ApiResult.Error("Not authenticated", 401) + // Compress images and prepare for upload + val compressedImages = images.map { ImageCompressor.compressImage(it) } + val imageFileNames = images.mapIndexed { index, image -> + // Always use .jpg extension since we compress to JPEG + val baseName = image.fileName.ifBlank { "completion_$index" } + if (baseName.endsWith(".jpg", ignoreCase = true) || + baseName.endsWith(".jpeg", ignoreCase = true)) { + baseName + } else { + // Remove any existing extension and add .jpg + baseName.substringBeforeLast('.', baseName) + ".jpg" + } } + + // Use APILayer which handles DataManager updates and summary refresh + _createCompletionState.value = APILayer.createTaskCompletionWithImages( + request = request, + images = compressedImages, + imageFileNames = imageFileNames + ) } } diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift index 5b69d4f..445a010 100644 --- a/iosApp/iosApp/Contractor/ContractorViewModel.swift +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -2,6 +2,9 @@ import Foundation import ComposeApp import Combine +/// ViewModel for contractor management. +/// Observes DataManagerObservable for contractors list (automatically updated after mutations). +/// Calls APILayer for operations - DataManager updates propagate automatically via observation. @MainActor class ContractorViewModel: ObservableObject { // MARK: - Published Properties @@ -15,145 +18,168 @@ class ContractorViewModel: ObservableObject { @Published var successMessage: String? // MARK: - Private Properties - private let sharedViewModel: ComposeApp.ContractorViewModel + private var cancellables = Set() // MARK: - Initialization - init(sharedViewModel: ComposeApp.ContractorViewModel? = nil) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.ContractorViewModel() + + init() { + // Observe contractors from DataManagerObservable + DataManagerObservable.shared.$contractors + .receive(on: DispatchQueue.main) + .sink { [weak self] contractors in + self?.contractors = contractors + } + .store(in: &cancellables) } // MARK: - Public Methods - func loadContractors( - specialty: String? = nil, - isFavorite: Bool? = nil, - isActive: Bool? = nil, - search: String? = nil, - forceRefresh: Bool = false - ) { + + /// Load contractors list - delegates to APILayer which handles cache timeout + func loadContractors(forceRefresh: Bool = false) { isLoading = true errorMessage = nil - sharedViewModel.loadContractors( - specialty: specialty, - isFavorite: isFavorite.asKotlin, - isActive: isActive.asKotlin, - search: search, - forceRefresh: forceRefresh - ) + Task { + do { + let result = try await APILayer.shared.getContractors( + specialty: nil, + isFavorite: nil, + isActive: nil, + search: nil, + forceRefresh: forceRefresh + ) - StateFlowObserver.observe( - sharedViewModel.contractorsState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (data: NSArray) in - self?.contractors = data as? [ContractorSummary] ?? [] - self?.isLoading = false - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false + // API updates DataManager on success, which triggers our observation + if result is ApiResultSuccess { + self.isLoading = false + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } - ) + } } func loadContractorDetail(id: Int32) { isLoading = true errorMessage = nil - sharedViewModel.loadContractorDetail(id: id) + Task { + do { + let result = try await APILayer.shared.getContractor(id: id, forceRefresh: false) - StateFlowObserver.observeWithState( - sharedViewModel.contractorDetailState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - onSuccess: { [weak self] (data: Contractor) in - self?.selectedContractor = data + if let success = result as? ApiResultSuccess { + self.selectedContractor = success.data + self.isLoading = false + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } - ) + } } func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) { isCreating = true errorMessage = nil - sharedViewModel.createContractor(request: request) + Task { + do { + let result = try await APILayer.shared.createContractor(request: request) - StateFlowObserver.observe( - sharedViewModel.createState, - onLoading: { [weak self] in self?.isCreating = true }, - onSuccess: { [weak self] (_: Contractor) in - self?.successMessage = "Contractor added successfully" - self?.isCreating = false - completion(true) - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isCreating = false + if result is ApiResultSuccess { + self.successMessage = "Contractor added successfully" + self.isCreating = false + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isCreating = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isCreating = false completion(false) - }, - resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } - ) + } + } } func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) { isUpdating = true errorMessage = nil - sharedViewModel.updateContractor(id: id, request: request) + Task { + do { + let result = try await APILayer.shared.updateContractor(id: id, request: request) - StateFlowObserver.observe( - sharedViewModel.updateState, - onLoading: { [weak self] in self?.isUpdating = true }, - onSuccess: { [weak self] (_: Contractor) in - self?.successMessage = "Contractor updated successfully" - self?.isUpdating = false - completion(true) - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isUpdating = false + if result is ApiResultSuccess { + self.successMessage = "Contractor updated successfully" + self.isUpdating = false + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isUpdating = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isUpdating = false completion(false) - }, - resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } - ) + } + } } func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) { isDeleting = true errorMessage = nil - sharedViewModel.deleteContractor(id: id) + Task { + do { + let result = try await APILayer.shared.deleteContractor(id: id) - StateFlowObserver.observe( - sharedViewModel.deleteState, - onLoading: { [weak self] in self?.isDeleting = true }, - onSuccess: { [weak self] (_: KotlinUnit) in - self?.successMessage = "Contractor deleted successfully" - self?.isDeleting = false - completion(true) - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isDeleting = false + if result is ApiResultSuccess { + self.successMessage = "Contractor deleted successfully" + self.isDeleting = false + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isDeleting = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isDeleting = false completion(false) - }, - resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } - ) + } + } } func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) { - sharedViewModel.toggleFavorite(id: id) + Task { + do { + let result = try await APILayer.shared.toggleFavorite(id: id) - StateFlowObserver.observe( - sharedViewModel.toggleFavoriteState, - onSuccess: { (_: Contractor) in - completion(true) - }, - onError: { [weak self] error in - self?.errorMessage = error + if result is ApiResultSuccess { + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription completion(false) - }, - resetState: { [weak self] in self?.sharedViewModel.resetToggleFavoriteState() } - ) + } + } } func clearMessages() { @@ -161,4 +187,3 @@ class ContractorViewModel: ObservableObject { successMessage = nil } } - diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 4dde7ed..a08ff40 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -178,7 +178,7 @@ struct ContractorsListView: View { private func loadContractors(forceRefresh: Bool = false) { // Load all contractors, filtering is done client-side - viewModel.loadContractors() + viewModel.loadContractors(forceRefresh: forceRefresh) } private func loadContractorSpecialties() { diff --git a/iosApp/iosApp/Core/AsyncContentView.swift b/iosApp/iosApp/Core/AsyncContentView.swift index e040ed8..163425e 100644 --- a/iosApp/iosApp/Core/AsyncContentView.swift +++ b/iosApp/iosApp/Core/AsyncContentView.swift @@ -223,11 +223,17 @@ struct ListAsyncContentView: View { var body: some View { Group { if let errorMessage = errorMessage, items.isEmpty { - DefaultErrorView(message: errorMessage, onRetry: onRetry) - .frame(maxWidth: .infinity, maxHeight: .infinity) + // Wrap in ScrollView for pull-to-refresh support + ScrollView { + DefaultErrorView(message: errorMessage, onRetry: onRetry) + .frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6) + } } else if items.isEmpty && !isLoading { - emptyContent() - .frame(maxWidth: .infinity, maxHeight: .infinity) + // Wrap in ScrollView for pull-to-refresh support + ScrollView { + emptyContent() + .frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6) + } } else { content(items) } diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index 0c19409..a066088 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -35,6 +35,7 @@ class DataManagerObservable: ObservableObject { @Published var residences: [ResidenceResponse] = [] @Published var myResidences: MyResidencesResponse? + @Published var totalSummary: TotalSummary? @Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:] // MARK: - Tasks @@ -49,7 +50,7 @@ class DataManagerObservable: ObservableObject { // MARK: - Contractors - @Published var contractors: [Contractor] = [] + @Published var contractors: [ContractorSummary] = [] // MARK: - Subscription @@ -138,6 +139,16 @@ class DataManagerObservable: ObservableObject { } observationTasks.append(myResidencesTask) + // TotalSummary + let totalSummaryTask = Task { + for await summary in DataManager.shared.totalSummary { + await MainActor.run { + self.totalSummary = summary + } + } + } + observationTasks.append(totalSummaryTask) + // ResidenceSummaries let residenceSummariesTask = Task { for await summaries in DataManager.shared.residenceSummaries { @@ -338,26 +349,35 @@ class DataManagerObservable: ObservableObject { // MARK: - Map Conversion Helpers /// Convert Kotlin Map to Swift [Int32: V] - private func convertIntMap(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] { + private func convertIntMap(_ kotlinMap: Any?) -> [Int32: V] { + guard let map = kotlinMap as? [KotlinInt: V] else { + return [:] + } var result: [Int32: V] = [:] - for (key, value) in kotlinMap { + for (key, value) in map { result[key.int32Value] = value } return result } /// Convert Kotlin Map> to Swift [Int32: [V]] - private func convertIntArrayMap(_ kotlinMap: [KotlinInt: [V]]) -> [Int32: [V]] { + private func convertIntArrayMap(_ kotlinMap: Any?) -> [Int32: [V]] { + guard let map = kotlinMap as? [KotlinInt: [V]] else { + return [:] + } var result: [Int32: [V]] = [:] - for (key, value) in kotlinMap { + for (key, value) in map { result[key.int32Value] = value } return result } /// Convert Kotlin Map to Swift [String: V] - private func convertStringMap(_ kotlinMap: [String: V]) -> [String: V] { - return kotlinMap + private func convertStringMap(_ kotlinMap: Any?) -> [String: V] { + guard let map = kotlinMap as? [String: V] else { + return [:] + } + return map } // MARK: - Convenience Lookup Methods diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift index 47e14a8..16cce2f 100644 --- a/iosApp/iosApp/Documents/DocumentViewModel.swift +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -3,16 +3,26 @@ import UIKit import ComposeApp import Combine +/// ViewModel for document management. +/// Observes DataManagerObservable for documents list. +/// Calls APILayer directly for all operations. @MainActor class DocumentViewModel: ObservableObject { @Published var documents: [Document] = [] @Published var isLoading = false @Published var errorMessage: String? - private let sharedViewModel: ComposeApp.DocumentViewModel + // MARK: - Private Properties + private var cancellables = Set() - init(sharedViewModel: ComposeApp.DocumentViewModel? = nil) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.DocumentViewModel() + init() { + // Observe documents from DataManagerObservable + DataManagerObservable.shared.$documents + .receive(on: DispatchQueue.main) + .sink { [weak self] documents in + self?.documents = documents + } + .store(in: &cancellables) } func loadDocuments( @@ -29,30 +39,32 @@ class DocumentViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.loadDocuments( - residenceId: residenceId.asKotlin, - documentType: documentType, - category: category, - contractorId: contractorId.asKotlin, - isActive: isActive.asKotlin, - expiringSoon: expiringSoon.asKotlin, - tags: tags, - search: search, - forceRefresh: forceRefresh - ) + Task { + do { + let result = try await APILayer.shared.getDocuments( + residenceId: residenceId.asKotlin, + documentType: documentType, + category: category, + contractorId: contractorId.asKotlin, + isActive: isActive.asKotlin, + expiringSoon: expiringSoon.asKotlin, + tags: tags, + search: search, + forceRefresh: forceRefresh + ) - StateFlowObserver.observe( - sharedViewModel.documentsState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (data: NSArray) in - self?.documents = data as? [Document] ?? [] - self?.isLoading = false - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false + // API updates DataManager on success, which triggers our observation + if result is ApiResultSuccess { + self.isLoading = false + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } - ) + } } func createDocument( @@ -82,53 +94,52 @@ class DocumentViewModel: ObservableObject { isLoading = true errorMessage = nil - // Convert UIImages to ImageData - var imageDataList: [Any] = [] - for (index, image) in images.enumerated() { - if let jpegData = image.jpegData(compressionQuality: 0.8) { - // This would need platform-specific ImageData implementation - // For now, skip image conversion - would need to be handled differently + Task { + do { + let result = try await APILayer.shared.createDocument( + title: title, + documentType: documentType, + residenceId: residenceId, + description: description, + category: category, + tags: tags, + notes: notes, + contractorId: contractorId.asKotlin, + isActive: isActive, + itemName: itemName, + modelNumber: modelNumber, + serialNumber: serialNumber, + provider: provider, + providerContact: providerContact, + claimPhone: claimPhone, + claimEmail: claimEmail, + claimWebsite: claimWebsite, + purchaseDate: purchaseDate, + startDate: startDate, + endDate: endDate, + fileBytes: nil, + fileName: nil, + mimeType: nil, + fileBytesList: nil, + fileNamesList: nil, + mimeTypesList: nil + ) + + if result is ApiResultSuccess { + self.isLoading = false + // DataManager is updated by APILayer, view updates via observation + completion(true, nil) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false, self.errorMessage) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false, self.errorMessage) } } - - sharedViewModel.createDocument( - title: title, - documentType: documentType, - residenceId: residenceId, - description: description, - category: category, - tags: tags, - notes: notes, - contractorId: contractorId.asKotlin, - isActive: isActive, - itemName: itemName, - modelNumber: modelNumber, - serialNumber: serialNumber, - provider: provider, - providerContact: providerContact, - claimPhone: claimPhone, - claimEmail: claimEmail, - claimWebsite: claimWebsite, - purchaseDate: purchaseDate, - startDate: startDate, - endDate: endDate, - images: [] // Image handling needs platform-specific implementation - ) - - StateFlowObserver.observe( - sharedViewModel.createState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (_: Document) in - self?.isLoading = false - completion(true, nil) - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false - completion(false, error) - }, - resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } - ) } func updateDocument( @@ -157,65 +168,77 @@ class DocumentViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.updateDocument( - id: id, - title: title, - documentType: "", // Required but not changing - description: description, - category: category, - tags: tags, - notes: notes, - contractorId: contractorId.asKotlin, - isActive: isActive, - itemName: itemName, - modelNumber: modelNumber, - serialNumber: serialNumber, - provider: provider, - providerContact: providerContact, - claimPhone: claimPhone, - claimEmail: claimEmail, - claimWebsite: claimWebsite, - purchaseDate: purchaseDate, - startDate: startDate, - endDate: endDate, - images: [] // Image handling needs platform-specific implementation - ) + Task { + do { + let result = try await APILayer.shared.updateDocument( + id: id, + title: title, + documentType: "", // Required but not changing + description: description, + category: category, + tags: tags, + notes: notes, + contractorId: contractorId.asKotlin, + isActive: isActive, + itemName: itemName, + modelNumber: modelNumber, + serialNumber: serialNumber, + provider: provider, + providerContact: providerContact, + claimPhone: claimPhone, + claimEmail: claimEmail, + claimWebsite: claimWebsite, + purchaseDate: purchaseDate, + startDate: startDate, + endDate: endDate + ) - StateFlowObserver.observe( - sharedViewModel.updateState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (_: Document) in - self?.isLoading = false - completion(true, nil) - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false - completion(false, error) - }, - resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } - ) + if result is ApiResultSuccess { + self.isLoading = false + // DataManager is updated by APILayer, view updates via observation + completion(true, nil) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false, self.errorMessage) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false, self.errorMessage) + } + } } - func deleteDocument(id: Int32) { + func deleteDocument(id: Int32, completion: @escaping (Bool) -> Void = { _ in }) { isLoading = true errorMessage = nil - sharedViewModel.deleteDocument(id: id) + Task { + do { + let result = try await APILayer.shared.deleteDocument(id: id) - StateFlowObserver.observeWithState( - sharedViewModel.deleteState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - onSuccess: { (_: KotlinUnit) in }, - resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } - ) + if result is ApiResultSuccess { + self.isLoading = false + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } } func downloadDocument(url: String) -> Task { return Task { do { - let result = try await sharedViewModel.downloadDocument(url: url) + let result = try await APILayer.shared.downloadDocument(url: url) if let success = result as? ApiResultSuccess, let byteArray = success.data { // Convert Kotlin ByteArray to Swift Data diff --git a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift index aae7df0..66f1e4d 100644 --- a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift +++ b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift @@ -56,8 +56,6 @@ class DocumentViewModelWrapper: ObservableObject { @Published var deleteState: DeleteState = DeleteStateIdle() @Published var deleteImageState: DeleteImageState = DeleteImageStateIdle() - private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient()) - func loadDocuments( residenceId: Int32? = nil, documentType: String? = nil, @@ -68,29 +66,22 @@ class DocumentViewModelWrapper: ObservableObject { tags: String? = nil, search: String? = nil ) { - guard let token = TokenStorage.shared.getToken() else { - DispatchQueue.main.async { - self.documentsState = DocumentStateError(message: "Not authenticated") - } - return - } - DispatchQueue.main.async { self.documentsState = DocumentStateLoading() } Task { do { - let result = try await documentApi.getDocuments( - token: token, - residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil, + let result = try await APILayer.shared.getDocuments( + residenceId: residenceId != nil ? KotlinInt(int: residenceId!) : nil, documentType: documentType, category: category, - contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, + contractorId: contractorId != nil ? KotlinInt(int: contractorId!) : nil, isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil, - expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil, + expiringSoon: expiringSoon != nil ? KotlinInt(int: expiringSoon!) : nil, tags: tags, - search: search + search: search, + forceRefresh: false ) await MainActor.run { @@ -110,20 +101,13 @@ class DocumentViewModelWrapper: ObservableObject { } func loadDocumentDetail(id: Int32) { - guard let token = TokenStorage.shared.getToken() else { - DispatchQueue.main.async { - self.documentDetailState = DocumentDetailStateError(message: "Not authenticated") - } - return - } - DispatchQueue.main.async { self.documentDetailState = DocumentDetailStateLoading() } Task { do { - let result = try await documentApi.getDocument(token: token, id: id) + let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false) await MainActor.run { if let success = result as? ApiResultSuccess, let document = success.data { @@ -161,21 +145,13 @@ class DocumentViewModelWrapper: ObservableObject { startDate: String? = nil, endDate: String? = nil ) { - guard let token = TokenStorage.shared.getToken() else { - DispatchQueue.main.async { - self.updateState = UpdateStateError(message: "Not authenticated") - } - return - } - DispatchQueue.main.async { self.updateState = UpdateStateLoading() } Task { do { - let result = try await documentApi.updateDocument( - token: token, + let result = try await APILayer.shared.updateDocument( id: id, title: title, documentType: documentType, @@ -184,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject { tags: tags, notes: notes, contractorId: nil, - isActive: KotlinBoolean(bool: isActive), + isActive: isActive, itemName: itemName, modelNumber: modelNumber, serialNumber: serialNumber, @@ -195,10 +171,7 @@ class DocumentViewModelWrapper: ObservableObject { claimWebsite: claimWebsite, purchaseDate: purchaseDate, startDate: startDate, - endDate: endDate, - fileBytes: nil, - fileName: nil, - mimeType: nil + endDate: endDate ) await MainActor.run { @@ -219,20 +192,13 @@ class DocumentViewModelWrapper: ObservableObject { } func deleteDocument(id: Int32) { - guard let token = TokenStorage.shared.getToken() else { - DispatchQueue.main.async { - self.deleteState = DeleteStateError(message: "Not authenticated") - } - return - } - DispatchQueue.main.async { self.deleteState = DeleteStateLoading() } Task { do { - let result = try await documentApi.deleteDocument(token: token, id: id) + let result = try await APILayer.shared.deleteDocument(id: id) await MainActor.run { if result is ApiResultSuccess { @@ -262,20 +228,13 @@ class DocumentViewModelWrapper: ObservableObject { } func deleteDocumentImage(imageId: Int32) { - guard let token = TokenStorage.shared.getToken() else { - DispatchQueue.main.async { - self.deleteImageState = DeleteImageStateError(message: "Not authenticated") - } - return - } - DispatchQueue.main.async { self.deleteImageState = DeleteImageStateLoading() } Task { do { - let result = try await documentApi.deleteDocumentImage(token: token, imageId: imageId) + let result = try await APILayer.shared.deleteDocumentImage(imageId: imageId) await MainActor.run { if result is ApiResultSuccess { diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 17fdd8e..1effdde 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -20,12 +20,32 @@ struct DocumentsWarrantiesView: View { let residenceId: Int32? + // Client-side filtering for warranties tab var warranties: [Document] { - documentViewModel.documents.filter { $0.documentType == "warranty" } + documentViewModel.documents.filter { doc in + guard doc.documentType == "warranty" else { return false } + // Apply active filter if enabled + if showActiveOnly && doc.isActive != true { + return false + } + // Apply category filter if selected + if let category = selectedCategory, doc.category != category { + return false + } + return true + } } + // Client-side filtering for documents tab var documents: [Document] { - documentViewModel.documents.filter { $0.documentType != "warranty" } + documentViewModel.documents.filter { doc in + guard doc.documentType != "warranty" else { return false } + // Apply document type filter if selected + if let docType = selectedDocType, doc.documentType != docType { + return false + } + return true + } } // Check if upgrade screen should be shown (disables add button) @@ -104,23 +124,21 @@ struct DocumentsWarrantiesView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: AppSpacing.sm) { - // Active Filter (for warranties) + // Active Filter (for warranties) - client-side, no API call if selectedTab == .warranties { Button(action: { showActiveOnly.toggle() - loadWarranties() }) { Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle") .foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary) } } - // Filter Menu + // Filter Menu - client-side filtering, no API calls Menu { if selectedTab == .warranties { Button(action: { selectedCategory = nil - loadWarranties() }) { Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "") } @@ -130,7 +148,6 @@ struct DocumentsWarrantiesView: View { ForEach(DocumentCategory.allCases, id: \.self) { category in Button(action: { selectedCategory = category.displayName - loadWarranties() }) { Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "") } @@ -138,7 +155,6 @@ struct DocumentsWarrantiesView: View { } else { Button(action: { selectedDocType = nil - loadDocuments() }) { Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "") } @@ -148,7 +164,6 @@ struct DocumentsWarrantiesView: View { ForEach(DocumentType.allCases, id: \.self) { type in Button(action: { selectedDocType = type.displayName - loadDocuments() }) { Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "") } @@ -177,16 +192,10 @@ struct DocumentsWarrantiesView: View { } } .onAppear { - loadWarranties() - loadDocuments() - } - .onChange(of: selectedTab) { _ in - if selectedTab == .warranties { - loadWarranties() - } else { - loadDocuments() - } + // Load all documents once - filtering is client-side + loadAllDocuments() } + // No need for onChange on selectedTab - filtering is client-side .sheet(isPresented: $showAddSheet) { AddDocumentView( residenceId: residenceId, @@ -200,20 +209,20 @@ struct DocumentsWarrantiesView: View { } } + private func loadAllDocuments(forceRefresh: Bool = false) { + // Load all documents without filters to use cache + // Filtering is done client-side in the computed properties + documentViewModel.loadDocuments(forceRefresh: forceRefresh) + } + private func loadWarranties() { - documentViewModel.loadDocuments( - residenceId: residenceId, - documentType: "warranty", - category: selectedCategory, - isActive: showActiveOnly ? true : nil - ) + // Just reload all - filtering happens client-side + loadAllDocuments() } private func loadDocuments() { - documentViewModel.loadDocuments( - residenceId: residenceId, - documentType: selectedDocType - ) + // Just reload all - filtering happens client-side + loadAllDocuments() } } diff --git a/iosApp/iosApp/Login/AppleSignInViewModel.swift b/iosApp/iosApp/Login/AppleSignInViewModel.swift index 7a0ac82..df6c016 100644 --- a/iosApp/iosApp/Login/AppleSignInViewModel.swift +++ b/iosApp/iosApp/Login/AppleSignInViewModel.swift @@ -2,8 +2,8 @@ import Foundation import ComposeApp import Combine -/// ViewModel for handling Apple Sign In flow -/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin) +/// ViewModel for handling Apple Sign In flow. +/// Calls APILayer directly for backend authentication. @MainActor class AppleSignInViewModel: ObservableObject { // MARK: - Published Properties @@ -13,21 +13,10 @@ class AppleSignInViewModel: ObservableObject { // MARK: - Private Properties private let appleSignInManager = AppleSignInManager() - private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorageProtocol // MARK: - Callbacks var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified - // MARK: - Initialization - init( - sharedViewModel: ComposeApp.AuthViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() - self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() - } - // MARK: - Public Methods /// Initiates the Apple Sign In flow @@ -58,70 +47,43 @@ class AppleSignInViewModel: ObservableObject { /// Sends Apple credential to backend for verification/authentication private func sendCredentialToBackend(_ credential: AppleSignInCredential) { - sharedViewModel.appleSignIn( - idToken: credential.identityToken, - userId: credential.userIdentifier, - email: credential.email, - firstName: credential.firstName, - lastName: credential.lastName - ) - - // Observe the result Task { - for await state in sharedViewModel.appleSignInState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.handleSuccess(success.data) - } - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.handleBackendError(error) - } - break + do { + let request = AppleSignInRequest( + idToken: credential.identityToken, + userId: credential.userIdentifier, + email: credential.email, + firstName: credential.firstName, + lastName: credential.lastName + ) + let result = try await APILayer.shared.appleSignIn(request: request) + + if let success = result as? ApiResultSuccess, let response = success.data { + self.handleSuccess(response) + } else if let error = result as? ApiResultError { + self.handleBackendError(error) } + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription } } } /// Handles successful authentication - private func handleSuccess(_ response: AppleSignInResponse?) { + private func handleSuccess(_ response: AppleSignInResponse) { isLoading = false - guard let response = response, - let token = response.token as String? else { - errorMessage = "Invalid response from server" - return - } - let user = response.user - // Store the token - tokenStorage.saveToken(token: token) - // Track if this is a new user isNewUser = response.isNewUser - // Initialize lookups - Task { - _ = try? await APILayer.shared.initializeLookups() - } - - // Prefetch data - Task { - do { - print("Starting data prefetch after Apple Sign In...") - let prefetchManager = DataPrefetchManager.Companion().getInstance() - _ = try await prefetchManager.prefetchAllData() - print("Data prefetch completed successfully") - } catch { - print("Data prefetch failed: \(error.localizedDescription)") - } - } + // APILayer.appleSignIn already: + // - Stores token in DataManager + // - Sets current user in DataManager + // - Initializes lookups + // - Prefetches all data print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)") @@ -147,7 +109,6 @@ class AppleSignInViewModel: ObservableObject { /// Handles backend API errors private func handleBackendError(_ error: ApiResultError) { isLoading = false - sharedViewModel.resetAppleSignInState() if let code = error.code?.intValue { switch code { diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 846de5f..16ca740 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -2,32 +2,46 @@ import Foundation import ComposeApp import Combine +/// ViewModel for user login. +/// Observes DataManagerObservable for authentication state. +/// Kicks off API calls that update DataManager, letting views react to cache updates. @MainActor class LoginViewModel: ObservableObject { - // MARK: - Published Properties + // MARK: - Published Properties (from DataManager observation) + @Published var currentUser: User? + @Published var isAuthenticated: Bool = false + + // MARK: - Local State @Published var username: String = "" @Published var password: String = "" @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var isVerified: Bool = false - @Published var currentUser: User? - - // MARK: - Private Properties - private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorageProtocol // Callback for successful login var onLoginSuccess: ((Bool) -> Void)? + // MARK: - Private Properties + private var cancellables = Set() + // MARK: - Initialization - init( - sharedViewModel: ComposeApp.AuthViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() - self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() + init() { + // Observe DataManagerObservable for authentication state + DataManagerObservable.shared.$currentUser + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + self?.currentUser = user + } + .store(in: &cancellables) + + DataManagerObservable.shared.$isAuthenticated + .receive(on: DispatchQueue.main) + .sink { [weak self] isAuth in + self?.isAuthenticated = isAuth + } + .store(in: &cancellables) } - + // MARK: - Public Methods func login() { guard !username.isEmpty else { @@ -43,175 +57,94 @@ class LoginViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.login(username: username, password: password) - Task { - for await state in sharedViewModel.loginState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - if let token = success.data?.token, - let user = success.data?.user { - self.tokenStorage.saveToken(token: token) + do { + let result = try await APILayer.shared.login( + request: LoginRequest(username: username, password: password) + ) - // Store user data and verification status - self.currentUser = user - self.isVerified = user.verified - self.isLoading = false + if let success = result as? ApiResultSuccess, + let response = success.data { + // APILayer.login already stores token in DataManager + // currentUser will be updated via DataManagerObservable observation + self.isVerified = response.user.verified + self.isLoading = false - print("Login successful! Token: token") - print("User: \(user.username), Verified: \(user.verified)") - print("isVerified set to: \(self.isVerified)") + print("Login successful!") + print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)") - // Initialize lookups via APILayer - Task { - _ = try? await APILayer.shared.initializeLookups() - } - - // Prefetch all data for caching - Task { - do { - print("Starting data prefetch...") - let prefetchManager = DataPrefetchManager.Companion().getInstance() - _ = try await prefetchManager.prefetchAllData() - print("Data prefetch completed successfully") - } catch { - print("Data prefetch failed: \(error.localizedDescription)") - // Don't block login on prefetch failure - } - } - - // Call login success callback - self.onLoginSuccess?(user.verified) - } - } - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.isLoading = false - - // Check for specific error codes and provide user-friendly messages - if let code = error.code?.intValue { - switch code { - case 400, 401: - self.errorMessage = "Invalid username or password" - case 403: - self.errorMessage = "Access denied. Please check your credentials." - case 404: - self.errorMessage = "Service not found. Please try again later." - case 500...599: - self.errorMessage = "Server error. Please try again later." - default: - self.errorMessage = ErrorMessageParser.parse(error.message) - } - } else { - self.errorMessage = ErrorMessageParser.parse(error.message) - } - - print("API Error: \(error.message)") - } - break - } - } - } - } - - // Helper function to clean up error messages - private func cleanErrorMessage(_ message: String) -> String { - // Remove common API error prefixes and technical details - var cleaned = message - - // Remove JSON-like error structures - if let range = cleaned.range(of: #"[{\[]"#, options: .regularExpression) { - cleaned = String(cleaned[.. 100 || cleaned.contains("Exception") { - return "Unable to sign in. Please check your credentials and try again." - } - - // Capitalize first letter - if let first = cleaned.first { - cleaned = first.uppercased() + cleaned.dropFirst() - } - - // Ensure it ends with a period - if !cleaned.hasSuffix(".") && !cleaned.hasSuffix("!") && !cleaned.hasSuffix("?") { - cleaned += "." - } - - return cleaned - } - - func logout() { - // Call shared ViewModel logout - sharedViewModel.logout() - - // Clear token from storage - tokenStorage.clearToken() - - // Clear lookups data on logout via DataCache - DataCache.shared.clearLookups() - - // Clear all cached data - DataCache.shared.clearAll() - - // Reset state - isVerified = false - currentUser = nil - username = "" - password = "" - errorMessage = nil - - print("Logged out - all state reset") - } - - func clearError() { - errorMessage = nil - } - - // MARK: - Private Methods - private func checkAuthenticationStatus() { - guard tokenStorage.getToken() != nil else { - isVerified = false - return - } - - // Fetch current user to check verification status - sharedViewModel.getCurrentUser(forceRefresh: false) - - StateFlowObserver.observe( - sharedViewModel.currentUserState, - onSuccess: { [weak self] (user: User) in - self?.currentUser = user - self?.isVerified = user.verified - - // Initialize lookups if verified - if user.verified { + // Initialize lookups via APILayer Task { _ = try? await APILayer.shared.initializeLookups() } - } - print("Auth check - User: \(user.username), Verified: \(user.verified)") - }, - onError: { [weak self] _ in - // Token invalid or expired, clear it - self?.tokenStorage.clearToken() - self?.isVerified = false - }, - resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() } - ) + // Prefetch all data for caching + Task { + do { + print("Starting data prefetch...") + let prefetchManager = DataPrefetchManager.Companion().getInstance() + _ = try await prefetchManager.prefetchAllData() + print("Data prefetch completed successfully") + } catch { + print("Data prefetch failed: \(error.localizedDescription)") + // Don't block login on prefetch failure + } + } + + // Call login success callback + self.onLoginSuccess?(self.isVerified) + } else if let error = result as? ApiResultError { + self.isLoading = false + self.handleLoginError(error) + } + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } } + private func handleLoginError(_ error: ApiResultError) { + // Check for specific error codes and provide user-friendly messages + if let code = error.code?.intValue { + switch code { + case 400, 401: + self.errorMessage = "Invalid username or password" + case 403: + self.errorMessage = "Access denied. Please check your credentials." + case 404: + self.errorMessage = "Service not found. Please try again later." + case 500...599: + self.errorMessage = "Server error. Please try again later." + default: + self.errorMessage = ErrorMessageParser.parse(error.message) + } + } else { + self.errorMessage = ErrorMessageParser.parse(error.message) + } + print("API Error: \(error.message)") + } + + func logout() { + Task { + // APILayer.logout clears DataManager + try? await APILayer.shared.logout() + + // Clear widget task data + WidgetDataManager.shared.clearCache() + + // Reset local state + self.isVerified = false + self.currentUser = nil + self.username = "" + self.password = "" + self.errorMessage = nil + + print("Logged out - all state reset") + } + } + + func clearError() { + errorMessage = nil + } } diff --git a/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift index 85a5eb6..62db2cf 100644 --- a/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift @@ -141,27 +141,12 @@ struct OnboardingJoinResidenceContent: View { isLoading = true errorMessage = nil - Task { - // Call the shared ViewModel which uses APILayer - await viewModel.sharedViewModel.joinWithCode(code: shareCode) - - // Observe the result - for await state in viewModel.sharedViewModel.joinResidenceState { - if state is ApiResultSuccess { - await MainActor.run { - viewModel.sharedViewModel.resetJoinResidenceState() - isLoading = false - onJoined() - } - break - } else if let error = state as? ApiResultError { - await MainActor.run { - errorMessage = ErrorMessageParser.parse(error.message) - viewModel.sharedViewModel.resetJoinResidenceState() - isLoading = false - } - break - } + viewModel.joinWithCode(code: shareCode) { success in + isLoading = false + if success { + onJoined() + } else { + errorMessage = viewModel.errorMessage } } } diff --git a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift index ad1dae5..74144af 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift @@ -9,6 +9,8 @@ enum PasswordResetStep: CaseIterable { case success // Final: Success confirmation } +/// ViewModel for password reset flow. +/// Calls APILayer directly for all password reset operations. @MainActor class PasswordResetViewModel: ObservableObject { // MARK: - Published Properties @@ -22,16 +24,8 @@ class PasswordResetViewModel: ObservableObject { @Published var currentStep: PasswordResetStep = .requestCode @Published var resetToken: String? - // MARK: - Private Properties - private let sharedViewModel: ComposeApp.AuthViewModel - // MARK: - Initialization - init( - resetToken: String? = nil, - sharedViewModel: ComposeApp.AuthViewModel? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() - + init(resetToken: String? = nil) { // If we have a reset token from deep link, skip to password reset step if let token = resetToken { self.resetToken = token @@ -51,27 +45,29 @@ class PasswordResetViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.forgotPassword(email: email) + Task { + do { + let request = ForgotPasswordRequest(email: email) + let result = try await APILayer.shared.forgotPassword(request: request) - StateFlowObserver.observe( - sharedViewModel.forgotPasswordState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (_: ForgotPasswordResponse) in - self?.isLoading = false - self?.successMessage = "Check your email for a 6-digit verification code" + if result is ApiResultSuccess { + self.isLoading = false + self.successMessage = "Check your email for a 6-digit verification code" - // Automatically move to next step after short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self?.successMessage = nil - self?.currentStep = .verifyCode + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.successMessage = nil + self.currentStep = .verifyCode + } + } else if let error = result as? ApiResultError { + self.isLoading = false + self.errorMessage = ErrorMessageParser.parse(error.message) } - }, - onError: { [weak self] error in - self?.isLoading = false - self?.errorMessage = error - }, - resetState: { [weak self] in self?.sharedViewModel.resetForgotPasswordState() } - ) + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } } /// Step 2: Verify reset code @@ -84,30 +80,31 @@ class PasswordResetViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.verifyResetCode(email: email, code: code) + Task { + do { + let request = VerifyResetCodeRequest(email: email, code: code) + let result = try await APILayer.shared.verifyResetCode(request: request) - StateFlowObserver.observe( - sharedViewModel.verifyResetCodeState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (response: VerifyResetCodeResponse) in - guard let self = self else { return } - let token = response.resetToken - self.resetToken = token - self.isLoading = false - self.successMessage = "Code verified! Now set your new password" + if let success = result as? ApiResultSuccess, let response = success.data { + let token = response.resetToken + self.resetToken = token + self.isLoading = false + self.successMessage = "Code verified! Now set your new password" - // Automatically move to next step after short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.successMessage = nil - self.currentStep = .resetPassword + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.successMessage = nil + self.currentStep = .resetPassword + } + } else if let error = result as? ApiResultError { + self.isLoading = false + self.handleVerifyError(ErrorMessageParser.parse(error.message)) } - }, - onError: { [weak self] error in - self?.isLoading = false - self?.handleVerifyError(error) - }, - resetState: { [weak self] in self?.sharedViewModel.resetVerifyResetCodeState() } - ) + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } } /// Step 3: Reset password @@ -135,22 +132,27 @@ class PasswordResetViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.resetPassword(resetToken: token, newPassword: newPassword, confirmPassword: confirmPassword) + Task { + do { + let request = ResetPasswordRequest( + resetToken: token, + newPassword: newPassword + ) + let result = try await APILayer.shared.resetPassword(request: request) - StateFlowObserver.observe( - sharedViewModel.resetPasswordState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (_: ResetPasswordResponse) in - self?.isLoading = false - self?.successMessage = "Password reset successfully! You can now log in with your new password." - self?.currentStep = .success - }, - onError: { [weak self] error in - self?.isLoading = false - self?.errorMessage = error - }, - resetState: { [weak self] in self?.sharedViewModel.resetResetPasswordState() } - ) + if result is ApiResultSuccess { + self.isLoading = false + self.successMessage = "Password reset successfully! You can now log in with your new password." + self.currentStep = .success + } else if let error = result as? ApiResultError { + self.isLoading = false + self.errorMessage = ErrorMessageParser.parse(error.message) + } + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } } /// Navigate to next step diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift index 36dafb7..d3c6c7a 100644 --- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -228,48 +228,30 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { @Published var errorMessage: String? @Published var isSaving: Bool = false - private let sharedViewModel = ComposeApp.NotificationPreferencesViewModel() - private var preferencesTask: Task? - private var updateTask: Task? - func loadPreferences() { - preferencesTask?.cancel() isLoading = true errorMessage = nil - sharedViewModel.loadPreferences() + Task { + do { + let result = try await APILayer.shared.getNotificationPreferences() - preferencesTask = Task { - for await state in sharedViewModel.preferencesState { - if Task.isCancelled { break } - - await MainActor.run { - switch state { - case let success as ApiResultSuccess: - if let prefs = success.data { - self.taskDueSoon = prefs.taskDueSoon - self.taskOverdue = prefs.taskOverdue - self.taskCompleted = prefs.taskCompleted - self.taskAssigned = prefs.taskAssigned - self.residenceShared = prefs.residenceShared - self.warrantyExpiring = prefs.warrantyExpiring - } - self.isLoading = false - self.errorMessage = nil - case let error as ApiResultError: - self.errorMessage = error.message - self.isLoading = false - case is ApiResultLoading: - self.isLoading = true - default: - break - } - } - - // Break after success or error - if state is ApiResultSuccess || state is ApiResultError { - break + if let success = result as? ApiResultSuccess, let prefs = success.data { + self.taskDueSoon = prefs.taskDueSoon + self.taskOverdue = prefs.taskOverdue + self.taskCompleted = prefs.taskCompleted + self.taskAssigned = prefs.taskAssigned + self.residenceShared = prefs.residenceShared + self.warrantyExpiring = prefs.warrantyExpiring + self.isLoading = false + self.errorMessage = nil + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } } } @@ -282,50 +264,32 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { residenceShared: Bool? = nil, warrantyExpiring: Bool? = nil ) { - updateTask?.cancel() isSaving = true - sharedViewModel.updatePreference( - taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) }, - taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) }, - taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) }, - taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) }, - residenceShared: residenceShared.map { KotlinBoolean(bool: $0) }, - warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) } - ) + Task { + do { + let request = UpdateNotificationPreferencesRequest( + taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) }, + taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) }, + taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) }, + taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) }, + residenceShared: residenceShared.map { KotlinBoolean(bool: $0) }, + warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) } + ) + let result = try await APILayer.shared.updateNotificationPreferences(request: request) - updateTask = Task { - for await state in sharedViewModel.updateState { - if Task.isCancelled { break } - - await MainActor.run { - switch state { - case is ApiResultSuccess: - self.isSaving = false - self.sharedViewModel.resetUpdateState() - case let error as ApiResultError: - self.errorMessage = error.message - self.isSaving = false - self.sharedViewModel.resetUpdateState() - case is ApiResultLoading: - self.isSaving = true - default: - break - } - } - - // Break after success or error - if state is ApiResultSuccess || state is ApiResultError { - break + if result is ApiResultSuccess { + self.isSaving = false + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isSaving = false } + } catch { + self.errorMessage = error.localizedDescription + self.isSaving = false } } } - - deinit { - preferencesTask?.cancel() - updateTask?.cancel() - } } #Preview { diff --git a/iosApp/iosApp/Profile/ProfileViewModel.swift b/iosApp/iosApp/Profile/ProfileViewModel.swift index b6cece4..a883849 100644 --- a/iosApp/iosApp/Profile/ProfileViewModel.swift +++ b/iosApp/iosApp/Profile/ProfileViewModel.swift @@ -2,6 +2,9 @@ import Foundation import ComposeApp import Combine +/// ViewModel for user profile management. +/// Observes DataManagerObservable for current user. +/// Calls APILayer directly for profile updates. @MainActor class ProfileViewModel: ObservableObject { // MARK: - Published Properties @@ -14,17 +17,26 @@ class ProfileViewModel: ObservableObject { @Published var successMessage: String? // MARK: - Private Properties - private let sharedViewModel: ComposeApp.AuthViewModel private let tokenStorage: TokenStorageProtocol + private var cancellables = Set() // MARK: - Initialization - init( - sharedViewModel: ComposeApp.AuthViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + init(tokenStorage: TokenStorageProtocol? = nil) { self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() + // Observe current user from DataManagerObservable + DataManagerObservable.shared.$currentUser + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + if let user = user { + self?.firstName = user.firstName ?? "" + self?.lastName = user.lastName ?? "" + self?.email = user.email + self?.isLoadingUser = false + } + } + .store(in: &cancellables) + // Load current user data loadCurrentUser() } @@ -37,27 +49,32 @@ class ProfileViewModel: ObservableObject { return } + // Check if we already have user data + if DataManagerObservable.shared.currentUser != nil { + isLoadingUser = false + return + } + isLoadingUser = true errorMessage = nil - sharedViewModel.getCurrentUser(forceRefresh: false) + Task { + do { + let result = try await APILayer.shared.getCurrentUser(forceRefresh: false) - StateFlowObserver.observe( - sharedViewModel.currentUserState, - onLoading: { [weak self] in self?.isLoadingUser = true }, - onSuccess: { [weak self] (user: User) in - self?.firstName = user.firstName ?? "" - self?.lastName = user.lastName ?? "" - self?.email = user.email - self?.isLoadingUser = false - self?.errorMessage = nil - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoadingUser = false - }, - resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() } - ) + // DataManager is updated by APILayer, UI updates via Combine observation + if result is ApiResultSuccess { + self.isLoadingUser = false + self.errorMessage = nil + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoadingUser = false + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoadingUser = false + } + } } func updateProfile() { @@ -66,7 +83,7 @@ class ProfileViewModel: ObservableObject { return } - guard tokenStorage.getToken() != nil else { + guard let token = tokenStorage.getToken() else { errorMessage = "Not authenticated" return } @@ -75,31 +92,31 @@ class ProfileViewModel: ObservableObject { errorMessage = nil successMessage = nil - sharedViewModel.updateProfile( - firstName: firstName.isEmpty ? nil : firstName, - lastName: lastName.isEmpty ? nil : lastName, - email: email - ) + Task { + do { + let request = UpdateProfileRequest( + firstName: firstName.isEmpty ? nil : firstName, + lastName: lastName.isEmpty ? nil : lastName, + email: email + ) + let result = try await APILayer.shared.updateProfile(token: token, request: request) - StateFlowObserver.observe( - sharedViewModel.updateProfileState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (user: User) in - self?.firstName = user.firstName ?? "" - self?.lastName = user.lastName ?? "" - self?.email = user.email - self?.isLoading = false - self?.errorMessage = nil - self?.successMessage = "Profile updated successfully" - print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")") - }, - onError: { [weak self] error in - self?.isLoading = false - self?.errorMessage = error - self?.successMessage = nil - }, - resetState: { [weak self] in self?.sharedViewModel.resetUpdateProfileState() } - ) + // DataManager is updated by APILayer, UI updates via Combine observation + if result is ApiResultSuccess { + self.isLoading = false + self.errorMessage = nil + self.successMessage = "Profile updated successfully" + } else if let error = result as? ApiResultError { + self.isLoading = false + self.errorMessage = ErrorMessageParser.parse(error.message) + self.successMessage = nil + } + } catch { + self.isLoading = false + self.errorMessage = error.localizedDescription + self.successMessage = nil + } + } } func clearMessages() { diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index e3ca91c..7240e15 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -2,6 +2,8 @@ import Foundation import ComposeApp import Combine +/// ViewModel for user registration. +/// Calls APILayer directly for registration. @MainActor class RegisterViewModel: ObservableObject { // MARK: - Published Properties @@ -14,15 +16,10 @@ class RegisterViewModel: ObservableObject { @Published var isRegistered: Bool = false // MARK: - Private Properties - private let sharedViewModel: ComposeApp.AuthViewModel private let tokenStorage: TokenStorageProtocol // MARK: - Initialization - init( - sharedViewModel: ComposeApp.AuthViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + init(tokenStorage: TokenStorageProtocol? = nil) { self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() } @@ -52,33 +49,32 @@ class RegisterViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.register(username: username, email: email, password: password) + Task { + do { + let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil) + let result = try await APILayer.shared.register(request: request) - StateFlowObserver.observe( - sharedViewModel.registerState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (response: AuthResponse) in - guard let self = self else { return } - let token = response.token - self.tokenStorage.saveToken(token: token) + if let success = result as? ApiResultSuccess, let response = success.data { + let token = response.token + self.tokenStorage.saveToken(token: token) - // Update AuthenticationManager - user is authenticated but NOT verified - AuthenticationManager.shared.login(verified: false) + // Update AuthenticationManager - user is authenticated but NOT verified + AuthenticationManager.shared.login(verified: false) - // Initialize lookups via APILayer after successful registration - Task { + // Initialize lookups via APILayer after successful registration _ = try? await APILayer.shared.initializeLookups() - } - self.isRegistered = true + self.isRegistered = true + self.isLoading = false + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + } + } catch { + self.errorMessage = error.localizedDescription self.isLoading = false - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false - }, - resetState: { [weak self] in self?.sharedViewModel.resetRegisterState() } - ) + } + } } func clearError() { diff --git a/iosApp/iosApp/Residence/JoinResidenceView.swift b/iosApp/iosApp/Residence/JoinResidenceView.swift index ddaf06a..86e77c2 100644 --- a/iosApp/iosApp/Residence/JoinResidenceView.swift +++ b/iosApp/iosApp/Residence/JoinResidenceView.swift @@ -80,27 +80,12 @@ struct JoinResidenceView: View { return } - Task { - // Call the shared ViewModel which uses APILayer - await viewModel.sharedViewModel.joinWithCode(code: shareCode) - - // Observe the result - for await state in viewModel.sharedViewModel.joinResidenceState { - if state is ApiResultSuccess { - await MainActor.run { - viewModel.sharedViewModel.resetJoinResidenceState() - onJoined() - dismiss() - } - break - } else if let error = state as? ApiResultError { - await MainActor.run { - viewModel.errorMessage = ErrorMessageParser.parse(error.message) - viewModel.sharedViewModel.resetJoinResidenceState() - } - break - } + viewModel.joinWithCode(code: shareCode) { success in + if success { + onJoined() + dismiss() } + // Error is handled by ViewModel and displayed via viewModel.errorMessage } } } diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index d8f9777..a7d1894 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -2,11 +2,17 @@ import Foundation import ComposeApp import Combine +/// ViewModel for residence management. +/// Observes DataManagerObservable for cached data. +/// Kicks off API calls that update DataManager, letting views react to cache updates. @MainActor class ResidenceViewModel: ObservableObject { - // MARK: - Published Properties - @Published var residenceSummary: ResidenceSummaryResponse? + // MARK: - Published Properties (from DataManager observation) @Published var myResidences: MyResidencesResponse? + @Published var residences: [ResidenceResponse] = [] + @Published var totalSummary: TotalSummary? + + // MARK: - Local State @Published var selectedResidence: ResidenceResponse? @Published var isLoading: Bool = false @Published var errorMessage: String? @@ -14,57 +20,105 @@ class ResidenceViewModel: ObservableObject { @Published var reportMessage: String? // MARK: - Private Properties - public let sharedViewModel: ComposeApp.ResidenceViewModel - private let tokenStorage: TokenStorageProtocol + private var cancellables = Set() // MARK: - Initialization - init( - sharedViewModel: ComposeApp.ResidenceViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel() - self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() + init() { + // Observe DataManagerObservable for residence data + DataManagerObservable.shared.$myResidences + .receive(on: DispatchQueue.main) + .sink { [weak self] myResidences in + self?.myResidences = myResidences + // Clear loading state when data arrives + if myResidences != nil { + self?.isLoading = false + } + } + .store(in: &cancellables) + + DataManagerObservable.shared.$residences + .receive(on: DispatchQueue.main) + .sink { [weak self] residences in + self?.residences = residences + } + .store(in: &cancellables) + + DataManagerObservable.shared.$totalSummary + .receive(on: DispatchQueue.main) + .sink { [weak self] summary in + self?.totalSummary = summary + } + .store(in: &cancellables) } // MARK: - Public Methods - func loadResidenceSummary() { - isLoading = true + + /// Load summary - kicks off API call that updates DataManager + func loadSummary(forceRefresh: Bool = false) { errorMessage = nil - sharedViewModel.loadResidenceSummary() + // Check if we have cached data and don't need to refresh + if !forceRefresh && totalSummary != nil { + return + } - StateFlowObserver.observeWithState( - sharedViewModel.residenceSummaryState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - onSuccess: { [weak self] (data: ResidenceSummaryResponse) in - self?.residenceSummary = data + isLoading = true + + // Kick off API call - DataManager will be updated, which updates DataManagerObservable + Task { + do { + let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh) + + // Only handle errors - success updates DataManager automatically + if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + } + self.isLoading = false + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } - ) + } } + /// Load my residences - checks cache first, then fetches if needed func loadMyResidences(forceRefresh: Bool = false) { - isLoading = true errorMessage = nil - sharedViewModel.loadMyResidences(forceRefresh: forceRefresh) + // Check if we have cached data and don't need to refresh + if !forceRefresh && DataManagerObservable.shared.myResidences != nil { + // Data already available via observation, no API call needed + return + } - StateFlowObserver.observeWithState( - sharedViewModel.myResidencesState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - onSuccess: { [weak self] (data: MyResidencesResponse) in - self?.myResidences = data + isLoading = true + + // Kick off API call - DataManager will be updated, which updates DataManagerObservable, + // which updates our @Published myResidences via the sink above + Task { + do { + let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh) + + // Only handle errors - success updates DataManager automatically + if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } - ) + } } func getResidence(id: Int32) { isLoading = true errorMessage = nil - sharedViewModel.getResidence(id: id) { result in - Task { @MainActor in + Task { + do { + let result = try await APILayer.shared.getResidence(id: id, forceRefresh: false) + if let success = result as? ApiResultSuccess { self.selectedResidence = success.data self.isLoading = false @@ -72,6 +126,9 @@ class ResidenceViewModel: ObservableObject { self.errorMessage = ErrorMessageParser.parse(error.message) self.isLoading = false } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false } } } @@ -80,56 +137,77 @@ class ResidenceViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.createResidence(request: request) + Task { + do { + let result = try await APILayer.shared.createResidence(request: request) - StateFlowObserver.observeWithCompletion( - sharedViewModel.createResidenceState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - completion: completion, - resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } - ) + if result is ApiResultSuccess { + self.isLoading = false + // DataManager is updated by APILayer (including refreshMyResidences), + // which updates DataManagerObservable, which updates our @Published + // myResidences via Combine subscription + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } } func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) { isLoading = true errorMessage = nil - sharedViewModel.updateResidence(residenceId: id, request: request) + Task { + do { + let result = try await APILayer.shared.updateResidence(id: id, request: request) - StateFlowObserver.observeWithCompletion( - sharedViewModel.updateResidenceState, - loadingSetter: { [weak self] in self?.isLoading = $0 }, - errorSetter: { [weak self] in self?.errorMessage = $0 }, - onSuccess: { [weak self] (data: ResidenceResponse) in - self?.selectedResidence = data - }, - completion: completion, - resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } - ) + if let success = result as? ApiResultSuccess { + self.selectedResidence = success.data + self.isLoading = false + // DataManager is updated by APILayer (including refreshMyResidences), + // which updates DataManagerObservable, which updates our @Published + // myResidences via Combine subscription + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } } func generateTasksReport(residenceId: Int32, email: String? = nil) { isGeneratingReport = true reportMessage = nil - sharedViewModel.generateTasksReport(residenceId: residenceId, email: email) + Task { + do { + let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email) - StateFlowObserver.observe( - sharedViewModel.generateReportState, - onLoading: { [weak self] in - self?.isGeneratingReport = true - }, - onSuccess: { [weak self] (response: GenerateReportResponse) in - self?.reportMessage = response.message ?? "Report generated, but no message returned." - self?.isGeneratingReport = false - }, - onError: { [weak self] error in - self?.reportMessage = error - self?.isGeneratingReport = false - }, - resetState: { [weak self] in self?.sharedViewModel.resetGenerateReportState() } - ) + if let success = result as? ApiResultSuccess { + self.reportMessage = success.data?.message ?? "Report generated, but no message returned." + self.isGeneratingReport = false + } else if let error = result as? ApiResultError { + self.reportMessage = ErrorMessageParser.parse(error.message) + self.isGeneratingReport = false + } + } catch { + self.reportMessage = error.localizedDescription + self.isGeneratingReport = false + } + } } func clearError() { @@ -137,6 +215,34 @@ class ResidenceViewModel: ObservableObject { } func loadResidenceContractors(residenceId: Int32) { - sharedViewModel.loadResidenceContractors(residenceId: residenceId) + // This can now be handled directly via APILayer if needed + // or through DataManagerObservable.shared.contractors + } + + func joinWithCode(code: String, completion: @escaping (Bool) -> Void) { + isLoading = true + errorMessage = nil + + Task { + do { + let result = try await APILayer.shared.joinWithCode(code: code) + + if result is ApiResultSuccess { + self.isLoading = false + // APILayer updates DataManager with refreshMyResidences, + // which updates DataManagerObservable, which updates our + // @Published myResidences via Combine subscription + completion(true) + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(false) + } + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(false) + } + } } } diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index baa57ec..bfad321 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -21,7 +21,7 @@ struct ResidencesListView: View { errorMessage: viewModel.errorMessage, content: { residences in ResidencesContent( - response: response, + summary: viewModel.totalSummary ?? response.summary, residences: residences ) }, @@ -120,14 +120,14 @@ struct ResidencesListView: View { // MARK: - Residences Content View private struct ResidencesContent: View { - let response: MyResidencesResponse + let summary: TotalSummary let residences: [ResidenceResponse] var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: AppSpacing.lg) { // Summary Card - SummaryCard(summary: response.summary) + SummaryCard(summary: summary) .padding(.horizontal, AppSpacing.md) .padding(.top, AppSpacing.sm) diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 1ed640c..c4f9ad3 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -8,10 +8,8 @@ class AuthenticationManager: ObservableObject { @Published var isAuthenticated: Bool = false @Published var isVerified: Bool = false @Published var isCheckingAuth: Bool = true - private let sharedViewModel: ComposeApp.AuthViewModel private init() { - self.sharedViewModel = ComposeApp.AuthViewModel() checkAuthenticationStatus() } @@ -85,8 +83,10 @@ class AuthenticationManager: ObservableObject { } func logout() { - // Call shared ViewModel logout which clears DataManager - sharedViewModel.logout() + // Call APILayer logout which clears DataManager + Task { + _ = try? await APILayer.shared.logout() + } // Clear widget task data WidgetDataManager.shared.clearCache() diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index 60a95c4..89e1dae 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -19,7 +19,6 @@ class StoreKitManager: ObservableObject { @Published var purchaseError: String? private var transactionListener: Task? - private let subscriptionApi = SubscriptionApi(client: ApiClient.shared.httpClient) private init() { // Start listening for transactions @@ -173,13 +172,8 @@ class StoreKitManager: ObservableObject { /// Fetch latest subscription status from backend and update cache private func refreshSubscriptionFromBackend() async { - guard let token = TokenStorage.shared.getToken() else { - print("⚠️ StoreKit: No auth token, skipping backend status refresh") - return - } - do { - let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token) + let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) if let statusSuccess = statusResult as? ApiResultSuccess, let subscription = statusSuccess.data { @@ -242,18 +236,11 @@ class StoreKitManager: ObservableObject { /// Verify transaction with backend API private func verifyTransactionWithBackend(_ transaction: Transaction) async { do { - // Get auth token - guard let token = TokenStorage.shared.getToken() else { - print("⚠️ StoreKit: No auth token, skipping backend verification") - return - } - // Get transaction receipt data let receiptData = String(transaction.id) - // Call backend verification endpoint - let result = try await subscriptionApi.verifyIOSReceipt( - token: token, + // Call backend verification endpoint via APILayer + let result = try await APILayer.shared.verifyIOSReceipt( receiptData: receiptData, transactionId: String(transaction.id) ) @@ -264,8 +251,8 @@ class StoreKitManager: ObservableObject { response.success { print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")") - // Fetch updated subscription status from backend - let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token) + // Fetch updated subscription status from backend via APILayer + let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true) if let statusSuccess = statusResult as? ApiResultSuccess, let subscription = statusSuccess.data { diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index aa21a30..553b394 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -2,17 +2,20 @@ import Foundation import ComposeApp import Combine +/// ViewModel for task management. +/// Observes DataManagerObservable for cached data. +/// Calls APILayer directly for all operations. @MainActor class TaskViewModel: ObservableObject { - // MARK: - Published Properties + // MARK: - Published Properties (from DataManager observation) + @Published var tasksResponse: TaskColumnsResponse? + + // MARK: - Local State @Published var actionState: ActionState = .idle @Published var errorMessage: String? @Published var completions: [TaskCompletionResponse] = [] @Published var isLoadingCompletions: Bool = false @Published var completionsError: String? - - // MARK: - Kanban Board State (shared across views) - @Published var tasksResponse: TaskColumnsResponse? @Published var isLoadingTasks: Bool = false @Published var tasksError: String? @@ -31,11 +34,36 @@ class TaskViewModel: ObservableObject { var taskUnarchived: Bool { actionState.isSuccess(.unarchive) } // MARK: - Private Properties - private let sharedViewModel: ComposeApp.TaskViewModel + private var cancellables = Set() // MARK: - Initialization - init(sharedViewModel: ComposeApp.TaskViewModel? = nil) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel() + init() { + // Observe DataManagerObservable for all tasks data + DataManagerObservable.shared.$allTasks + .receive(on: DispatchQueue.main) + .sink { [weak self] allTasks in + // Only update if we're showing all tasks (no residence filter) + if self?.currentResidenceId == nil { + self?.tasksResponse = allTasks + if allTasks != nil { + self?.isLoadingTasks = false + } + } + } + .store(in: &cancellables) + + // Observe tasks by residence + DataManagerObservable.shared.$tasksByResidence + .receive(on: DispatchQueue.main) + .sink { [weak self] tasksByResidence in + // Only update if we're filtering by residence + if let resId = self?.currentResidenceId, + let tasks = tasksByResidence[resId] { + self?.tasksResponse = tasks + self?.isLoadingTasks = false + } + } + .store(in: &cancellables) } // MARK: - Public Methods @@ -43,42 +71,48 @@ class TaskViewModel: ObservableObject { actionState = .loading(.create) errorMessage = nil - sharedViewModel.createNewTask(request: request) + Task { + do { + let result = try await APILayer.shared.createTask(request: request) - StateFlowObserver.observeWithCompletion( - sharedViewModel.taskAddNewCustomTaskState, - loadingSetter: { [weak self] loading in - if loading { self?.actionState = .loading(.create) } - }, - errorSetter: { [weak self] error in - if let error = error { - self?.actionState = .error(.create, error) - self?.errorMessage = error + if result is ApiResultSuccess { + self.actionState = .success(.create) + // DataManager is updated by APILayer, view updates via observation + completion(true) + } else if let error = result as? ApiResultError { + self.actionState = .error(.create, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) + completion(false) } - }, - onSuccess: { [weak self] (_: TaskResponse) in - self?.actionState = .success(.create) - }, - completion: completion, - resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() } - ) + } catch { + self.actionState = .error(.create, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) + } + } } func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) { actionState = .loading(.cancel) errorMessage = nil - sharedViewModel.cancelTask(taskId: id) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.cancelTask(taskId: id) + + if result is ApiResultSuccess { self.actionState = .success(.cancel) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to cancel task" - self.actionState = .error(.cancel, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.cancel, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -87,17 +121,23 @@ class TaskViewModel: ObservableObject { actionState = .loading(.uncancel) errorMessage = nil - sharedViewModel.uncancelTask(taskId: id) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.uncancelTask(taskId: id) + + if result is ApiResultSuccess { self.actionState = .success(.uncancel) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to uncancel task" - self.actionState = .error(.uncancel, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.uncancel, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -106,17 +146,23 @@ class TaskViewModel: ObservableObject { actionState = .loading(.markInProgress) errorMessage = nil - sharedViewModel.markInProgress(taskId: id) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.markInProgress(taskId: id) + + if result is ApiResultSuccess { self.actionState = .success(.markInProgress) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to mark task in progress" - self.actionState = .error(.markInProgress, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.markInProgress, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -125,17 +171,23 @@ class TaskViewModel: ObservableObject { actionState = .loading(.archive) errorMessage = nil - sharedViewModel.archiveTask(taskId: id) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.archiveTask(taskId: id) + + if result is ApiResultSuccess { self.actionState = .success(.archive) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to archive task" - self.actionState = .error(.archive, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.archive, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.archive, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -144,17 +196,23 @@ class TaskViewModel: ObservableObject { actionState = .loading(.unarchive) errorMessage = nil - sharedViewModel.unarchiveTask(taskId: id) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.unarchiveTask(taskId: id) + + if result is ApiResultSuccess { self.actionState = .success(.unarchive) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to unarchive task" - self.actionState = .error(.unarchive, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.unarchive, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -163,17 +221,23 @@ class TaskViewModel: ObservableObject { actionState = .loading(.update) errorMessage = nil - sharedViewModel.updateTask(taskId: id, request: request) { success in - Task { @MainActor in - if success.boolValue { + Task { + do { + let result = try await APILayer.shared.updateTask(id: id, request: request) + + if result is ApiResultSuccess { self.actionState = .success(.update) + // DataManager is updated by APILayer, view updates via observation completion(true) - } else { - let errorMsg = "Failed to update task" - self.actionState = .error(.update, errorMsg) - self.errorMessage = errorMsg + } else if let error = result as? ApiResultError { + self.actionState = .error(.update, ErrorMessageParser.parse(error.message)) + self.errorMessage = ErrorMessageParser.parse(error.message) completion(false) } + } catch { + self.actionState = .error(.update, error.localizedDescription) + self.errorMessage = error.localizedDescription + completion(false) } } } @@ -196,27 +260,20 @@ class TaskViewModel: ObservableObject { isLoadingCompletions = true completionsError = nil - sharedViewModel.loadTaskCompletions(taskId: taskId) - Task { - for await state in sharedViewModel.taskCompletionsState { - if let success = state as? ApiResultSuccess { - await MainActor.run { - self.completions = (success.data as? [TaskCompletionResponse]) ?? [] - self.isLoadingCompletions = false - } - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.completionsError = error.message - self.isLoadingCompletions = false - } - break - } else if state is ApiResultLoading { - await MainActor.run { - self.isLoadingCompletions = true - } + do { + let result = try await APILayer.shared.getTaskCompletions(taskId: taskId) + + if let success = result as? ApiResultSuccess { + self.completions = (success.data as? [TaskCompletionResponse]) ?? [] + self.isLoadingCompletions = false + } else if let error = result as? ApiResultError { + self.completionsError = ErrorMessageParser.parse(error.message) + self.isLoadingCompletions = false } + } catch { + self.completionsError = error.localizedDescription + self.isLoadingCompletions = false } } } @@ -225,7 +282,6 @@ class TaskViewModel: ObservableObject { completions = [] completionsError = nil isLoadingCompletions = false - sharedViewModel.resetTaskCompletionsState() } // MARK: - Kanban Board Methods @@ -248,6 +304,7 @@ class TaskViewModel: ObservableObject { } /// Load tasks - either all tasks or filtered by residence + /// Checks cache first, then fetches if needed. /// - Parameters: /// - residenceId: Optional residence ID to filter by. If nil, loads all tasks. /// - forceRefresh: Whether to bypass cache @@ -255,9 +312,25 @@ class TaskViewModel: ObservableObject { guard DataManager.shared.isAuthenticated() else { return } currentResidenceId = residenceId - isLoadingTasks = true tasksError = nil + // Check if we have cached data and don't need to refresh + if !forceRefresh { + if let resId = residenceId { + if DataManagerObservable.shared.tasksByResidence[resId] != nil { + // Data already available via observation, no API call needed + return + } + } else if DataManagerObservable.shared.allTasks != nil { + // Data already available via observation, no API call needed + return + } + } + + isLoadingTasks = true + + // Kick off API call - DataManager will be updated, which updates DataManagerObservable, + // which updates our @Published tasksResponse via the sink above Task { do { let result: Any @@ -270,17 +343,17 @@ class TaskViewModel: ObservableObject { result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh) } + // Handle all result states await MainActor.run { if let success = result as? ApiResultSuccess, let data = success.data { - self.tasksResponse = data - self.isLoadingTasks = false - self.tasksError = nil - // Update widget data if loading all tasks if residenceId == nil { WidgetDataManager.shared.saveTasks(from: data) } + // tasksResponse is updated via DataManagerObservable observation + // Ensure loading state is cleared on success + self.isLoadingTasks = false } else if let error = result as? ApiResultError { self.tasksError = error.message self.isLoadingTasks = false diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 4fd3d1c..f97f18a 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -2,6 +2,8 @@ import Foundation import ComposeApp import Combine +/// ViewModel for email verification. +/// Calls APILayer directly for verification. @MainActor class VerifyEmailViewModel: ObservableObject { // MARK: - Published Properties @@ -11,15 +13,10 @@ class VerifyEmailViewModel: ObservableObject { @Published var isVerified: Bool = false // MARK: - Private Properties - private let sharedViewModel: ComposeApp.AuthViewModel private let tokenStorage: TokenStorageProtocol // MARK: - Initialization - init( - sharedViewModel: ComposeApp.AuthViewModel? = nil, - tokenStorage: TokenStorageProtocol? = nil - ) { - self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + init(tokenStorage: TokenStorageProtocol? = nil) { self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() } @@ -31,7 +28,7 @@ class VerifyEmailViewModel: ObservableObject { return } - guard tokenStorage.getToken() != nil else { + guard let token = tokenStorage.getToken() else { errorMessage = "Not authenticated" return } @@ -39,29 +36,31 @@ class VerifyEmailViewModel: ObservableObject { isLoading = true errorMessage = nil - sharedViewModel.verifyEmail(code: code) + Task { + do { + let request = VerifyEmailRequest(code: code) + let result = try await APILayer.shared.verifyEmail(token: token, request: request) - StateFlowObserver.observe( - sharedViewModel.verifyEmailState, - onLoading: { [weak self] in self?.isLoading = true }, - onSuccess: { [weak self] (response: VerifyEmailResponse) in - print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)") - if response.verified { - print("🏠 VerifyEmailViewModel: Setting isVerified = true") - self?.isVerified = true - self?.isLoading = false - print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)") - } else { - self?.errorMessage = "Verification failed" - self?.isLoading = false + if let success = result as? ApiResultSuccess, let response = success.data { + print("VerifyEmailViewModel: onSuccess called, verified=\(response.verified)") + if response.verified { + print("VerifyEmailViewModel: Setting isVerified = true") + self.isVerified = true + self.isLoading = false + print("VerifyEmailViewModel: isVerified is now \(self.isVerified)") + } else { + self.errorMessage = "Verification failed" + self.isLoading = false + } + } else if let error = result as? ApiResultError { + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false } - }, - onError: { [weak self] error in - self?.errorMessage = error - self?.isLoading = false - }, - resetState: { [weak self] in self?.sharedViewModel.resetVerifyEmailState() } - ) + } catch { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } } func clearError() {