diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataCache.kt b/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataCache.kt deleted file mode 100644 index 2b612c5..0000000 --- a/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataCache.kt +++ /dev/null @@ -1,301 +0,0 @@ -package com.example.casera.cache - -import com.example.casera.models.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlin.time.Clock -import kotlin.time.ExperimentalTime - -//import kotlinx.datetime.Clock -//import kotlinx.datetime.Instant - -/** - * Centralized data cache for the application. - * This singleton holds all frequently accessed data in memory to avoid redundant API calls. - */ -object DataCache { - - // User & Authentication - private val _currentUser = MutableStateFlow(null) - val currentUser: StateFlow = _currentUser.asStateFlow() - - // Residences - private val _residences = MutableStateFlow>(emptyList()) - val residences: StateFlow> = _residences.asStateFlow() - - private val _myResidences = MutableStateFlow(null) - val myResidences: StateFlow = _myResidences.asStateFlow() - - private val _residenceSummaries = MutableStateFlow>(emptyMap()) - val residenceSummaries: StateFlow> = _residenceSummaries.asStateFlow() - - // Tasks - private val _allTasks = MutableStateFlow(null) - val allTasks: StateFlow = _allTasks.asStateFlow() - - private val _tasksByResidence = MutableStateFlow>(emptyMap()) - val tasksByResidence: StateFlow> = _tasksByResidence.asStateFlow() - - // Documents - private val _documents = MutableStateFlow>(emptyList()) - val documents: StateFlow> = _documents.asStateFlow() - - private val _documentsByResidence = MutableStateFlow>>(emptyMap()) - val documentsByResidence: StateFlow>> = _documentsByResidence.asStateFlow() - - // Contractors - private val _contractors = MutableStateFlow>(emptyList()) - val contractors: StateFlow> = _contractors.asStateFlow() - - // Lookups/Reference Data - List-based (for dropdowns/pickers) - private val _residenceTypes = MutableStateFlow>(emptyList()) - val residenceTypes: StateFlow> = _residenceTypes.asStateFlow() - - private val _taskFrequencies = MutableStateFlow>(emptyList()) - val taskFrequencies: StateFlow> = _taskFrequencies.asStateFlow() - - private val _taskPriorities = MutableStateFlow>(emptyList()) - val taskPriorities: StateFlow> = _taskPriorities.asStateFlow() - - private val _taskStatuses = MutableStateFlow>(emptyList()) - val taskStatuses: StateFlow> = _taskStatuses.asStateFlow() - - private val _taskCategories = MutableStateFlow>(emptyList()) - val taskCategories: StateFlow> = _taskCategories.asStateFlow() - - private val _contractorSpecialties = MutableStateFlow>(emptyList()) - val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() - - // Lookups/Reference Data - Map-based (for O(1) ID resolution) - private val _residenceTypesMap = MutableStateFlow>(emptyMap()) - val residenceTypesMap: StateFlow> = _residenceTypesMap.asStateFlow() - - private val _taskFrequenciesMap = MutableStateFlow>(emptyMap()) - val taskFrequenciesMap: StateFlow> = _taskFrequenciesMap.asStateFlow() - - private val _taskPrioritiesMap = MutableStateFlow>(emptyMap()) - val taskPrioritiesMap: StateFlow> = _taskPrioritiesMap.asStateFlow() - - private val _taskStatusesMap = MutableStateFlow>(emptyMap()) - val taskStatusesMap: StateFlow> = _taskStatusesMap.asStateFlow() - - private val _taskCategoriesMap = MutableStateFlow>(emptyMap()) - val taskCategoriesMap: StateFlow> = _taskCategoriesMap.asStateFlow() - - private val _contractorSpecialtiesMap = MutableStateFlow>(emptyMap()) - val contractorSpecialtiesMap: StateFlow> = _contractorSpecialtiesMap.asStateFlow() - - private val _lookupsInitialized = MutableStateFlow(false) - val lookupsInitialized: StateFlow = _lookupsInitialized.asStateFlow() - - // O(1) lookup helper methods - resolve ID to full object - fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] } - fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] } - fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] } - fun getTaskStatus(id: Int?): TaskStatus? = id?.let { _taskStatusesMap.value[it] } - fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] } - fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] } - - // Cache metadata - private val _lastRefreshTime = MutableStateFlow(0L) - val lastRefreshTime: StateFlow = _lastRefreshTime.asStateFlow() - - private val _isCacheInitialized = MutableStateFlow(false) - val isCacheInitialized: StateFlow = _isCacheInitialized.asStateFlow() - - // Update methods - fun updateCurrentUser(user: User?) { - _currentUser.value = user - } - - fun updateResidences(residences: List) { - _residences.value = residences - updateLastRefreshTime() - } - - fun updateMyResidences(myResidences: MyResidencesResponse) { - _myResidences.value = myResidences - updateLastRefreshTime() - } - - fun updateResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) { - _residenceSummaries.value = _residenceSummaries.value + (residenceId to summary) - } - - fun updateAllTasks(tasks: TaskColumnsResponse) { - _allTasks.value = tasks - updateLastRefreshTime() - } - - fun updateTasksByResidence(residenceId: Int, tasks: TaskColumnsResponse) { - _tasksByResidence.value = _tasksByResidence.value + (residenceId to tasks) - } - - fun updateDocuments(documents: List) { - _documents.value = documents - updateLastRefreshTime() - } - - fun updateDocumentsByResidence(residenceId: Int, documents: List) { - _documentsByResidence.value = _documentsByResidence.value + (residenceId to documents) - } - - fun updateContractors(contractors: List) { - _contractors.value = contractors - updateLastRefreshTime() - } - - // Lookup update methods removed - lookups are handled by LookupsViewModel - - fun setCacheInitialized(initialized: Boolean) { - _isCacheInitialized.value = initialized - } - - @OptIn(ExperimentalTime::class) - private fun updateLastRefreshTime() { - _lastRefreshTime.value = Clock.System.now().toEpochMilliseconds() - } - - // Helper methods to add/update/remove individual items - fun addResidence(residence: Residence) { - _residences.value = _residences.value + residence - } - - fun updateResidence(residence: Residence) { - _residences.value = _residences.value.map { - if (it.id == residence.id) residence else it - } - } - - fun removeResidence(residenceId: Int) { - _residences.value = _residences.value.filter { it.id != residenceId } - // Also clear related caches - _tasksByResidence.value = _tasksByResidence.value - residenceId - _documentsByResidence.value = _documentsByResidence.value - residenceId - _residenceSummaries.value = _residenceSummaries.value - residenceId - } - - fun addDocument(document: Document) { - _documents.value = _documents.value + document - } - - fun updateDocument(document: Document) { - _documents.value = _documents.value.map { - if (it.id == document.id) document else it - } - } - - fun removeDocument(documentId: Int) { - _documents.value = _documents.value.filter { it.id != documentId } - } - - fun addContractor(contractor: Contractor) { - _contractors.value = _contractors.value + contractor - } - - fun updateContractor(contractor: Contractor) { - _contractors.value = _contractors.value.map { - if (it.id == contractor.id) contractor else it - } - } - - fun removeContractor(contractorId: Int) { - _contractors.value = _contractors.value.filter { it.id != contractorId } - } - - // Lookup update methods - update both list and map versions - fun updateResidenceTypes(types: List) { - _residenceTypes.value = types - _residenceTypesMap.value = types.associateBy { it.id } - } - - fun updateTaskFrequencies(frequencies: List) { - _taskFrequencies.value = frequencies - _taskFrequenciesMap.value = frequencies.associateBy { it.id } - } - - fun updateTaskPriorities(priorities: List) { - _taskPriorities.value = priorities - _taskPrioritiesMap.value = priorities.associateBy { it.id } - } - - fun updateTaskStatuses(statuses: List) { - _taskStatuses.value = statuses - _taskStatusesMap.value = statuses.associateBy { it.id } - } - - fun updateTaskCategories(categories: List) { - _taskCategories.value = categories - _taskCategoriesMap.value = categories.associateBy { it.id } - } - - fun updateContractorSpecialties(specialties: List) { - _contractorSpecialties.value = specialties - _contractorSpecialtiesMap.value = specialties.associateBy { it.id } - } - - fun updateAllLookups(staticData: StaticDataResponse) { - _residenceTypes.value = staticData.residenceTypes - _residenceTypesMap.value = staticData.residenceTypes.associateBy { it.id } - _taskFrequencies.value = staticData.taskFrequencies - _taskFrequenciesMap.value = staticData.taskFrequencies.associateBy { it.id } - _taskPriorities.value = staticData.taskPriorities - _taskPrioritiesMap.value = staticData.taskPriorities.associateBy { it.id } - _taskStatuses.value = staticData.taskStatuses - _taskStatusesMap.value = staticData.taskStatuses.associateBy { it.id } - _taskCategories.value = staticData.taskCategories - _taskCategoriesMap.value = staticData.taskCategories.associateBy { it.id } - _contractorSpecialties.value = staticData.contractorSpecialties - _contractorSpecialtiesMap.value = staticData.contractorSpecialties.associateBy { it.id } - } - - fun markLookupsInitialized() { - _lookupsInitialized.value = true - } - - // Clear methods - fun clearAll() { - _currentUser.value = null - _residences.value = emptyList() - _myResidences.value = null - _residenceSummaries.value = emptyMap() - _allTasks.value = null - _tasksByResidence.value = emptyMap() - _documents.value = emptyList() - _documentsByResidence.value = emptyMap() - _contractors.value = emptyList() - clearLookups() - _lastRefreshTime.value = 0L - _isCacheInitialized.value = false - } - - fun clearLookups() { - _residenceTypes.value = emptyList() - _residenceTypesMap.value = emptyMap() - _taskFrequencies.value = emptyList() - _taskFrequenciesMap.value = emptyMap() - _taskPriorities.value = emptyList() - _taskPrioritiesMap.value = emptyMap() - _taskStatuses.value = emptyList() - _taskStatusesMap.value = emptyMap() - _taskCategories.value = emptyList() - _taskCategoriesMap.value = emptyMap() - _contractorSpecialties.value = emptyList() - _contractorSpecialtiesMap.value = emptyMap() - _lookupsInitialized.value = false - } - - fun clearUserData() { - _currentUser.value = null - _residences.value = emptyList() - _myResidences.value = null - _residenceSummaries.value = emptyMap() - _allTasks.value = null - _tasksByResidence.value = emptyMap() - _documents.value = emptyList() - _documentsByResidence.value = emptyMap() - _contractors.value = emptyList() - _isCacheInitialized.value = false - } -} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataPrefetchManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataPrefetchManager.kt deleted file mode 100644 index 173e932..0000000 --- a/composeApp/src/commonMain/kotlin/com/example/casera/cache/DataPrefetchManager.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.example.casera.cache - -import com.example.casera.network.* -import com.example.casera.storage.TokenStorage -import kotlinx.coroutines.* - -/** - * Manager responsible for prefetching and caching data when the app launches. - * This ensures all screens have immediate access to data without making API calls. - */ -class DataPrefetchManager { - - private val residenceApi = ResidenceApi() - private val taskApi = TaskApi() - private val documentApi = DocumentApi() - private val contractorApi = ContractorApi() - private val lookupsApi = LookupsApi() - - /** - * Prefetch all essential data on app launch. - * This runs asynchronously and populates the DataCache. - */ - suspend fun prefetchAllData(): Result = withContext(Dispatchers.Default) { - try { - val token = TokenStorage.getToken() - if (token == null) { - return@withContext Result.failure(Exception("Not authenticated")) - } - - println("DataPrefetchManager: Starting data prefetch...") - - // Launch all prefetch operations in parallel - val jobs = listOf( - async { prefetchResidences(token) }, - async { prefetchMyResidences(token) }, - async { prefetchTasks(token) }, - async { prefetchDocuments(token) }, - async { prefetchContractors(token) }, - async { prefetchLookups(token) } - ) - - // Wait for all jobs to complete - jobs.awaitAll() - - // Mark cache as initialized - DataCache.setCacheInitialized(true) - - println("DataPrefetchManager: Data prefetch completed successfully") - Result.success(Unit) - } catch (e: Exception) { - println("DataPrefetchManager: Error during prefetch: ${e.message}") - e.printStackTrace() - Result.failure(e) - } - } - - /** - * Refresh specific data types. - * Useful for pull-to-refresh functionality. - */ - suspend fun refreshResidences(): Result = withContext(Dispatchers.Default) { - try { - val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated")) - prefetchResidences(token) - prefetchMyResidences(token) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun refreshTasks(): Result = withContext(Dispatchers.Default) { - try { - val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated")) - prefetchTasks(token) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun refreshDocuments(): Result = withContext(Dispatchers.Default) { - try { - val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated")) - prefetchDocuments(token) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun refreshContractors(): Result = withContext(Dispatchers.Default) { - try { - val token = TokenStorage.getToken() ?: return@withContext Result.failure(Exception("Not authenticated")) - prefetchContractors(token) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - // Private prefetch methods - private suspend fun prefetchResidences(token: String) { - try { - println("DataPrefetchManager: Fetching residences...") - val result = residenceApi.getResidences(token) - if (result is ApiResult.Success) { - DataCache.updateResidences(result.data) - println("DataPrefetchManager: Cached ${result.data.size} residences") - } - } catch (e: Exception) { - println("DataPrefetchManager: Error fetching residences: ${e.message}") - } - } - - private suspend fun prefetchMyResidences(token: String) { - try { - println("DataPrefetchManager: Fetching my residences...") - val result = residenceApi.getMyResidences(token) - if (result is ApiResult.Success) { - DataCache.updateMyResidences(result.data) - println("DataPrefetchManager: Cached my residences") - } - } catch (e: Exception) { - println("DataPrefetchManager: Error fetching my residences: ${e.message}") - } - } - - private suspend fun prefetchTasks(token: String) { - try { - println("DataPrefetchManager: Fetching tasks...") - val result = taskApi.getTasks(token) - if (result is ApiResult.Success) { - DataCache.updateAllTasks(result.data) - println("DataPrefetchManager: Cached tasks") - } - } catch (e: Exception) { - println("DataPrefetchManager: Error fetching tasks: ${e.message}") - } - } - - private suspend fun prefetchDocuments(token: String) { - try { - println("DataPrefetchManager: Fetching documents...") - val result = documentApi.getDocuments( - token = token, - residenceId = null, - documentType = null, - category = null, - contractorId = null, - isActive = null, - expiringSoon = null, - tags = null, - search = null - ) - if (result is ApiResult.Success) { - DataCache.updateDocuments(result.data) - println("DataPrefetchManager: Cached ${result.data.size} documents") - } - } catch (e: Exception) { - println("DataPrefetchManager: Error fetching documents: ${e.message}") - } - } - - private suspend fun prefetchContractors(token: String) { - try { - println("DataPrefetchManager: Fetching contractors...") - val result = contractorApi.getContractors( - token = token, - specialty = null, - isFavorite = null, - isActive = null, - search = null - ) - if (result is ApiResult.Success) { - // API returns List, not List - // Skip caching for now - full Contractor objects will be cached when fetched individually - println("DataPrefetchManager: Fetched ${result.data.size} contractor summaries") - } - } catch (e: Exception) { - println("DataPrefetchManager: Error fetching contractors: ${e.message}") - } - } - - private suspend fun prefetchLookups(token: String) { - // Lookups are handled separately by LookupsViewModel with their own caching - println("DataPrefetchManager: Skipping lookups prefetch (handled by LookupsViewModel)") - } - - companion object { - private var instance: DataPrefetchManager? = null - - fun getInstance(): DataPrefetchManager { - if (instance == null) { - instance = DataPrefetchManager() - } - return instance!! - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/cache/README_CACHING.md b/composeApp/src/commonMain/kotlin/com/example/casera/cache/README_CACHING.md deleted file mode 100644 index 4bb77df..0000000 --- a/composeApp/src/commonMain/kotlin/com/example/casera/cache/README_CACHING.md +++ /dev/null @@ -1,159 +0,0 @@ -# Data Caching Implementation - -## Overview - -This app now uses a centralized caching system to avoid redundant API calls when navigating between screens. - -## How It Works - -1. **App Launch**: When the app launches and the user is authenticated, `DataPrefetchManager` automatically loads all essential data in parallel: - - Residences (all + my residences) - - Tasks (all tasks) - - Documents - - Contractors - - Lookup data (categories, priorities, frequencies, statuses) - -2. **Data Access**: ViewModels check the `DataCache` first before making API calls: - - If cache has data and `forceRefresh=false`: Use cached data immediately - - If cache is empty or `forceRefresh=true`: Fetch from API and update cache - -3. **Cache Updates**: When create/update/delete operations succeed, the cache is automatically updated - -## Usage in ViewModels - -### Load Data (with caching) -```kotlin -// In ViewModel -fun loadResidences(forceRefresh: Boolean = false) { - viewModelScope.launch { - // Check cache first - val cachedData = DataCache.residences.value - if (!forceRefresh && cachedData.isNotEmpty()) { - _residencesState.value = ApiResult.Success(cachedData) - return@launch - } - - // Fetch from API if needed - _residencesState.value = ApiResult.Loading - val result = residenceApi.getResidences(token) - _residencesState.value = result - - // Update cache on success - if (result is ApiResult.Success) { - DataCache.updateResidences(result.data) - } - } -} -``` - -### Update Cache After Mutations -```kotlin -fun createResidence(request: ResidenceCreateRequest) { - viewModelScope.launch { - val result = residenceApi.createResidence(token, request) - _createState.value = result - - // Update cache on success - if (result is ApiResult.Success) { - DataCache.addResidence(result.data) - } - } -} -``` - -## iOS Integration - -In your iOS app's main initialization (e.g., `iOSApp.swift`): - -```swift -import ComposeApp - -@main -struct MyCribApp: App { - init() { - // After successful login, prefetch data - Task { - await prefetchData() - } - } - - func prefetchData() async { - let prefetchManager = DataPrefetchManager.Companion().getInstance() - _ = try? await prefetchManager.prefetchAllData() - } - - var body: some Scene { - WindowGroup { - ContentView() - } - } -} -``` - -## Android Integration - -In your Android `MainActivity`: - -```kotlin -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Check if authenticated - val token = TokenStorage.getToken() - if (token != null) { - // Prefetch data in background - lifecycleScope.launch { - DataPrefetchManager.getInstance().prefetchAllData() - } - } - - setContent { - // Your compose content - } - } -} -``` - -## Pull to Refresh - -To refresh data manually (e.g., pull-to-refresh): - -```kotlin -// In ViewModel -fun refresh() { - viewModelScope.launch { - prefetchManager.refreshResidences() - loadResidences(forceRefresh = true) - } -} -``` - -## Benefits - -1. **Instant Screen Load**: Screens show data immediately from cache -2. **Reduced API Calls**: No redundant calls when navigating between screens -3. **Better UX**: No loading spinners on every screen transition -4. **Offline Support**: Data remains available even with poor connectivity -5. **Consistent State**: All screens see the same data from cache - -## Cache Lifecycle - -- **Initialization**: App launch (after authentication) -- **Updates**: After successful create/update/delete operations -- **Clear**: On logout or authentication error -- **Refresh**: Manual pull-to-refresh or `forceRefresh=true` - -## ViewModels Updated - -The following ViewModels now use caching: - -- ✅ `ResidenceViewModel` -- ✅ `TaskViewModel` -- ⏳ `DocumentViewModel` (TODO) -- ⏳ `ContractorViewModel` (TODO) -- ⏳ `LookupsViewModel` (TODO) - -## Note - -For DocumentViewModel and ContractorViewModel, follow the same pattern as shown in ResidenceViewModel and TaskViewModel. 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 7ff15f3..32da741 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -58,23 +58,28 @@ object APILayer { } /** - * Initialize all lookup data. Should be called once after login. + * Initialize all lookup data. Can be called at app start even without authentication. * Loads all reference data (residence types, task categories, priorities, etc.) into DataManager. + * + * - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required) + * - /subscription/status/ requires auth and is only called if user is authenticated */ suspend fun initializeLookups(): ApiResult { + val token = getToken() + if (DataManager.lookupsInitialized.value) { - // Lookups already initialized, but refresh subscription status + // Lookups already initialized, but refresh subscription status if authenticated println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...") - refreshSubscriptionStatus() + if (token != null) { + refreshSubscriptionStatus() + } return ApiResult.Success(Unit) } - val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - try { - // Load all lookups in a single API call using static_data endpoint + // Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required) println("🔄 Fetching static data (all lookups)...") - val staticDataResult = lookupsApi.getStaticData(token) + val staticDataResult = lookupsApi.getStaticData(token) // token is optional println("📦 Static data result: $staticDataResult") // Update DataManager with all lookups at once @@ -86,24 +91,11 @@ object APILayer { return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}") } - // Load subscription status to get limitationsEnabled, usage, and limits from backend - println("🔄 Fetching subscription status...") - val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token) - println("📦 Subscription status result: $subscriptionStatusResult") - - // Load upgrade triggers + // Load upgrade triggers (PUBLIC - no auth required) println("🔄 Fetching upgrade triggers...") - val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) + val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional println("📦 Upgrade triggers result: $upgradeTriggersResult") - if (subscriptionStatusResult is ApiResult.Success) { - println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}") - DataManager.setSubscription(subscriptionStatusResult.data) - println("✅ Subscription updated successfully") - } else if (subscriptionStatusResult is ApiResult.Error) { - println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}") - } - if (upgradeTriggersResult is ApiResult.Success) { println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers") DataManager.setUpgradeTriggers(upgradeTriggersResult.data) @@ -112,6 +104,23 @@ object APILayer { println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}") } + // Load subscription status only if authenticated (requires auth for user-specific data) + if (token != null) { + println("🔄 Fetching subscription status...") + val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token) + println("📦 Subscription status result: $subscriptionStatusResult") + + if (subscriptionStatusResult is ApiResult.Success) { + println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}") + DataManager.setSubscription(subscriptionStatusResult.data) + println("✅ Subscription updated successfully") + } else if (subscriptionStatusResult is ApiResult.Error) { + println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}") + } + } else { + println("⏭️ Skipping subscription status (not authenticated)") + } + DataManager.markLookupsInitialized() return ApiResult.Success(Unit) } catch (e: Exception) { @@ -887,7 +896,17 @@ object APILayer { } suspend fun register(request: RegisterRequest): ApiResult { - return authApi.register(request) + val result = authApi.register(request) + + // Update DataManager on success (same as login) + if (result is ApiResult.Success) { + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) + // Initialize lookups after successful registration + initializeLookups() + } + + return result } suspend fun logout(): ApiResult { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt index 80ba339..34f0d80 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/LookupsApi.kt @@ -121,10 +121,11 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getStaticData(token: String): ApiResult { + suspend fun getStaticData(token: String? = null): ApiResult { return try { val response = client.get("$baseUrl/static_data/") { - header("Authorization", "Token $token") + // Token is optional - endpoint is public + token?.let { header("Authorization", "Token $it") } } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt index aabf720..19cdf27 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt @@ -25,10 +25,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getUpgradeTriggers(token: String): ApiResult> { + suspend fun getUpgradeTriggers(token: String? = null): ApiResult> { return try { val response = client.get("$baseUrl/subscription/upgrade-triggers/") { - header("Authorization", "Token $token") + // Token is optional - endpoint is public + token?.let { header("Authorization", "Token $it") } } if (response.status.isSuccess()) { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskApi.kt index f043f88..e0c6ff6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskApi.kt @@ -153,7 +153,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) { // DEPRECATED: These methods now use PATCH internally. // They're kept for backward compatibility with existing ViewModel calls. - // New code should use patchTask directly with status IDs from DataCache. + // New code should use patchTask directly with status IDs from DataManager. suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult { return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId)) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt index f2e4b7d..5d2596f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.casera.cache.DataCache +import com.example.casera.data.DataManager import com.example.casera.ui.components.AddContractorDialog import com.example.casera.ui.components.ApiResultHandler import com.example.casera.ui.components.HandleErrors @@ -117,7 +117,7 @@ fun ContractorDetailScreen( .background(Color(0xFFF9FAFB)) ) { val uriHandler = LocalUriHandler.current - val residences = DataCache.residences.value + val residences = DataManager.residences.value ApiResultHandler( state = contractorState, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index 39c5db4..79199ac 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -34,7 +34,7 @@ import com.example.casera.network.ApiResult import com.example.casera.utils.SubscriptionHelper import com.example.casera.ui.subscription.UpgradePromptDialog import com.example.casera.cache.SubscriptionCache -import com.example.casera.cache.DataCache +import com.example.casera.data.DataManager import com.example.casera.util.DateUtils import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -77,7 +77,7 @@ fun ResidenceDetailScreen( var upgradeTriggerKey by remember { mutableStateOf(null) } // Get current user for ownership checks - val currentUser by DataCache.currentUser.collectAsState() + val currentUser by DataManager.currentUser.collectAsState() // Check if tasks are blocked (limit=0) - this hides the FAB val isTasksBlocked = SubscriptionHelper.isTasksBlocked() diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt index 3c35b38..98367af 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt @@ -28,14 +28,14 @@ import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.AppSpacing import com.example.casera.viewmodel.OnboardingViewModel import casera.composeapp.generated.resources.* -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import com.example.casera.util.DateUtils import org.jetbrains.compose.resources.stringResource -import java.util.UUID +import kotlin.random.Random + +private fun generateId(): String = Random.nextLong().toString(36) data class OnboardingTaskTemplate( - val id: UUID = UUID.randomUUID(), + val id: String = generateId(), val icon: ImageVector, val title: String, val category: String, @@ -43,7 +43,7 @@ data class OnboardingTaskTemplate( ) data class OnboardingTaskCategory( - val id: UUID = UUID.randomUUID(), + val id: String = generateId(), val name: String, val icon: ImageVector, val color: Color, @@ -56,8 +56,8 @@ fun OnboardingFirstTaskContent( onTasksAdded: () -> Unit ) { val maxTasksAllowed = 5 - var selectedTaskIds by remember { mutableStateOf(setOf()) } - var expandedCategoryId by remember { mutableStateOf(null) } + var selectedTaskIds by remember { mutableStateOf(setOf()) } + var expandedCategoryId by remember { mutableStateOf(null) } var isCreatingTasks by remember { mutableStateOf(false) } val createTasksState by viewModel.createTasksState.collectAsState() @@ -328,10 +328,7 @@ fun OnboardingFirstTaskContent( val residences = DataManager.residences.value val residence = residences.firstOrNull() if (residence != null) { - val today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - .toString() + val today = DateUtils.getTodayString() val selectedTemplates = allTasks.filter { it.id in selectedTaskIds } val taskRequests = selectedTemplates.map { template -> @@ -397,11 +394,11 @@ fun OnboardingFirstTaskContent( @Composable private fun TaskCategorySection( category: OnboardingTaskCategory, - selectedTaskIds: Set, + selectedTaskIds: Set, isExpanded: Boolean, isAtMaxSelection: Boolean, onToggleExpand: () -> Unit, - onToggleTask: (UUID) -> Unit + onToggleTask: (String) -> Unit ) { val selectedInCategory = category.tasks.count { it.id in selectedTaskIds } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt index 4442416..a8224d9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt @@ -21,6 +21,13 @@ object DateUtils { return instant.toLocalDateTime(TimeZone.currentSystemDefault()).date } + /** + * Get today's date as an ISO string (YYYY-MM-DD) + */ + fun getTodayString(): String { + return getToday().toString() + } + /** * Format a date string (YYYY-MM-DD) to a human-readable format * Returns "Today", "Tomorrow", "Yesterday", or "Mon, Dec 15" format diff --git a/docs/TODO_AUDIT.md b/docs/TODO_AUDIT.md new file mode 100644 index 0000000..73b36a5 --- /dev/null +++ b/docs/TODO_AUDIT.md @@ -0,0 +1,191 @@ +# TODO Audit - Incomplete Functionality + +This document tracks all incomplete functionality, TODOs, and missing features across the iOS and Android/Kotlin codebases. + +**Last Updated:** December 3, 2024 + +--- + +## iOS (SwiftUI) + +### 1. Push Notification Navigation + +**File:** `iosApp/iosApp/PushNotifications/PushNotificationManager.swift` + +Three TODO items related to deep-linking from push notifications: + +```swift +// TODO: Navigate to specific residence +// TODO: Navigate to specific task +// TODO: Navigate to specific contractor +``` + +**Status:** Push notifications are received but tapping them doesn't navigate to the relevant screen. + +**Priority:** Medium - Improves user experience when responding to notifications. + +--- + +### 2. File/Document Download + +**File:** `iosApp/iosApp/Documents/DocumentDetailView.swift` + +```swift +// TODO: Implement file download functionality +``` + +**Status:** Document viewing is partially implemented, but users cannot download/save documents to their device. + +**Priority:** Medium - Documents can be viewed but not saved locally. + +--- + +### 3. Subscription Upgrade Flow + +**File:** `iosApp/iosApp/Subscription/FeatureComparisonView.swift` + +```swift +// TODO: Implement upgrade functionality +``` + +**Status:** Feature comparison UI exists but the actual upgrade/purchase flow is not connected. + +**Priority:** High - Required for monetization. + +--- + +### 4. Widget App Groups + +**File:** `iosApp/TaskWidgetExample.swift` + +```swift +// TODO: Implement App Groups or shared container for widget data access. +``` + +**Status:** Widget shell exists but cannot access shared app data. Widgets always show empty state. + +**Priority:** Low - Widgets are a nice-to-have feature. + +--- + +## Android/Kotlin (Compose Multiplatform) + +### 1. Document Download + +**File:** `composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt` + +```kotlin +// TODO: Download functionality +``` + +**Status:** Same as iOS - documents can be viewed but not downloaded. + +**Priority:** Medium + +--- + +### 2. Subscription Navigation from Residences + +**File:** `composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidencesScreen.kt` + +```kotlin +// TODO: Navigate to subscription/upgrade screen +``` + +**Status:** When user hits residence limit, there's no navigation to the upgrade screen. + +**Priority:** High - Blocks user from upgrading when hitting limits. + +--- + +### 3. Subscription Navigation from Residence Detail + +**File:** `composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt` + +```kotlin +// TODO: Navigate to subscription screen +``` + +**Status:** Same issue - hitting task limit doesn't navigate to upgrade. + +**Priority:** High + +--- + +### 4. Profile Update Disabled + +**File:** `composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt` + +```kotlin +// Update profile button is disabled/not implemented +``` + +**Status:** Profile editing UI exists but the save/update functionality may be incomplete. + +**Priority:** Medium - Users expect to be able to edit their profile. + +--- + +### 5. Contractor Favorite Toggle + +**File:** `composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt` + +```kotlin +// Contractor favorite toggle not fully implemented +``` + +**Status:** Favorite toggle button exists on contractor cards but may not persist correctly. + +**Priority:** Low + +--- + +### 6. Platform-Specific Image Pickers + +**Files:** +- `composeApp/src/jvmMain/kotlin/ImagePicker.jvm.kt` +- `composeApp/src/wasmJsMain/kotlin/ImagePicker.wasmJs.kt` +- `composeApp/src/jsMain/kotlin/ImagePicker.js.kt` + +```kotlin +// TODO: Implement for Desktop +// TODO: Implement for WASM +// TODO: Implement for JS +``` + +**Status:** Image picker only works on mobile (Android/iOS). Desktop and web targets show placeholder implementations. + +**Priority:** Low - Mobile is primary target. + +--- + +## Summary by Priority + +### High Priority +1. Subscription navigation from ResidencesScreen (Android) +2. Subscription navigation from ResidenceDetailScreen (Android) +3. Subscription upgrade flow (iOS) + +### Medium Priority +4. Push notification navigation (iOS) +5. Document download (iOS & Android) +6. Profile update functionality (Android) + +### Low Priority +7. Contractor favorite toggle (Android) +8. Widget App Groups (iOS) +9. Platform-specific image pickers (Desktop/Web) + +--- + +## Recommendations + +1. **Subscription Flow (High):** Both platforms need proper navigation to upgrade screens when users hit feature limits. This is critical for monetization. + +2. **Push Notification Deep Links (Medium):** iOS push notification taps should navigate to the relevant residence/task/contractor detail screen. + +3. **Document Download (Medium):** Implement share sheet / file saving for both platforms. + +4. **Profile Update (Medium):** Verify the profile update API call is connected and working. + +5. **Low Priority Items:** Widget, desktop/web image pickers, and contractor favorites can be addressed in future iterations. diff --git a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift index c75e341..143b73b 100644 --- a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift +++ b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift @@ -172,6 +172,65 @@ struct AccessibilityIdentifiers { static let downloadButton = "DocumentDetail.DownloadButton" } + // MARK: - Onboarding + struct Onboarding { + // Welcome Screen + static let welcomeTitle = "Onboarding.WelcomeTitle" + static let startFreshButton = "Onboarding.StartFreshButton" + static let joinExistingButton = "Onboarding.JoinExistingButton" + static let loginButton = "Onboarding.LoginButton" + + // Value Props Screen + static let valuePropsTitle = "Onboarding.ValuePropsTitle" + static let valuePropsNextButton = "Onboarding.ValuePropsNextButton" + + // Name Residence Screen + static let nameResidenceTitle = "Onboarding.NameResidenceTitle" + static let residenceNameField = "Onboarding.ResidenceNameField" + static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton" + + // Create Account Screen + static let createAccountTitle = "Onboarding.CreateAccountTitle" + static let appleSignInButton = "Onboarding.AppleSignInButton" + static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton" + static let usernameField = "Onboarding.UsernameField" + static let emailField = "Onboarding.EmailField" + static let passwordField = "Onboarding.PasswordField" + static let confirmPasswordField = "Onboarding.ConfirmPasswordField" + static let createAccountButton = "Onboarding.CreateAccountButton" + static let loginLinkButton = "Onboarding.LoginLinkButton" + + // Verify Email Screen + static let verifyEmailTitle = "Onboarding.VerifyEmailTitle" + static let verificationCodeField = "Onboarding.VerificationCodeField" + static let verifyButton = "Onboarding.VerifyButton" + + // Join Residence Screen + static let joinResidenceTitle = "Onboarding.JoinResidenceTitle" + static let shareCodeField = "Onboarding.ShareCodeField" + static let joinResidenceButton = "Onboarding.JoinResidenceButton" + + // First Task Screen + static let firstTaskTitle = "Onboarding.FirstTaskTitle" + static let taskSelectionCounter = "Onboarding.TaskSelectionCounter" + static let addPopularTasksButton = "Onboarding.AddPopularTasksButton" + static let addTasksContinueButton = "Onboarding.AddTasksContinueButton" + static let taskCategorySection = "Onboarding.TaskCategorySection" + static let taskTemplateRow = "Onboarding.TaskTemplateRow" + + // Subscription Screen + static let subscriptionTitle = "Onboarding.SubscriptionTitle" + static let yearlyPlanCard = "Onboarding.YearlyPlanCard" + static let monthlyPlanCard = "Onboarding.MonthlyPlanCard" + static let startTrialButton = "Onboarding.StartTrialButton" + static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton" + + // Navigation + static let backButton = "Onboarding.BackButton" + static let skipButton = "Onboarding.SkipButton" + static let progressIndicator = "Onboarding.ProgressIndicator" + } + // MARK: - Profile struct Profile { static let logoutButton = "Profile.LogoutButton" diff --git a/iosApp/CaseraUITests/Suite0_OnboardingTests.swift b/iosApp/CaseraUITests/Suite0_OnboardingTests.swift new file mode 100644 index 0000000..fc2e326 --- /dev/null +++ b/iosApp/CaseraUITests/Suite0_OnboardingTests.swift @@ -0,0 +1,151 @@ +import XCTest + +/// Onboarding flow tests +/// +/// SETUP REQUIREMENTS: +/// This test suite requires the app to be UNINSTALLED before running. +/// Add a Pre-action script to the CaseraUITests scheme (Edit Scheme → Test → Pre-actions): +/// /usr/bin/xcrun simctl uninstall booted com.tt.casera.CaseraDev +/// exit 0 +/// +/// There is ONE fresh-install test that runs the complete onboarding flow. +/// Additional tests for returning users (login screen) can run without fresh install. +final class Suite0_OnboardingTests: XCTestCase { + var app: XCUIApplication! + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + sleep(2) + } + + override func tearDownWithError() throws { + app.terminate() + app = nil + } + + func test_onboarding() { + let app = XCUIApplication() + app.activate() + + sleep(3) + + let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard") + springboardApp/*@START_MENU_TOKEN@*/.buttons["Allow"]/*[[".otherElements.buttons[\"Allow\"]",".buttons[\"Allow\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + sleep(1) + + app/*@START_MENU_TOKEN@*/.buttons["Onboarding.StartFreshButton"]/*[[".buttons",".containing(.staticText, identifier: \"Start Fresh\")",".containing(.image, identifier: \"icon\")",".otherElements",".buttons[\"Start Fresh\"]",".buttons[\"Onboarding.StartFreshButton\"]"],[[[-1,5],[-1,4],[-1,3,2],[-1,0,1]],[[-1,2],[-1,1]],[[-1,5],[-1,4]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + sleep(1) + app.cells/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.swipeLeft() + + sleep(1) + app/*@START_MENU_TOKEN@*/.staticTexts["Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet."]/*[[".otherElements.staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]",".staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft() + + sleep(1) + app/*@START_MENU_TOKEN@*/.staticTexts["Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly."]/*[[".otherElements.staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]",".staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's covered—instantly.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft() + sleep(1) + + app/*@START_MENU_TOKEN@*/.staticTexts["I'm Ready!"]/*[[".buttons[\"I'm Ready!\"].staticTexts",".buttons.staticTexts[\"I'm Ready!\"]",".staticTexts[\"I'm Ready!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + sleep(1) + app/*@START_MENU_TOKEN@*/.textFields["Onboarding.ResidenceNameField"]/*[[".otherElements",".textFields[\"Xcuites\"]",".textFields[\"The Smith Residence\"]",".textFields[\"Onboarding.ResidenceNameField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest") + app/*@START_MENU_TOKEN@*/.staticTexts["That's Perfect!"]/*[[".buttons[\"Onboarding.NameResidenceContinueButton\"].staticTexts",".buttons.staticTexts[\"That's Perfect!\"]",".staticTexts[\"That's Perfect!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + app/*@START_MENU_TOKEN@*/.staticTexts["Create Account with Email"]/*[[".buttons",".staticTexts",".staticTexts[\"Create Account with Email\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + sleep(1) + let scrollViewsQuery = app.scrollViews + let element = scrollViewsQuery/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/ + element.tap() + app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements.textFields[\"Username\"]",".textFields[\"Username\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements",".textFields[\"xcuitest\"]",".textFields[\"Username\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest") + scrollViewsQuery/*@START_MENU_TOKEN@*/.containing(.other, identifier: nil).firstMatch/*[[".element(boundBy: 0)",".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() + + let element2 = app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements.textFields[\"Email\"]",".textFields[\"Email\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element2.tap() + element2.tap() + app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements",".textFields[\"xcuitest@treymail.com\"]",".textFields[\"Email\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest@treymail.com") + + let element3 = app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements.secureTextFields[\"Password\"]",".secureTextFields[\"Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element3.tap() + element3.tap() + app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements",".secureTextFields[\"••••••••\"]",".secureTextFields[\"Password\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("12345678") + + let element4 = app/*@START_MENU_TOKEN@*/.secureTextFields["Confirm Password"]/*[[".otherElements.secureTextFields[\"Confirm Password\"]",".secureTextFields[\"Confirm Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element4.tap() + element4.tap() + element4.typeText("12345678") + element.swipeUp() + app/*@START_MENU_TOKEN@*/.buttons["Onboarding.CreateAccountButton"]/*[[".otherElements",".buttons[\"Create Account\"]",".buttons[\"Onboarding.CreateAccountButton\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + + sleep(1) + let element5 = app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element5.tap() + element5.tap() + app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"123456\"]",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("123456") + sleep(1) + + app/*@START_MENU_TOKEN@*/.images["chevron.up"]/*[[".buttons",".images[\"Go Up\"]",".images[\"chevron.up\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + sleep(1) + app/*@START_MENU_TOKEN@*/.buttons["HVAC & Climate"]/*[[".buttons",".containing(.staticText, identifier: \"HVAC & Climate\")",".containing(.image, identifier: \"thermometer.medium\")",".otherElements.buttons[\"HVAC & Climate\"]",".buttons[\"HVAC & Climate\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp() + sleep(1) + + app/*@START_MENU_TOKEN@*/.staticTexts["Add Most Popular"]/*[[".buttons[\"Add Most Popular\"].staticTexts",".buttons.staticTexts[\"Add Most Popular\"]",".staticTexts[\"Add Most Popular\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + app/*@START_MENU_TOKEN@*/.buttons["Add 5 Tasks & Continue"]/*[[".buttons",".containing(.image, identifier: \"arrow.right\")",".containing(.staticText, identifier: \"Add 5 Tasks & Continue\")",".otherElements.buttons[\"Add 5 Tasks & Continue\"]",".buttons[\"Add 5 Tasks & Continue\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + sleep(1) + app/*@START_MENU_TOKEN@*/.staticTexts["All your warranties, receipts, and manuals in one searchable place"]/*[[".otherElements.staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]",".staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp() + sleep(1) + + app/*@START_MENU_TOKEN@*/.buttons["Continue with Free"]/*[[".otherElements.buttons[\"Continue with Free\"]",".buttons[\"Continue with Free\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + sleep(2) + let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible") + + let xcuitestResidence = app.staticTexts["xcuitest"].waitForExistence(timeout: 10) + XCTAssertTrue(xcuitestResidence, "Residence should appear in list") + + app/*@START_MENU_TOKEN@*/.images["checkmark.circle.fill"]/*[[".buttons[\"checkmark.circle.fill\"].images",".buttons",".images[\"selected\"]",".images[\"checkmark.circle.fill\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + + let taskOne = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "HVAC")).firstMatch + XCTAssertTrue(taskOne.waitForExistence(timeout: 10), "HVAC task should appear in list") + + let taskTwo = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Leaks")).firstMatch + XCTAssertTrue(taskTwo.waitForExistence(timeout: 10), "Leaks task should appear in list") + + let taskThree = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Coils")).firstMatch + XCTAssertTrue(taskThree.waitForExistence(timeout: 10), "Coils task should appear in list") + + + // Try profile tab logout + let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch + if profileTab.exists && profileTab.isHittable { + profileTab.tap() + + let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch + if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable { + logoutButton.tap() + + // Handle confirmation alert + let alertLogout = app.alerts.buttons["Log Out"] + if alertLogout.waitForExistence(timeout: 2) { + alertLogout.tap() + } + } + } + + // Try verification screen logout + let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch + if verifyLogout.exists && verifyLogout.isHittable { + verifyLogout.tap() + } + + // Wait for login screen + _ = app.staticTexts["Welcome Back"].waitForExistence(timeout: 5) + } +} diff --git a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift b/iosApp/CaseraUITests/Suite1_RegistrationTests.swift index f176593..3aca79a 100644 --- a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift +++ b/iosApp/CaseraUITests/Suite1_RegistrationTests.swift @@ -34,6 +34,8 @@ final class Suite1_RegistrationTests: XCTestCase { // STRICT: Must be on login screen before each test XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen") + + app.swipeUp() } override func tearDownWithError() throws { @@ -78,25 +80,26 @@ final class Suite1_RegistrationTests: XCTestCase { /// Navigate to registration screen with strict verification /// Note: Registration is presented as a sheet, so login screen elements still exist underneath private func navigateToRegistration() { - // PRECONDITION: Must be on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration") - - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen") - XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable") - - dismissKeyboard() - signUpButton.tap() - - // STRICT: Verify registration screen appeared (shown as sheet) - // Note: Login screen still exists underneath the sheet, so we verify registration elements instead - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear") - XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable") - - // STRICT: The Sign Up button should no longer be hittable (covered by sheet) - XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet") + app.swipeUp() + // PRECONDITION: Must be on login screen + let welcomeText = app.staticTexts["Welcome Back"] + XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration") + + let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch + XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen") + XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable") + + dismissKeyboard() + signUpButton.tap() + + // STRICT: Verify registration screen appeared (shown as sheet) + // Note: Login screen still exists underneath the sheet, so we verify registration elements instead + let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] + XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear") + XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable") + + // STRICT: The Sign Up button should no longer be hittable (covered by sheet) + XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet") } /// Dismisses iOS Strong Password suggestion overlay diff --git a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift b/iosApp/CaseraUITests/Suite3_ResidenceTests.swift index 4b0f51f..efc7f4c 100644 --- a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift +++ b/iosApp/CaseraUITests/Suite3_ResidenceTests.swift @@ -172,10 +172,6 @@ final class Suite3_ResidenceTests: XCTestCase { sleep(1) } - // Scroll down to see more fields - app.swipeUp() - sleep(1) - // Fill address fields - MUST exist for residence let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch XCTAssertTrue(streetField.exists, "Street field should exist in residence form") @@ -192,11 +188,15 @@ final class Suite3_ResidenceTests: XCTestCase { stateField.tap() stateField.typeText("TS") - let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch + let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Postal'")).firstMatch XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form") postalField.tap() postalField.typeText("12345") + // Scroll down to see more fields + app.swipeUp() + sleep(1) + // Save let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch XCTAssertTrue(saveButton.exists, "Save button should exist") diff --git a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift b/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift index 8f9b06d..ad9c581 100644 --- a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift +++ b/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift @@ -358,14 +358,10 @@ final class Suite4_ComprehensiveResidenceTests: XCTestCase { // Edit name let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch if nameField.exists { - nameField.tap() - // Clear existing text - nameField.tap() - sleep(1) - nameField.tap() - sleep(1) - app.menuItems["Select All"].tap() - sleep(1) + let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element.tap() + element.tap() + app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() nameField.typeText(newName) // Save diff --git a/iosApp/CaseraUITests/Suite7_ContractorTests.swift b/iosApp/CaseraUITests/Suite7_ContractorTests.swift index b4aba55..84fbe40 100644 --- a/iosApp/CaseraUITests/Suite7_ContractorTests.swift +++ b/iosApp/CaseraUITests/Suite7_ContractorTests.swift @@ -519,10 +519,6 @@ final class Suite7_ContractorTests: XCTestCase { phoneField.typeText(newPhone) } - // Scroll to more fields - app.swipeUp() - sleep(1) - // Update email let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch if emailField.exists { @@ -558,10 +554,6 @@ final class Suite7_ContractorTests: XCTestCase { } } - // Scroll to save button - app.swipeUp() - sleep(1) - // Save (when editing, button should say "Save") let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor") diff --git a/iosApp/CaseraUITests/UITestHelpers.swift b/iosApp/CaseraUITests/UITestHelpers.swift index acc3285..f617616 100644 --- a/iosApp/CaseraUITests/UITestHelpers.swift +++ b/iosApp/CaseraUITests/UITestHelpers.swift @@ -43,6 +43,13 @@ struct UITestHelpers { } } } + + // if user is on verify screen after previous test + let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch + if logoutButton.exists { + logoutButton.tap() + sleep(2) + } // Verify we're back on login screen XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Failed to log out - Welcome Back screen should appear after logout") diff --git a/iosApp/TaskWidgetExample.swift b/iosApp/TaskWidgetExample.swift index 7ef5242..98e8082 100644 --- a/iosApp/TaskWidgetExample.swift +++ b/iosApp/TaskWidgetExample.swift @@ -15,7 +15,9 @@ struct TaskWidgetProvider: TimelineProvider { } func getSnapshot(in context: Context, completion: @escaping (TaskWidgetEntry) -> ()) { - let tasks = DataCache.shared.allTasks.value as? [CustomTask] ?? [] + // Note: Widgets run in a separate process and can't access shared app state directly. + // TODO: Implement App Groups or shared container for widget data access. + let tasks: [CustomTask] = [] let entry = TaskWidgetEntry( date: Date(), tasks: Array(tasks.prefix(5)) @@ -24,7 +26,9 @@ struct TaskWidgetProvider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - let tasks = DataCache.shared.allTasks.value as? [CustomTask] ?? [] + // Note: Widgets run in a separate process and can't access shared app state directly. + // TODO: Implement App Groups or shared container for widget data access. + let tasks: [CustomTask] = [] let entry = TaskWidgetEntry( date: Date(), tasks: Array(tasks.prefix(5)) diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index 956afc9..019379a 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -12,6 +12,7 @@ struct ContractorFormSheet: View { @Environment(\.dismiss) private var dismiss @StateObject private var viewModel = ContractorViewModel() @StateObject private var residenceViewModel = ResidenceViewModel() + @ObservedObject private var dataManager = DataManagerObservable.shared let contractor: Contractor? let onSave: () -> Void @@ -41,7 +42,7 @@ struct ContractorFormSheet: View { @FocusState private var focusedField: ContractorFormField? private var specialties: [ContractorSpecialty] { - return DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] ?? [] + return dataManager.contractorSpecialties } private var canSave: Bool { diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index a08ff40..ac953ad 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -4,6 +4,7 @@ import ComposeApp struct ContractorsListView: View { @StateObject private var viewModel = ContractorViewModel() @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared + @ObservedObject private var dataManager = DataManagerObservable.shared @State private var searchText = "" @State private var showingAddSheet = false @State private var selectedSpecialty: String? = nil @@ -11,8 +12,8 @@ struct ContractorsListView: View { @State private var showSpecialtyFilter = false @State private var showingUpgradePrompt = false - // Lookups from DataCache - @State private var contractorSpecialties: [ContractorSpecialty] = [] + // Lookups from DataManagerObservable + private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties } var specialties: [String] { contractorSpecialties.map { $0.name } @@ -171,9 +172,9 @@ struct ContractorsListView: View { } .onAppear { loadContractors() - loadContractorSpecialties() } // No need for onChange on searchText - filtering is client-side + // Contractor specialties are loaded from DataManagerObservable } private func loadContractors(forceRefresh: Bool = false) { @@ -181,23 +182,6 @@ struct ContractorsListView: View { viewModel.loadContractors(forceRefresh: forceRefresh) } - private func loadContractorSpecialties() { - Task { - // Small delay to ensure DataCache is populated - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - - await MainActor.run { - if let specialties = DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] { - self.contractorSpecialties = specialties - print("✅ ContractorsList: Loaded \(specialties.count) contractor specialties") - } else { - print("❌ ContractorsList: Failed to load contractor specialties from DataCache") - self.contractorSpecialties = [] - } - } - } - } - private func toggleFavorite(_ id: Int32) { viewModel.toggleFavorite(id: id) { success in if success { diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index a066088..dbba45e 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -94,6 +94,10 @@ class DataManagerObservable: ObservableObject { await MainActor.run { self.authToken = token self.isAuthenticated = token != nil + // Clear widget cache on logout + if token == nil { + WidgetDataManager.shared.clearCache() + } } } } @@ -164,6 +168,10 @@ class DataManagerObservable: ObservableObject { for await tasks in DataManager.shared.allTasks { await MainActor.run { self.allTasks = tasks + // Save to widget shared container + if let tasks = tasks { + WidgetDataManager.shared.saveTasks(from: tasks) + } } } } @@ -349,26 +357,52 @@ class DataManagerObservable: ObservableObject { // MARK: - Map Conversion Helpers /// Convert Kotlin Map to Swift [Int32: V] + /// Uses ObjectIdentifier-based iteration to avoid Swift bridging issues with KotlinInt keys private func convertIntMap(_ kotlinMap: Any?) -> [Int32: V] { - guard let map = kotlinMap as? [KotlinInt: V] else { + guard let kotlinMap = kotlinMap else { return [:] } + var result: [Int32: V] = [:] - for (key, value) in map { - result[key.int32Value] = value + + // Cast to NSDictionary to avoid Swift's strict type bridging + // which can crash when iterating [KotlinInt: V] dictionaries + let nsDict = kotlinMap as! NSDictionary + + for key in nsDict.allKeys { + guard let value = nsDict[key], let typedValue = value as? V else { continue } + + // Extract the int value from whatever key type we have + if let kotlinKey = key as? KotlinInt { + result[kotlinKey.int32Value] = typedValue + } else if let nsNumberKey = key as? NSNumber { + result[nsNumberKey.int32Value] = typedValue + } } + return result } /// Convert Kotlin Map> to Swift [Int32: [V]] private func convertIntArrayMap(_ kotlinMap: Any?) -> [Int32: [V]] { - guard let map = kotlinMap as? [KotlinInt: [V]] else { + guard let kotlinMap = kotlinMap else { return [:] } + var result: [Int32: [V]] = [:] - for (key, value) in map { - result[key.int32Value] = value + + let nsDict = kotlinMap as! NSDictionary + + for key in nsDict.allKeys { + guard let value = nsDict[key], let typedValue = value as? [V] else { continue } + + if let kotlinKey = key as? KotlinInt { + result[kotlinKey.int32Value] = typedValue + } else if let nsNumberKey = key as? NSNumber { + result[nsNumberKey.int32Value] = typedValue + } } + return result } diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index f90dc0d..f7d2af1 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -10,6 +10,11 @@ struct DocumentDetailView: View { @State private var showImageViewer = false @State private var selectedImageIndex = 0 @State private var deleteSucceeded = false + @State private var isDownloading = false + @State private var downloadProgress: Double = 0 + @State private var downloadError: String? + @State private var downloadedFileURL: URL? + @State private var showShareSheet = false var body: some View { ZStack { @@ -99,6 +104,87 @@ struct DocumentDetailView: View { ) } } + .sheet(isPresented: $showShareSheet) { + if let fileURL = downloadedFileURL { + ShareSheet(activityItems: [fileURL]) + } + } + } + + // MARK: - Download File + + private func downloadFile(document: Document) { + guard let fileUrl = document.fileUrl else { + downloadError = "No file URL available" + return + } + + guard let token = TokenStorage.shared.getToken() else { + downloadError = "Not authenticated" + return + } + + isDownloading = true + downloadError = nil + + Task { + do { + // Build full URL + let baseURL = ApiClient.shared.getMediaBaseUrl() + let fullURLString = baseURL + fileUrl + + guard let url = URL(string: fullURLString) else { + await MainActor.run { + downloadError = "Invalid URL" + isDownloading = false + } + return + } + + // Create authenticated request + var request = URLRequest(url: url) + request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + + // Download the file + let (tempURL, response) = try await URLSession.shared.download(for: request) + + // Check response status + if let httpResponse = response as? HTTPURLResponse { + guard (200...299).contains(httpResponse.statusCode) else { + await MainActor.run { + downloadError = "Download failed: HTTP \(httpResponse.statusCode)" + isDownloading = false + } + return + } + } + + // Determine filename + let filename = document.title.replacingOccurrences(of: " ", with: "_") + "." + (document.fileType ?? "file") + + // Move to a permanent location + let documentsPath = FileManager.default.temporaryDirectory + let destinationURL = documentsPath.appendingPathComponent(filename) + + // Remove existing file if present + try? FileManager.default.removeItem(at: destinationURL) + + // Move downloaded file + try FileManager.default.moveItem(at: tempURL, to: destinationURL) + + await MainActor.run { + downloadedFileURL = destinationURL + isDownloading = false + showShareSheet = true + } + + } catch { + await MainActor.run { + downloadError = "Download failed: \(error.localizedDescription)" + isDownloading = false + } + } + } } @ViewBuilder @@ -290,18 +376,32 @@ struct DocumentDetailView: View { } Button(action: { - // TODO: Download file + downloadFile(document: document) }) { HStack { - Image(systemName: "arrow.down.circle") - Text(L10n.Documents.downloadFile) + if isDownloading { + ProgressView() + .tint(.white) + .scaleEffect(0.8) + Text("Downloading...") + } else { + Image(systemName: "arrow.down.circle") + Text(L10n.Documents.downloadFile) + } } .frame(maxWidth: .infinity) .padding() - .background(Color.blue) + .background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary) .foregroundColor(.white) .cornerRadius(8) } + .disabled(isDownloading) + + if let error = downloadError { + Text(error) + .font(.caption) + .foregroundColor(Color.appError) + } } .padding() .background(Color(.systemBackground)) @@ -424,3 +524,19 @@ struct DocumentDetailView: View { return formatter.string(fromByteCount: Int64(bytes)) } } + +// MARK: - Share Sheet + +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index c75e341..143b73b 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -172,6 +172,65 @@ struct AccessibilityIdentifiers { static let downloadButton = "DocumentDetail.DownloadButton" } + // MARK: - Onboarding + struct Onboarding { + // Welcome Screen + static let welcomeTitle = "Onboarding.WelcomeTitle" + static let startFreshButton = "Onboarding.StartFreshButton" + static let joinExistingButton = "Onboarding.JoinExistingButton" + static let loginButton = "Onboarding.LoginButton" + + // Value Props Screen + static let valuePropsTitle = "Onboarding.ValuePropsTitle" + static let valuePropsNextButton = "Onboarding.ValuePropsNextButton" + + // Name Residence Screen + static let nameResidenceTitle = "Onboarding.NameResidenceTitle" + static let residenceNameField = "Onboarding.ResidenceNameField" + static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton" + + // Create Account Screen + static let createAccountTitle = "Onboarding.CreateAccountTitle" + static let appleSignInButton = "Onboarding.AppleSignInButton" + static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton" + static let usernameField = "Onboarding.UsernameField" + static let emailField = "Onboarding.EmailField" + static let passwordField = "Onboarding.PasswordField" + static let confirmPasswordField = "Onboarding.ConfirmPasswordField" + static let createAccountButton = "Onboarding.CreateAccountButton" + static let loginLinkButton = "Onboarding.LoginLinkButton" + + // Verify Email Screen + static let verifyEmailTitle = "Onboarding.VerifyEmailTitle" + static let verificationCodeField = "Onboarding.VerificationCodeField" + static let verifyButton = "Onboarding.VerifyButton" + + // Join Residence Screen + static let joinResidenceTitle = "Onboarding.JoinResidenceTitle" + static let shareCodeField = "Onboarding.ShareCodeField" + static let joinResidenceButton = "Onboarding.JoinResidenceButton" + + // First Task Screen + static let firstTaskTitle = "Onboarding.FirstTaskTitle" + static let taskSelectionCounter = "Onboarding.TaskSelectionCounter" + static let addPopularTasksButton = "Onboarding.AddPopularTasksButton" + static let addTasksContinueButton = "Onboarding.AddTasksContinueButton" + static let taskCategorySection = "Onboarding.TaskCategorySection" + static let taskTemplateRow = "Onboarding.TaskTemplateRow" + + // Subscription Screen + static let subscriptionTitle = "Onboarding.SubscriptionTitle" + static let yearlyPlanCard = "Onboarding.YearlyPlanCard" + static let monthlyPlanCard = "Onboarding.MonthlyPlanCard" + static let startTrialButton = "Onboarding.StartTrialButton" + static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton" + + // Navigation + static let backButton = "Onboarding.BackButton" + static let skipButton = "Onboarding.SkipButton" + static let progressIndicator = "Onboarding.ProgressIndicator" + } + // MARK: - Profile struct Profile { static let logoutButton = "Profile.LogoutButton" diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 6aff7ff..428fefc 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -16890,6 +16890,9 @@ "Done" : { "comment" : "A button that dismisses an image viewer sheet.", "isCommentAutoGenerated" : true + }, + "Downloading..." : { + }, "Edit" : { "comment" : "A label for an edit action.", @@ -29458,10 +29461,6 @@ }, "Unarchive Task" : { - }, - "Upgrade to Pro" : { - "comment" : "A button label that says \"Upgrade to Pro\".", - "isCommentAutoGenerated" : true }, "Upgrade to Pro for unlimited access" : { "comment" : "A description of the benefit of upgrading to the Pro plan.", diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 16ca740..39d1343 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -78,19 +78,6 @@ class LoginViewModel: ObservableObject { _ = 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?(self.isVerified) } else if let error = result as? ApiResultError { diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift index ad8d829..7170070 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift @@ -92,9 +92,14 @@ struct OnboardingCoordinator: View { isPrimary: KotlinBoolean(bool: true) ) - residenceViewModel.createResidence(request: request) { success in - print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")") + residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in self.isCreatingResidence = false + if let residence = residence { + print("🏠 ONBOARDING: Residence created successfully with ID: \(residence.id)") + self.onboardingState.createdResidenceId = residence.id + } else { + print("🏠 ONBOARDING: Residence creation FAILED") + } // Navigate regardless of success - user can create residence later if needed self.goForward(to: step) } diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 1ff84ab..5968dd9 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -43,6 +43,7 @@ struct OnboardingCreateAccountContent: View { .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle) Text("Your data will be synced across devices") .font(.subheadline) @@ -121,6 +122,7 @@ struct OnboardingCreateAccountContent: View { .background(Color.appPrimary.opacity(0.1)) .cornerRadius(AppRadius.md) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton) } else { // Expanded form VStack(spacing: AppSpacing.md) { @@ -188,6 +190,7 @@ struct OnboardingCreateAccountContent: View { .cornerRadius(AppRadius.md) .shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton) .disabled(!isFormValid || viewModel.isLoading) } .transition(.opacity.combined(with: .move(edge: .top))) diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index f9ed1cb..9eaa2e3 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -7,6 +7,8 @@ struct OnboardingFirstTaskContent: View { var onTaskAdded: () -> Void @StateObject private var viewModel = TaskViewModel() + @ObservedObject private var dataManager = DataManagerObservable.shared + @ObservedObject private var onboardingState = OnboardingState.shared @State private var selectedTasks: Set = [] @State private var isCreatingTasks = false @State private var showCustomTaskSheet = false @@ -318,10 +320,9 @@ struct OnboardingFirstTaskContent: View { return } - // Get the first residence from cache (just created during onboarding) - guard let residences = DataCache.shared.residences.value as? [ResidenceResponse], - let residence = residences.first else { - print("🏠 ONBOARDING: No residence found in cache, skipping task creation") + // Get the residence ID from OnboardingState (set during residence creation) + guard let residenceId = onboardingState.createdResidenceId else { + print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation") onTaskAdded() return } @@ -337,27 +338,25 @@ struct OnboardingFirstTaskContent: View { dateFormatter.dateFormat = "yyyy-MM-dd" let todayString = dateFormatter.string(from: Date()) - print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residence.id)") + print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)") for template in selectedTemplates { - // Look up category ID from DataCache + // Look up category ID from DataManager let categoryId: Int32? = { - guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil } let categoryName = template.category.lowercased() - return categories.first { $0.name.lowercased() == categoryName }?.id + return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id }() - // Look up frequency ID from DataCache + // Look up frequency ID from DataManager let frequencyId: Int32? = { - guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil } let frequencyName = template.frequency.lowercased() - return frequencies.first { $0.name.lowercased() == frequencyName }?.id + return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id }() print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))") let request = TaskCreateRequest( - residenceId: residence.id, + residenceId: residenceId, title: template.title, description: nil, categoryId: categoryId.map { KotlinInt(int: $0) }, diff --git a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift index 8959a17..ee6f318 100644 --- a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift @@ -68,6 +68,7 @@ struct OnboardingNameResidenceContent: View { .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle) Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") .font(.subheadline) @@ -96,6 +97,7 @@ struct OnboardingNameResidenceContent: View { .textInputAutocapitalization(.words) .focused($isTextFieldFocused) .submitLabel(.continue) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField) .onSubmit { if isValid { onContinue() @@ -182,6 +184,7 @@ struct OnboardingNameResidenceContent: View { .cornerRadius(AppRadius.lg) .shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton) .disabled(!isValid) .padding(.horizontal, AppSpacing.xl) .padding(.bottom, AppSpacing.xxxl) diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift index 22c8e76..c8948e3 100644 --- a/iosApp/iosApp/Onboarding/OnboardingState.swift +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -18,6 +18,9 @@ class OnboardingState: ObservableObject { /// The name of the residence being created during onboarding @AppStorage("onboardingResidenceName") var pendingResidenceName: String = "" + /// The ID of the residence created during onboarding (used for task creation) + @Published var createdResidenceId: Int32? = nil + /// The user's selected intent (start fresh or join existing) - persisted @AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue @@ -86,6 +89,7 @@ class OnboardingState: ObservableObject { hasCompletedOnboarding = true isOnboardingActive = false pendingResidenceName = "" + createdResidenceId = nil userIntent = .unknown } @@ -94,6 +98,7 @@ class OnboardingState: ObservableObject { hasCompletedOnboarding = false isOnboardingActive = false pendingResidenceName = "" + createdResidenceId = nil userIntent = .unknown currentStep = .welcome } diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift index 5871adc..bf54616 100644 --- a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift @@ -32,6 +32,7 @@ struct OnboardingVerifyEmailContent: View { .font(.title2) .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle) Text("We sent a 6-digit code to your email address. Enter it below to verify your account.") .font(.subheadline) @@ -50,6 +51,7 @@ struct OnboardingVerifyEmailContent: View { .keyboardType(.numberPad) .textContentType(.oneTimeCode) .focused($isCodeFieldFocused) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField) .onChange(of: viewModel.code) { _, newValue in // Limit to 6 digits if newValue.count > 6 { @@ -124,6 +126,7 @@ struct OnboardingVerifyEmailContent: View { .cornerRadius(AppRadius.md) .shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton) .disabled(viewModel.code.count != 6 || viewModel.isLoading) .padding(.horizontal, AppSpacing.xl) .padding(.bottom, AppSpacing.xxxl) diff --git a/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift index 27dafcf..ee779fc 100644 --- a/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift @@ -28,6 +28,7 @@ struct OnboardingWelcomeView: View { .font(.largeTitle) .fontWeight(.bold) .foregroundColor(Color.appTextPrimary) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle) Text("Your home maintenance companion") .font(.title3) @@ -64,6 +65,7 @@ struct OnboardingWelcomeView: View { .cornerRadius(AppRadius.md) .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton) // Secondary CTA - Join Existing Button(action: onJoinExisting) { @@ -80,6 +82,7 @@ struct OnboardingWelcomeView: View { .background(Color.appPrimary.opacity(0.1)) .cornerRadius(AppRadius.md) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton) // Returning user login Button(action: { @@ -89,6 +92,7 @@ struct OnboardingWelcomeView: View { .font(.subheadline) .foregroundColor(Color.appTextSecondary) } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton) .padding(.top, AppSpacing.sm) } .padding(.horizontal, AppSpacing.xl) diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index 7240e15..cc94ceb 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -15,13 +15,8 @@ class RegisterViewModel: ObservableObject { @Published var errorMessage: String? @Published var isRegistered: Bool = false - // MARK: - Private Properties - private let tokenStorage: TokenStorageProtocol - // MARK: - Initialization - init(tokenStorage: TokenStorageProtocol? = nil) { - self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() - } + init() {} // MARK: - Public Methods func register() { @@ -54,16 +49,15 @@ class RegisterViewModel: ObservableObject { let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil) let result = try await APILayer.shared.register(request: request) - if let success = result as? ApiResultSuccess, let response = success.data { - let token = response.token - self.tokenStorage.saveToken(token: token) + if let success = result as? ApiResultSuccess, let _ = success.data { + // APILayer.register() now handles: + // - Setting auth token in DataManager + // - Storing token in TokenManager + // - Initializing lookups // Update AuthenticationManager - user is authenticated but NOT verified AuthenticationManager.shared.login(verified: false) - // Initialize lookups via APILayer after successful registration - _ = try? await APILayer.shared.initializeLookups() - self.isRegistered = true self.isLoading = false } else if let error = result as? ApiResultError { diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 49c7b1b..9567e46 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -3,9 +3,10 @@ import ComposeApp struct ResidenceDetailView: View { let residenceId: Int32 - + @StateObject private var viewModel = ResidenceViewModel() @StateObject private var taskViewModel = TaskViewModel() + @ObservedObject private var dataManager = DataManagerObservable.shared // Use TaskViewModel's state instead of local state private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse } @@ -15,7 +16,7 @@ struct ResidenceDetailView: View { @State private var contractors: [ContractorSummary] = [] @State private var isLoadingContractors = false @State private var contractorsError: String? - + @State private var showAddTask = false @State private var showEditResidence = false @State private var showEditTask = false @@ -37,7 +38,7 @@ struct ResidenceDetailView: View { // Check if current user is the owner of the residence private func isCurrentUserOwner(of residence: ResidenceResponse) -> Bool { - guard let currentUser = ComposeApp.DataCache.shared.currentUser.value else { + guard let currentUser = dataManager.currentUser else { return false } return Int(residence.ownerId) == Int(currentUser.id) diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index a7d1894..5baea4d 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -134,28 +134,52 @@ class ResidenceViewModel: ObservableObject { } func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) { + createResidence(request: request) { result in + completion(result != nil) + } + } + + /// Creates a residence and returns the created residence on success + func createResidence(request: ResidenceCreateRequest, completion: @escaping (ResidenceResponse?) -> Void) { isLoading = true errorMessage = nil Task { do { + print("🏠 ResidenceVM: Calling API...") let result = try await APILayer.shared.createResidence(request: request) + print("🏠 ResidenceVM: Got result: \(String(describing: result))") - 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) + await MainActor.run { + if let success = result as? ApiResultSuccess { + print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))") + if let residence = success.data { + print("🏠 ResidenceVM: Got residence with id \(residence.id)") + self.isLoading = false + completion(residence) + } else { + print("🏠 ResidenceVM: success.data is nil") + self.isLoading = false + completion(nil) + } + } else if let error = result as? ApiResultError { + print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")") + self.errorMessage = ErrorMessageParser.parse(error.message) + self.isLoading = false + completion(nil) + } else { + print("🏠 ResidenceVM: Unknown result type: \(type(of: result))") + self.isLoading = false + completion(nil) + } } } catch { - self.errorMessage = error.localizedDescription - self.isLoading = false - completion(false) + print("🏠 ResidenceVM: Exception: \(error)") + await MainActor.run { + self.errorMessage = error.localizedDescription + self.isLoading = false + completion(nil) + } } } } diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift index 19d878d..db6dbdd 100644 --- a/iosApp/iosApp/ResidenceFormView.swift +++ b/iosApp/iosApp/ResidenceFormView.swift @@ -6,10 +6,11 @@ struct ResidenceFormView: View { @Binding var isPresented: Bool var onSuccess: (() -> Void)? @StateObject private var viewModel = ResidenceViewModel() + @ObservedObject private var dataManager = DataManagerObservable.shared @FocusState private var focusedField: Field? - // Lookups from DataCache - @State private var residenceTypes: [ResidenceType] = [] + // Lookups from DataManagerObservable + private var residenceTypes: [ResidenceType] { dataManager.residenceTypes } // Form fields @State private var name: String = "" @@ -196,21 +197,10 @@ struct ResidenceFormView: View { private func loadResidenceTypes() { Task { - // Get residence types from DataCache via APILayer - let result = try? await APILayer.shared.getResidenceTypes(forceRefresh: false) - if let success = result as? ApiResultSuccess, - let types = success.data as? [ResidenceType] { - await MainActor.run { - self.residenceTypes = types - } - } else { - // Fallback to DataCache directly - await MainActor.run { - if let cached = DataCache.shared.residenceTypes.value as? [ResidenceType] { - self.residenceTypes = cached - } - } - } + // Trigger residence types refresh if needed + // Residence types are now loaded from DataManagerObservable + // Just trigger a refresh if needed + _ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false) } } diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index c4f9ad3..a0a67ba 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -26,20 +26,22 @@ class AuthenticationManager: ObservableObject { isAuthenticated = true - // Fetch current user to check verification status + // Fetch current user and initialize lookups immediately for all authenticated users Task { @MainActor in do { + // Initialize lookups right away for any authenticated user + // This fetches /static_data/ and /upgrade-triggers/ at app start + print("🚀 Initializing lookups at app start...") + _ = try await APILayer.shared.initializeLookups() + print("✅ Lookups initialized on app launch") + let result = try await APILayer.shared.getCurrentUser(forceRefresh: true) if let success = result as? ApiResultSuccess { self.isVerified = success.data?.verified ?? false - // Initialize lookups if verified + // Verify subscription entitlements with backend for verified users if self.isVerified { - _ = try await APILayer.shared.initializeLookups() - print("✅ Lookups initialized on app launch for verified user") - - // Verify subscription entitlements with backend await StoreKitManager.shared.verifyEntitlementsOnLaunch() } } else if result is ApiResultError { @@ -68,17 +70,11 @@ class AuthenticationManager: ObservableObject { func markVerified() { isVerified = true - // Initialize lookups after verification + // Lookups are already initialized at app start or during login/register + // Just verify subscription entitlements after user becomes verified Task { - do { - _ = try await APILayer.shared.initializeLookups() - print("✅ Lookups initialized after email verification") - - // Verify subscription entitlements with backend - await StoreKitManager.shared.verifyEntitlementsOnLaunch() - } catch { - print("❌ Failed to initialize lookups after verification: \(error)") - } + await StoreKitManager.shared.verifyEntitlementsOnLaunch() + print("✅ Subscription entitlements verified after email verification") } } diff --git a/iosApp/iosApp/Subscription/FeatureComparisonView.swift b/iosApp/iosApp/Subscription/FeatureComparisonView.swift index acad705..bddd883 100644 --- a/iosApp/iosApp/Subscription/FeatureComparisonView.swift +++ b/iosApp/iosApp/Subscription/FeatureComparisonView.swift @@ -1,10 +1,17 @@ import SwiftUI import ComposeApp +import StoreKit struct FeatureComparisonView: View { @Binding var isPresented: Bool @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared - + @StateObject private var storeKit = StoreKitManager.shared + @State private var showUpgradePrompt = false + @State private var selectedProduct: Product? + @State private var isProcessing = false + @State private var errorMessage: String? + @State private var showSuccessAlert = false + var body: some View { NavigationStack { ScrollView { @@ -70,20 +77,65 @@ struct FeatureComparisonView: View { .cornerRadius(AppRadius.lg) .padding(.horizontal) - // Upgrade Button - Button(action: { - // TODO: Trigger upgrade flow - isPresented = false - }) { - Text("Upgrade to Pro") - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .foregroundColor(Color.appTextOnPrimary) + // Subscription Products + if storeKit.isLoading { + ProgressView() + .tint(Color.appPrimary) .padding() - .background(Color.appPrimary) - .cornerRadius(AppRadius.md) + } else if !storeKit.products.isEmpty { + VStack(spacing: AppSpacing.md) { + ForEach(storeKit.products, id: \.id) { product in + SubscriptionButton( + product: product, + isSelected: selectedProduct?.id == product.id, + isProcessing: isProcessing, + onSelect: { + selectedProduct = product + handlePurchase(product) + } + ) + } + } + .padding(.horizontal) + } else { + // Fallback if products fail to load + Button(action: { + Task { await storeKit.loadProducts() } + }) { + Text("Retry Loading Products") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .foregroundColor(Color.appTextOnPrimary) + .padding() + .background(Color.appPrimary) + .cornerRadius(AppRadius.md) + } + .padding(.horizontal) + } + + // Error Message + if let error = errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.subheadline) + .foregroundColor(Color.appError) + } + .padding() + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + .padding(.horizontal) + } + + // Restore Purchases + Button(action: { + handleRestore() + }) { + Text("Restore Purchases") + .font(.caption) + .foregroundColor(Color.appTextSecondary) } - .padding(.horizontal) .padding(.bottom, AppSpacing.xl) } } @@ -96,8 +148,121 @@ struct FeatureComparisonView: View { } } } + .alert("Subscription Active", isPresented: $showSuccessAlert) { + Button("Done") { + isPresented = false + } + } message: { + Text("You now have full access to all Pro features!") + } + .task { + await storeKit.loadProducts() + } } } + + // MARK: - Purchase Handling + + private func handlePurchase(_ product: Product) { + isProcessing = true + errorMessage = nil + + Task { + do { + let transaction = try await storeKit.purchase(product) + + await MainActor.run { + isProcessing = false + + if transaction != nil { + showSuccessAlert = true + } + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = "Purchase failed: \(error.localizedDescription)" + } + } + } + } + + private func handleRestore() { + isProcessing = true + errorMessage = nil + + Task { + await storeKit.restorePurchases() + + await MainActor.run { + isProcessing = false + + if !storeKit.purchasedProductIDs.isEmpty { + showSuccessAlert = true + } else { + errorMessage = "No purchases found to restore" + } + } + } + } +} + +// MARK: - Subscription Button + +struct SubscriptionButton: View { + let product: Product + let isSelected: Bool + let isProcessing: Bool + let onSelect: () -> Void + + var isAnnual: Bool { + product.id.contains("annual") + } + + var savingsText: String? { + if isAnnual { + return "Save 17%" + } + return nil + } + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + .font(.headline) + .foregroundColor(Color.appTextPrimary) + + if let savings = savingsText { + Text(savings) + .font(.caption) + .foregroundColor(Color.appPrimary) + } + } + + Spacer() + + if isProcessing && isSelected { + ProgressView() + .tint(Color.appTextOnPrimary) + } else { + Text(product.displayPrice) + .font(.title3.weight(.bold)) + .foregroundColor(Color.appTextOnPrimary) + } + } + .padding() + .frame(maxWidth: .infinity) + .background(isAnnual ? Color.appPrimary : Color.appSecondary) + .cornerRadius(AppRadius.md) + .overlay( + RoundedRectangle(cornerRadius: AppRadius.md) + .stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2) + ) + } + .disabled(isProcessing) + } } struct ComparisonRow: View { diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 65bfeae..09d9a4c 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -32,6 +32,12 @@ struct SummaryCard: View { Divider() HStack(spacing: 20) { + SummaryStatView( + icon: "calendar", + value: "\(summary.totalOverdue)", + label: "Over Due" + ) + SummaryStatView( icon: "calendar", value: "\(summary.tasksDueNextWeek)", diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 2b1b62a..fa0b177 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -13,6 +13,7 @@ struct TaskFormView: View { let existingTask: TaskResponse? // nil for add mode, populated for edit mode @Binding var isPresented: Bool @StateObject private var viewModel = TaskViewModel() + @ObservedObject private var dataManager = DataManagerObservable.shared @FocusState private var focusedField: TaskFormField? private var isEditMode: Bool { @@ -32,12 +33,12 @@ struct TaskFormView: View { selectedStatus != nil } - // Lookups from DataCache - @State private var taskCategories: [TaskCategory] = [] - @State private var taskFrequencies: [TaskFrequency] = [] - @State private var taskPriorities: [TaskPriority] = [] - @State private var taskStatuses: [TaskStatus] = [] - @State private var isLoadingLookups: Bool = true + // Lookups from DataManagerObservable + private var taskCategories: [TaskCategory] { dataManager.taskCategories } + private var taskFrequencies: [TaskFrequency] { dataManager.taskFrequencies } + private var taskPriorities: [TaskPriority] { dataManager.taskPriorities } + private var taskStatuses: [TaskStatus] { dataManager.taskStatuses } + private var isLoadingLookups: Bool { !dataManager.lookupsInitialized } // Form fields @State private var selectedResidence: ResidenceResponse? @@ -254,8 +255,16 @@ struct TaskFormView: View { .disabled(!canSave || viewModel.isLoading || isLoadingLookups) } } - .task { - await loadLookups() + .onAppear { + // Set defaults when lookups are available + if dataManager.lookupsInitialized { + setDefaults() + } + } + .onChange(of: dataManager.lookupsInitialized) { initialized in + if initialized { + setDefaults() + } } .onChange(of: viewModel.taskCreated) { created in if created { @@ -280,37 +289,6 @@ struct TaskFormView: View { } } - private func loadLookups() async { - // Wait a bit for lookups to be initialized (they load on app launch or login) - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - - // Load lookups from DataCache - await MainActor.run { - if let categories = DataCache.shared.taskCategories.value as? [TaskCategory], - let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency], - let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority], - let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] { - - self.taskCategories = categories - self.taskFrequencies = frequencies - self.taskPriorities = priorities - self.taskStatuses = statuses - - print("✅ TaskFormView: Loaded lookups - Categories: \(categories.count), Frequencies: \(frequencies.count), Priorities: \(priorities.count), Statuses: \(statuses.count)") - - setDefaults() - isLoadingLookups = false - } - } - - // If lookups not loaded, retry - if taskCategories.isEmpty { - print("⏳ TaskFormView: Lookups not ready, retrying...") - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - await loadLookups() - } - } - private func setDefaults() { // Set default values if not already set if selectedCategory == nil && !taskCategories.isEmpty { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index c51ae05..220dc9f 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -18,6 +18,14 @@ struct iOSApp: App { // Initialize TokenStorage once at app startup (legacy support) TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance()) + + // Initialize lookups at app start (public endpoints, no auth required) + // This fetches /static_data/ and /upgrade-triggers/ immediately + Task { + print("🚀 Initializing lookups at app start...") + _ = try? await APILayer.shared.initializeLookups() + print("✅ Lookups initialized") + } } var body: some Scene {