Add onboarding UI tests and improve app data management
- Add Suite0_OnboardingTests with fresh install and login test flows - Add accessibility identifiers to onboarding views for UI testing - Remove deprecated DataCache in favor of unified DataManager - Update API layer to support public upgrade-triggers endpoint - Improve onboarding first task view with better date handling - Update various views with accessibility identifiers for testing - Fix subscription feature comparison view layout - Update document detail view improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<User?>(null)
|
||||
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
||||
|
||||
// Residences
|
||||
private val _residences = MutableStateFlow<List<Residence>>(emptyList())
|
||||
val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
|
||||
|
||||
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
||||
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
||||
|
||||
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
||||
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
||||
|
||||
// Tasks
|
||||
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||
|
||||
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
||||
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
||||
|
||||
// Documents
|
||||
private val _documents = MutableStateFlow<List<Document>>(emptyList())
|
||||
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
|
||||
|
||||
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
|
||||
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
||||
|
||||
// Contractors
|
||||
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
|
||||
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
|
||||
|
||||
// Lookups/Reference Data - List-based (for dropdowns/pickers)
|
||||
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
|
||||
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
|
||||
|
||||
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
|
||||
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
|
||||
|
||||
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
|
||||
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
|
||||
|
||||
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
|
||||
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses.asStateFlow()
|
||||
|
||||
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
||||
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
|
||||
|
||||
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
||||
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
|
||||
|
||||
// Lookups/Reference Data - Map-based (for O(1) ID resolution)
|
||||
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
|
||||
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
|
||||
|
||||
private val _taskFrequenciesMap = MutableStateFlow<Map<Int, TaskFrequency>>(emptyMap())
|
||||
val taskFrequenciesMap: StateFlow<Map<Int, TaskFrequency>> = _taskFrequenciesMap.asStateFlow()
|
||||
|
||||
private val _taskPrioritiesMap = MutableStateFlow<Map<Int, TaskPriority>>(emptyMap())
|
||||
val taskPrioritiesMap: StateFlow<Map<Int, TaskPriority>> = _taskPrioritiesMap.asStateFlow()
|
||||
|
||||
private val _taskStatusesMap = MutableStateFlow<Map<Int, TaskStatus>>(emptyMap())
|
||||
val taskStatusesMap: StateFlow<Map<Int, TaskStatus>> = _taskStatusesMap.asStateFlow()
|
||||
|
||||
private val _taskCategoriesMap = MutableStateFlow<Map<Int, TaskCategory>>(emptyMap())
|
||||
val taskCategoriesMap: StateFlow<Map<Int, TaskCategory>> = _taskCategoriesMap.asStateFlow()
|
||||
|
||||
private val _contractorSpecialtiesMap = MutableStateFlow<Map<Int, ContractorSpecialty>>(emptyMap())
|
||||
val contractorSpecialtiesMap: StateFlow<Map<Int, ContractorSpecialty>> = _contractorSpecialtiesMap.asStateFlow()
|
||||
|
||||
private val _lookupsInitialized = MutableStateFlow(false)
|
||||
val lookupsInitialized: StateFlow<Boolean> = _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<Long>(0L)
|
||||
val lastRefreshTime: StateFlow<Long> = _lastRefreshTime.asStateFlow()
|
||||
|
||||
private val _isCacheInitialized = MutableStateFlow(false)
|
||||
val isCacheInitialized: StateFlow<Boolean> = _isCacheInitialized.asStateFlow()
|
||||
|
||||
// Update methods
|
||||
fun updateCurrentUser(user: User?) {
|
||||
_currentUser.value = user
|
||||
}
|
||||
|
||||
fun updateResidences(residences: List<Residence>) {
|
||||
_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<Document>) {
|
||||
_documents.value = documents
|
||||
updateLastRefreshTime()
|
||||
}
|
||||
|
||||
fun updateDocumentsByResidence(residenceId: Int, documents: List<Document>) {
|
||||
_documentsByResidence.value = _documentsByResidence.value + (residenceId to documents)
|
||||
}
|
||||
|
||||
fun updateContractors(contractors: List<Contractor>) {
|
||||
_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<ResidenceType>) {
|
||||
_residenceTypes.value = types
|
||||
_residenceTypesMap.value = types.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskFrequencies(frequencies: List<TaskFrequency>) {
|
||||
_taskFrequencies.value = frequencies
|
||||
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskPriorities(priorities: List<TaskPriority>) {
|
||||
_taskPriorities.value = priorities
|
||||
_taskPrioritiesMap.value = priorities.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskStatuses(statuses: List<TaskStatus>) {
|
||||
_taskStatuses.value = statuses
|
||||
_taskStatusesMap.value = statuses.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateTaskCategories(categories: List<TaskCategory>) {
|
||||
_taskCategories.value = categories
|
||||
_taskCategoriesMap.value = categories.associateBy { it.id }
|
||||
}
|
||||
|
||||
fun updateContractorSpecialties(specialties: List<ContractorSpecialty>) {
|
||||
_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
|
||||
}
|
||||
}
|
||||
@@ -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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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<ContractorSummary>, not List<Contractor>
|
||||
// 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!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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<Unit> {
|
||||
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<AuthResponse> {
|
||||
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<Unit> {
|
||||
|
||||
@@ -121,10 +121,11 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getStaticData(token: String): ApiResult<StaticDataResponse> {
|
||||
suspend fun getStaticData(token: String? = null): ApiResult<StaticDataResponse> {
|
||||
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()) {
|
||||
|
||||
@@ -25,10 +25,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getUpgradeTriggers(token: String): ApiResult<Map<String, UpgradeTriggerData>> {
|
||||
suspend fun getUpgradeTriggers(token: String? = null): ApiResult<Map<String, UpgradeTriggerData>> {
|
||||
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()) {
|
||||
|
||||
@@ -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<TaskResponse> {
|
||||
return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String?>(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()
|
||||
|
||||
@@ -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<UUID>()) }
|
||||
var expandedCategoryId by remember { mutableStateOf<UUID?>(null) }
|
||||
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||
var expandedCategoryId by remember { mutableStateOf<String?>(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<UUID>,
|
||||
selectedTaskIds: Set<String>,
|
||||
isExpanded: Boolean,
|
||||
isAtMaxSelection: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onToggleTask: (UUID) -> Unit
|
||||
onToggleTask: (String) -> Unit
|
||||
) {
|
||||
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
191
docs/TODO_AUDIT.md
Normal file
191
docs/TODO_AUDIT.md
Normal file
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
151
iosApp/CaseraUITests/Suite0_OnboardingTests.swift
Normal file
151
iosApp/CaseraUITests/Suite0_OnboardingTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Int, V> to Swift [Int32: V]
|
||||
/// Uses ObjectIdentifier-based iteration to avoid Swift bridging issues with KotlinInt keys
|
||||
private func convertIntMap<V>(_ 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<Int, List<V>> to Swift [Int32: [V]]
|
||||
private func convertIntArrayMap<V>(_ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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<UUID> = []
|
||||
@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) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AuthResponse>, let response = success.data {
|
||||
let token = response.token
|
||||
self.tokenStorage.saveToken(token: token)
|
||||
if let success = result as? ApiResultSuccess<AuthResponse>, 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ResidenceResponse> {
|
||||
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<ResidenceResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NSArray>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user