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.
|
* 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> {
|
suspend fun initializeLookups(): ApiResult<Unit> {
|
||||||
|
val token = getToken()
|
||||||
|
|
||||||
if (DataManager.lookupsInitialized.value) {
|
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...")
|
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
|
||||||
refreshSubscriptionStatus()
|
if (token != null) {
|
||||||
|
refreshSubscriptionStatus()
|
||||||
|
}
|
||||||
return ApiResult.Success(Unit)
|
return ApiResult.Success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
|
|
||||||
try {
|
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)...")
|
println("🔄 Fetching static data (all lookups)...")
|
||||||
val staticDataResult = lookupsApi.getStaticData(token)
|
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
|
||||||
println("📦 Static data result: $staticDataResult")
|
println("📦 Static data result: $staticDataResult")
|
||||||
|
|
||||||
// Update DataManager with all lookups at once
|
// Update DataManager with all lookups at once
|
||||||
@@ -86,24 +91,11 @@ object APILayer {
|
|||||||
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subscription status to get limitationsEnabled, usage, and limits from backend
|
// Load upgrade triggers (PUBLIC - no auth required)
|
||||||
println("🔄 Fetching subscription status...")
|
|
||||||
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
|
|
||||||
println("📦 Subscription status result: $subscriptionStatusResult")
|
|
||||||
|
|
||||||
// Load upgrade triggers
|
|
||||||
println("🔄 Fetching upgrade triggers...")
|
println("🔄 Fetching upgrade triggers...")
|
||||||
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
|
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
|
||||||
println("📦 Upgrade triggers result: $upgradeTriggersResult")
|
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) {
|
if (upgradeTriggersResult is ApiResult.Success) {
|
||||||
println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers")
|
println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers")
|
||||||
DataManager.setUpgradeTriggers(upgradeTriggersResult.data)
|
DataManager.setUpgradeTriggers(upgradeTriggersResult.data)
|
||||||
@@ -112,6 +104,23 @@ object APILayer {
|
|||||||
println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}")
|
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()
|
DataManager.markLookupsInitialized()
|
||||||
return ApiResult.Success(Unit)
|
return ApiResult.Success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -887,7 +896,17 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
|
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> {
|
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 {
|
return try {
|
||||||
val response = client.get("$baseUrl/static_data/") {
|
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()) {
|
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 {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/upgrade-triggers/") {
|
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()) {
|
if (response.status.isSuccess()) {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
|
|
||||||
// DEPRECATED: These methods now use PATCH internally.
|
// DEPRECATED: These methods now use PATCH internally.
|
||||||
// They're kept for backward compatibility with existing ViewModel calls.
|
// 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> {
|
suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult<TaskResponse> {
|
||||||
return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId))
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.AddContractorDialog
|
||||||
import com.example.casera.ui.components.ApiResultHandler
|
import com.example.casera.ui.components.ApiResultHandler
|
||||||
import com.example.casera.ui.components.HandleErrors
|
import com.example.casera.ui.components.HandleErrors
|
||||||
@@ -117,7 +117,7 @@ fun ContractorDetailScreen(
|
|||||||
.background(Color(0xFFF9FAFB))
|
.background(Color(0xFFF9FAFB))
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val residences = DataCache.residences.value
|
val residences = DataManager.residences.value
|
||||||
|
|
||||||
ApiResultHandler(
|
ApiResultHandler(
|
||||||
state = contractorState,
|
state = contractorState,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import com.example.casera.network.ApiResult
|
|||||||
import com.example.casera.utils.SubscriptionHelper
|
import com.example.casera.utils.SubscriptionHelper
|
||||||
import com.example.casera.ui.subscription.UpgradePromptDialog
|
import com.example.casera.ui.subscription.UpgradePromptDialog
|
||||||
import com.example.casera.cache.SubscriptionCache
|
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 com.example.casera.util.DateUtils
|
||||||
import casera.composeapp.generated.resources.*
|
import casera.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -77,7 +77,7 @@ fun ResidenceDetailScreen(
|
|||||||
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Get current user for ownership checks
|
// 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
|
// Check if tasks are blocked (limit=0) - this hides the FAB
|
||||||
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
|
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.ui.theme.AppSpacing
|
||||||
import com.example.casera.viewmodel.OnboardingViewModel
|
import com.example.casera.viewmodel.OnboardingViewModel
|
||||||
import casera.composeapp.generated.resources.*
|
import casera.composeapp.generated.resources.*
|
||||||
import kotlinx.datetime.Clock
|
import com.example.casera.util.DateUtils
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
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(
|
data class OnboardingTaskTemplate(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: String = generateId(),
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val title: String,
|
val title: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
@@ -43,7 +43,7 @@ data class OnboardingTaskTemplate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class OnboardingTaskCategory(
|
data class OnboardingTaskCategory(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: String = generateId(),
|
||||||
val name: String,
|
val name: String,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val color: Color,
|
val color: Color,
|
||||||
@@ -56,8 +56,8 @@ fun OnboardingFirstTaskContent(
|
|||||||
onTasksAdded: () -> Unit
|
onTasksAdded: () -> Unit
|
||||||
) {
|
) {
|
||||||
val maxTasksAllowed = 5
|
val maxTasksAllowed = 5
|
||||||
var selectedTaskIds by remember { mutableStateOf(setOf<UUID>()) }
|
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var expandedCategoryId by remember { mutableStateOf<UUID?>(null) }
|
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
|
||||||
var isCreatingTasks by remember { mutableStateOf(false) }
|
var isCreatingTasks by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val createTasksState by viewModel.createTasksState.collectAsState()
|
val createTasksState by viewModel.createTasksState.collectAsState()
|
||||||
@@ -328,10 +328,7 @@ fun OnboardingFirstTaskContent(
|
|||||||
val residences = DataManager.residences.value
|
val residences = DataManager.residences.value
|
||||||
val residence = residences.firstOrNull()
|
val residence = residences.firstOrNull()
|
||||||
if (residence != null) {
|
if (residence != null) {
|
||||||
val today = Clock.System.now()
|
val today = DateUtils.getTodayString()
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault())
|
|
||||||
.date
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
|
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
|
||||||
val taskRequests = selectedTemplates.map { template ->
|
val taskRequests = selectedTemplates.map { template ->
|
||||||
@@ -397,11 +394,11 @@ fun OnboardingFirstTaskContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TaskCategorySection(
|
private fun TaskCategorySection(
|
||||||
category: OnboardingTaskCategory,
|
category: OnboardingTaskCategory,
|
||||||
selectedTaskIds: Set<UUID>,
|
selectedTaskIds: Set<String>,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
isAtMaxSelection: Boolean,
|
isAtMaxSelection: Boolean,
|
||||||
onToggleExpand: () -> Unit,
|
onToggleExpand: () -> Unit,
|
||||||
onToggleTask: (UUID) -> Unit
|
onToggleTask: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ object DateUtils {
|
|||||||
return instant.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
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
|
* Format a date string (YYYY-MM-DD) to a human-readable format
|
||||||
* Returns "Today", "Tomorrow", "Yesterday", or "Mon, Dec 15" 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"
|
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
|
// MARK: - Profile
|
||||||
struct Profile {
|
struct Profile {
|
||||||
static let logoutButton = "Profile.LogoutButton"
|
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
|
// STRICT: Must be on login screen before each test
|
||||||
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
|
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
|
||||||
|
|
||||||
|
app.swipeUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
@@ -78,25 +80,26 @@ final class Suite1_RegistrationTests: XCTestCase {
|
|||||||
/// Navigate to registration screen with strict verification
|
/// Navigate to registration screen with strict verification
|
||||||
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
|
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
|
||||||
private func navigateToRegistration() {
|
private func navigateToRegistration() {
|
||||||
// PRECONDITION: Must be on login screen
|
app.swipeUp()
|
||||||
let welcomeText = app.staticTexts["Welcome Back"]
|
// PRECONDITION: Must be on login screen
|
||||||
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
|
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
|
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.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
|
||||||
XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable")
|
XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable")
|
||||||
|
|
||||||
dismissKeyboard()
|
dismissKeyboard()
|
||||||
signUpButton.tap()
|
signUpButton.tap()
|
||||||
|
|
||||||
// STRICT: Verify registration screen appeared (shown as sheet)
|
// STRICT: Verify registration screen appeared (shown as sheet)
|
||||||
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
|
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
|
||||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
|
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
|
||||||
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
|
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
|
||||||
|
|
||||||
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
|
// 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")
|
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dismisses iOS Strong Password suggestion overlay
|
/// Dismisses iOS Strong Password suggestion overlay
|
||||||
|
|||||||
@@ -172,10 +172,6 @@ final class Suite3_ResidenceTests: XCTestCase {
|
|||||||
sleep(1)
|
sleep(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll down to see more fields
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Fill address fields - MUST exist for residence
|
// Fill address fields - MUST exist for residence
|
||||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
||||||
XCTAssertTrue(streetField.exists, "Street field should exist in residence form")
|
XCTAssertTrue(streetField.exists, "Street field should exist in residence form")
|
||||||
@@ -192,11 +188,15 @@ final class Suite3_ResidenceTests: XCTestCase {
|
|||||||
stateField.tap()
|
stateField.tap()
|
||||||
stateField.typeText("TS")
|
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")
|
XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form")
|
||||||
postalField.tap()
|
postalField.tap()
|
||||||
postalField.typeText("12345")
|
postalField.typeText("12345")
|
||||||
|
|
||||||
|
// Scroll down to see more fields
|
||||||
|
app.swipeUp()
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||||
|
|||||||
@@ -358,14 +358,10 @@ final class Suite4_ComprehensiveResidenceTests: XCTestCase {
|
|||||||
// Edit name
|
// Edit name
|
||||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||||
if nameField.exists {
|
if nameField.exists {
|
||||||
nameField.tap()
|
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
|
||||||
// Clear existing text
|
element.tap()
|
||||||
nameField.tap()
|
element.tap()
|
||||||
sleep(1)
|
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.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
nameField.typeText(newName)
|
nameField.typeText(newName)
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
|
|||||||
@@ -519,10 +519,6 @@ final class Suite7_ContractorTests: XCTestCase {
|
|||||||
phoneField.typeText(newPhone)
|
phoneField.typeText(newPhone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to more fields
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Update email
|
// Update email
|
||||||
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
|
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
|
||||||
if emailField.exists {
|
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")
|
// Save (when editing, button should say "Save")
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||||
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
|
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
|
||||||
|
|||||||
@@ -44,6 +44,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
|
// Verify we're back on login screen
|
||||||
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Failed to log out - Welcome Back screen should appear after logout")
|
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) -> ()) {
|
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(
|
let entry = TaskWidgetEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
tasks: Array(tasks.prefix(5))
|
tasks: Array(tasks.prefix(5))
|
||||||
@@ -24,7 +26,9 @@ struct TaskWidgetProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TaskWidgetEntry>) -> ()) {
|
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(
|
let entry = TaskWidgetEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
tasks: Array(tasks.prefix(5))
|
tasks: Array(tasks.prefix(5))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct ContractorFormSheet: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel = ContractorViewModel()
|
@StateObject private var viewModel = ContractorViewModel()
|
||||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||||
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
|
|
||||||
let contractor: Contractor?
|
let contractor: Contractor?
|
||||||
let onSave: () -> Void
|
let onSave: () -> Void
|
||||||
@@ -41,7 +42,7 @@ struct ContractorFormSheet: View {
|
|||||||
@FocusState private var focusedField: ContractorFormField?
|
@FocusState private var focusedField: ContractorFormField?
|
||||||
|
|
||||||
private var specialties: [ContractorSpecialty] {
|
private var specialties: [ContractorSpecialty] {
|
||||||
return DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] ?? []
|
return dataManager.contractorSpecialties
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ComposeApp
|
|||||||
struct ContractorsListView: View {
|
struct ContractorsListView: View {
|
||||||
@StateObject private var viewModel = ContractorViewModel()
|
@StateObject private var viewModel = ContractorViewModel()
|
||||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||||
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
@State private var selectedSpecialty: String? = nil
|
@State private var selectedSpecialty: String? = nil
|
||||||
@@ -11,8 +12,8 @@ struct ContractorsListView: View {
|
|||||||
@State private var showSpecialtyFilter = false
|
@State private var showSpecialtyFilter = false
|
||||||
@State private var showingUpgradePrompt = false
|
@State private var showingUpgradePrompt = false
|
||||||
|
|
||||||
// Lookups from DataCache
|
// Lookups from DataManagerObservable
|
||||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
|
||||||
|
|
||||||
var specialties: [String] {
|
var specialties: [String] {
|
||||||
contractorSpecialties.map { $0.name }
|
contractorSpecialties.map { $0.name }
|
||||||
@@ -171,9 +172,9 @@ struct ContractorsListView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadContractors()
|
loadContractors()
|
||||||
loadContractorSpecialties()
|
|
||||||
}
|
}
|
||||||
// No need for onChange on searchText - filtering is client-side
|
// No need for onChange on searchText - filtering is client-side
|
||||||
|
// Contractor specialties are loaded from DataManagerObservable
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContractors(forceRefresh: Bool = false) {
|
private func loadContractors(forceRefresh: Bool = false) {
|
||||||
@@ -181,23 +182,6 @@ struct ContractorsListView: View {
|
|||||||
viewModel.loadContractors(forceRefresh: forceRefresh)
|
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) {
|
private func toggleFavorite(_ id: Int32) {
|
||||||
viewModel.toggleFavorite(id: id) { success in
|
viewModel.toggleFavorite(id: id) { success in
|
||||||
if success {
|
if success {
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ class DataManagerObservable: ObservableObject {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.authToken = token
|
self.authToken = token
|
||||||
self.isAuthenticated = token != nil
|
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 {
|
for await tasks in DataManager.shared.allTasks {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.allTasks = tasks
|
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
|
// MARK: - Map Conversion Helpers
|
||||||
|
|
||||||
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
/// 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] {
|
private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
|
||||||
guard let map = kotlinMap as? [KotlinInt: V] else {
|
guard let kotlinMap = kotlinMap else {
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
var result: [Int32: V] = [:]
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
||||||
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
|
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
|
||||||
guard let map = kotlinMap as? [KotlinInt: [V]] else {
|
guard let kotlinMap = kotlinMap else {
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
var result: [Int32: [V]] = [:]
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ struct DocumentDetailView: View {
|
|||||||
@State private var showImageViewer = false
|
@State private var showImageViewer = false
|
||||||
@State private var selectedImageIndex = 0
|
@State private var selectedImageIndex = 0
|
||||||
@State private var deleteSucceeded = false
|
@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 {
|
var body: some View {
|
||||||
ZStack {
|
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
|
@ViewBuilder
|
||||||
@@ -290,18 +376,32 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// TODO: Download file
|
downloadFile(document: document)
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "arrow.down.circle")
|
if isDownloading {
|
||||||
Text(L10n.Documents.downloadFile)
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
Text("Downloading...")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
Text(L10n.Documents.downloadFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.blue)
|
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
.disabled(isDownloading)
|
||||||
|
|
||||||
|
if let error = downloadError {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
@@ -424,3 +524,19 @@ struct DocumentDetailView: View {
|
|||||||
return formatter.string(fromByteCount: Int64(bytes))
|
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"
|
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
|
// MARK: - Profile
|
||||||
struct Profile {
|
struct Profile {
|
||||||
static let logoutButton = "Profile.LogoutButton"
|
static let logoutButton = "Profile.LogoutButton"
|
||||||
|
|||||||
@@ -16890,6 +16890,9 @@
|
|||||||
"Done" : {
|
"Done" : {
|
||||||
"comment" : "A button that dismisses an image viewer sheet.",
|
"comment" : "A button that dismisses an image viewer sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Downloading..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Edit" : {
|
"Edit" : {
|
||||||
"comment" : "A label for an edit action.",
|
"comment" : "A label for an edit action.",
|
||||||
@@ -29458,10 +29461,6 @@
|
|||||||
},
|
},
|
||||||
"Unarchive Task" : {
|
"Unarchive Task" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Upgrade to Pro" : {
|
|
||||||
"comment" : "A button label that says \"Upgrade to Pro\".",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Upgrade to Pro for unlimited access" : {
|
"Upgrade to Pro for unlimited access" : {
|
||||||
"comment" : "A description of the benefit of upgrading to the Pro plan.",
|
"comment" : "A description of the benefit of upgrading to the Pro plan.",
|
||||||
|
|||||||
@@ -78,19 +78,6 @@ class LoginViewModel: ObservableObject {
|
|||||||
_ = try? await APILayer.shared.initializeLookups()
|
_ = 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
|
// Call login success callback
|
||||||
self.onLoginSuccess?(self.isVerified)
|
self.onLoginSuccess?(self.isVerified)
|
||||||
} else if let error = result as? ApiResultError {
|
} else if let error = result as? ApiResultError {
|
||||||
|
|||||||
@@ -92,9 +92,14 @@ struct OnboardingCoordinator: View {
|
|||||||
isPrimary: KotlinBoolean(bool: true)
|
isPrimary: KotlinBoolean(bool: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
residenceViewModel.createResidence(request: request) { success in
|
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||||
print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")")
|
|
||||||
self.isCreatingResidence = false
|
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
|
// Navigate regardless of success - user can create residence later if needed
|
||||||
self.goForward(to: step)
|
self.goForward(to: step)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
||||||
|
|
||||||
Text("Your data will be synced across devices")
|
Text("Your data will be synced across devices")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -121,6 +122,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
.background(Color.appPrimary.opacity(0.1))
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
||||||
} else {
|
} else {
|
||||||
// Expanded form
|
// Expanded form
|
||||||
VStack(spacing: AppSpacing.md) {
|
VStack(spacing: AppSpacing.md) {
|
||||||
@@ -188,6 +190,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||||
.disabled(!isFormValid || viewModel.isLoading)
|
.disabled(!isFormValid || viewModel.isLoading)
|
||||||
}
|
}
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
var onTaskAdded: () -> Void
|
var onTaskAdded: () -> Void
|
||||||
|
|
||||||
@StateObject private var viewModel = TaskViewModel()
|
@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 selectedTasks: Set<UUID> = []
|
||||||
@State private var isCreatingTasks = false
|
@State private var isCreatingTasks = false
|
||||||
@State private var showCustomTaskSheet = false
|
@State private var showCustomTaskSheet = false
|
||||||
@@ -318,10 +320,9 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first residence from cache (just created during onboarding)
|
// Get the residence ID from OnboardingState (set during residence creation)
|
||||||
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
|
guard let residenceId = onboardingState.createdResidenceId else {
|
||||||
let residence = residences.first else {
|
print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation")
|
||||||
print("🏠 ONBOARDING: No residence found in cache, skipping task creation")
|
|
||||||
onTaskAdded()
|
onTaskAdded()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -337,27 +338,25 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||||
let todayString = dateFormatter.string(from: Date())
|
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 {
|
for template in selectedTemplates {
|
||||||
// Look up category ID from DataCache
|
// Look up category ID from DataManager
|
||||||
let categoryId: Int32? = {
|
let categoryId: Int32? = {
|
||||||
guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil }
|
|
||||||
let categoryName = template.category.lowercased()
|
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? = {
|
let frequencyId: Int32? = {
|
||||||
guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
|
|
||||||
let frequencyName = template.frequency.lowercased()
|
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))")
|
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
|
||||||
|
|
||||||
let request = TaskCreateRequest(
|
let request = TaskCreateRequest(
|
||||||
residenceId: residence.id,
|
residenceId: residenceId,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
description: nil,
|
description: nil,
|
||||||
categoryId: categoryId.map { KotlinInt(int: $0) },
|
categoryId: categoryId.map { KotlinInt(int: $0) },
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
.multilineTextAlignment(.center)
|
.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.")
|
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -96,6 +97,7 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
.textInputAutocapitalization(.words)
|
.textInputAutocapitalization(.words)
|
||||||
.focused($isTextFieldFocused)
|
.focused($isTextFieldFocused)
|
||||||
.submitLabel(.continue)
|
.submitLabel(.continue)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
if isValid {
|
if isValid {
|
||||||
onContinue()
|
onContinue()
|
||||||
@@ -182,6 +184,7 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
.cornerRadius(AppRadius.lg)
|
.cornerRadius(AppRadius.lg)
|
||||||
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
|
||||||
.disabled(!isValid)
|
.disabled(!isValid)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.padding(.horizontal, AppSpacing.xl)
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
.padding(.bottom, AppSpacing.xxxl)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class OnboardingState: ObservableObject {
|
|||||||
/// The name of the residence being created during onboarding
|
/// The name of the residence being created during onboarding
|
||||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
@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
|
/// The user's selected intent (start fresh or join existing) - persisted
|
||||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ class OnboardingState: ObservableObject {
|
|||||||
hasCompletedOnboarding = true
|
hasCompletedOnboarding = true
|
||||||
isOnboardingActive = false
|
isOnboardingActive = false
|
||||||
pendingResidenceName = ""
|
pendingResidenceName = ""
|
||||||
|
createdResidenceId = nil
|
||||||
userIntent = .unknown
|
userIntent = .unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +98,7 @@ class OnboardingState: ObservableObject {
|
|||||||
hasCompletedOnboarding = false
|
hasCompletedOnboarding = false
|
||||||
isOnboardingActive = false
|
isOnboardingActive = false
|
||||||
pendingResidenceName = ""
|
pendingResidenceName = ""
|
||||||
|
createdResidenceId = nil
|
||||||
userIntent = .unknown
|
userIntent = .unknown
|
||||||
currentStep = .welcome
|
currentStep = .welcome
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.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.")
|
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@@ -50,6 +51,7 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textContentType(.oneTimeCode)
|
.textContentType(.oneTimeCode)
|
||||||
.focused($isCodeFieldFocused)
|
.focused($isCodeFieldFocused)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
// Limit to 6 digits
|
// Limit to 6 digits
|
||||||
if newValue.count > 6 {
|
if newValue.count > 6 {
|
||||||
@@ -124,6 +126,7 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
.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)
|
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.padding(.horizontal, AppSpacing.xl)
|
||||||
.padding(.bottom, AppSpacing.xxxl)
|
.padding(.bottom, AppSpacing.xxxl)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct OnboardingWelcomeView: View {
|
|||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||||
|
|
||||||
Text("Your home maintenance companion")
|
Text("Your home maintenance companion")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
@@ -64,6 +65,7 @@ struct OnboardingWelcomeView: View {
|
|||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||||
|
|
||||||
// Secondary CTA - Join Existing
|
// Secondary CTA - Join Existing
|
||||||
Button(action: onJoinExisting) {
|
Button(action: onJoinExisting) {
|
||||||
@@ -80,6 +82,7 @@ struct OnboardingWelcomeView: View {
|
|||||||
.background(Color.appPrimary.opacity(0.1))
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
.cornerRadius(AppRadius.md)
|
.cornerRadius(AppRadius.md)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||||
|
|
||||||
// Returning user login
|
// Returning user login
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -89,6 +92,7 @@ struct OnboardingWelcomeView: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||||
.padding(.top, AppSpacing.sm)
|
.padding(.top, AppSpacing.sm)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppSpacing.xl)
|
.padding(.horizontal, AppSpacing.xl)
|
||||||
|
|||||||
@@ -15,13 +15,8 @@ class RegisterViewModel: ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var isRegistered: Bool = false
|
@Published var isRegistered: Bool = false
|
||||||
|
|
||||||
// MARK: - Private Properties
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(tokenStorage: TokenStorageProtocol? = nil) {
|
init() {}
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func register() {
|
func register() {
|
||||||
@@ -54,16 +49,15 @@ class RegisterViewModel: ObservableObject {
|
|||||||
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
|
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
|
||||||
let result = try await APILayer.shared.register(request: request)
|
let result = try await APILayer.shared.register(request: request)
|
||||||
|
|
||||||
if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
|
if let success = result as? ApiResultSuccess<AuthResponse>, let _ = success.data {
|
||||||
let token = response.token
|
// APILayer.register() now handles:
|
||||||
self.tokenStorage.saveToken(token: token)
|
// - Setting auth token in DataManager
|
||||||
|
// - Storing token in TokenManager
|
||||||
|
// - Initializing lookups
|
||||||
|
|
||||||
// Update AuthenticationManager - user is authenticated but NOT verified
|
// Update AuthenticationManager - user is authenticated but NOT verified
|
||||||
AuthenticationManager.shared.login(verified: false)
|
AuthenticationManager.shared.login(verified: false)
|
||||||
|
|
||||||
// Initialize lookups via APILayer after successful registration
|
|
||||||
_ = try? await APILayer.shared.initializeLookups()
|
|
||||||
|
|
||||||
self.isRegistered = true
|
self.isRegistered = true
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
} else if let error = result as? ApiResultError {
|
} else if let error = result as? ApiResultError {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct ResidenceDetailView: View {
|
|||||||
|
|
||||||
@StateObject private var viewModel = ResidenceViewModel()
|
@StateObject private var viewModel = ResidenceViewModel()
|
||||||
@StateObject private var taskViewModel = TaskViewModel()
|
@StateObject private var taskViewModel = TaskViewModel()
|
||||||
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
|
|
||||||
// Use TaskViewModel's state instead of local state
|
// Use TaskViewModel's state instead of local state
|
||||||
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
|
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
|
||||||
@@ -37,7 +38,7 @@ struct ResidenceDetailView: View {
|
|||||||
|
|
||||||
// Check if current user is the owner of the residence
|
// Check if current user is the owner of the residence
|
||||||
private func isCurrentUserOwner(of residence: ResidenceResponse) -> Bool {
|
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 false
|
||||||
}
|
}
|
||||||
return Int(residence.ownerId) == Int(currentUser.id)
|
return Int(residence.ownerId) == Int(currentUser.id)
|
||||||
|
|||||||
@@ -134,28 +134,52 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
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
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
print("🏠 ResidenceVM: Calling API...")
|
||||||
let result = try await APILayer.shared.createResidence(request: request)
|
let result = try await APILayer.shared.createResidence(request: request)
|
||||||
|
print("🏠 ResidenceVM: Got result: \(String(describing: result))")
|
||||||
|
|
||||||
if result is ApiResultSuccess<ResidenceResponse> {
|
await MainActor.run {
|
||||||
self.isLoading = false
|
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||||
// DataManager is updated by APILayer (including refreshMyResidences),
|
print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))")
|
||||||
// which updates DataManagerObservable, which updates our @Published
|
if let residence = success.data {
|
||||||
// myResidences via Combine subscription
|
print("🏠 ResidenceVM: Got residence with id \(residence.id)")
|
||||||
completion(true)
|
self.isLoading = false
|
||||||
} else if let error = result as? ApiResultError {
|
completion(residence)
|
||||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
} else {
|
||||||
self.isLoading = false
|
print("🏠 ResidenceVM: success.data is nil")
|
||||||
completion(false)
|
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 {
|
} catch {
|
||||||
self.errorMessage = error.localizedDescription
|
print("🏠 ResidenceVM: Exception: \(error)")
|
||||||
self.isLoading = false
|
await MainActor.run {
|
||||||
completion(false)
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ struct ResidenceFormView: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
var onSuccess: (() -> Void)?
|
var onSuccess: (() -> Void)?
|
||||||
@StateObject private var viewModel = ResidenceViewModel()
|
@StateObject private var viewModel = ResidenceViewModel()
|
||||||
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
// Lookups from DataCache
|
// Lookups from DataManagerObservable
|
||||||
@State private var residenceTypes: [ResidenceType] = []
|
private var residenceTypes: [ResidenceType] { dataManager.residenceTypes }
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
@State private var name: String = ""
|
@State private var name: String = ""
|
||||||
@@ -196,21 +197,10 @@ struct ResidenceFormView: View {
|
|||||||
|
|
||||||
private func loadResidenceTypes() {
|
private func loadResidenceTypes() {
|
||||||
Task {
|
Task {
|
||||||
// Get residence types from DataCache via APILayer
|
// Trigger residence types refresh if needed
|
||||||
let result = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
// Residence types are now loaded from DataManagerObservable
|
||||||
if let success = result as? ApiResultSuccess<NSArray>,
|
// Just trigger a refresh if needed
|
||||||
let types = success.data as? [ResidenceType] {
|
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,20 +26,22 @@ class AuthenticationManager: ObservableObject {
|
|||||||
|
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
|
|
||||||
// Fetch current user to check verification status
|
// Fetch current user and initialize lookups immediately for all authenticated users
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
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)
|
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
|
||||||
|
|
||||||
if let success = result as? ApiResultSuccess<User> {
|
if let success = result as? ApiResultSuccess<User> {
|
||||||
self.isVerified = success.data?.verified ?? false
|
self.isVerified = success.data?.verified ?? false
|
||||||
|
|
||||||
// Initialize lookups if verified
|
// Verify subscription entitlements with backend for verified users
|
||||||
if self.isVerified {
|
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()
|
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||||
}
|
}
|
||||||
} else if result is ApiResultError {
|
} else if result is ApiResultError {
|
||||||
@@ -68,17 +70,11 @@ class AuthenticationManager: ObservableObject {
|
|||||||
func markVerified() {
|
func markVerified() {
|
||||||
isVerified = true
|
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 {
|
Task {
|
||||||
do {
|
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||||
_ = try await APILayer.shared.initializeLookups()
|
print("✅ Subscription entitlements verified after email verification")
|
||||||
print("✅ Lookups initialized after email verification")
|
|
||||||
|
|
||||||
// Verify subscription entitlements with backend
|
|
||||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
|
||||||
} catch {
|
|
||||||
print("❌ Failed to initialize lookups after verification: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
struct FeatureComparisonView: View {
|
struct FeatureComparisonView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -70,20 +77,65 @@ struct FeatureComparisonView: View {
|
|||||||
.cornerRadius(AppRadius.lg)
|
.cornerRadius(AppRadius.lg)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Upgrade Button
|
// Subscription Products
|
||||||
Button(action: {
|
if storeKit.isLoading {
|
||||||
// TODO: Trigger upgrade flow
|
ProgressView()
|
||||||
isPresented = false
|
.tint(Color.appPrimary)
|
||||||
}) {
|
|
||||||
Text("Upgrade to Pro")
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.foregroundColor(Color.appTextOnPrimary)
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.appPrimary)
|
} else if !storeKit.products.isEmpty {
|
||||||
.cornerRadius(AppRadius.md)
|
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)
|
.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 {
|
struct ComparisonRow: View {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ struct SummaryCard: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
|
SummaryStatView(
|
||||||
|
icon: "calendar",
|
||||||
|
value: "\(summary.totalOverdue)",
|
||||||
|
label: "Over Due"
|
||||||
|
)
|
||||||
|
|
||||||
SummaryStatView(
|
SummaryStatView(
|
||||||
icon: "calendar",
|
icon: "calendar",
|
||||||
value: "\(summary.tasksDueNextWeek)",
|
value: "\(summary.tasksDueNextWeek)",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct TaskFormView: View {
|
|||||||
let existingTask: TaskResponse? // nil for add mode, populated for edit mode
|
let existingTask: TaskResponse? // nil for add mode, populated for edit mode
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@StateObject private var viewModel = TaskViewModel()
|
@StateObject private var viewModel = TaskViewModel()
|
||||||
|
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||||
@FocusState private var focusedField: TaskFormField?
|
@FocusState private var focusedField: TaskFormField?
|
||||||
|
|
||||||
private var isEditMode: Bool {
|
private var isEditMode: Bool {
|
||||||
@@ -32,12 +33,12 @@ struct TaskFormView: View {
|
|||||||
selectedStatus != nil
|
selectedStatus != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookups from DataCache
|
// Lookups from DataManagerObservable
|
||||||
@State private var taskCategories: [TaskCategory] = []
|
private var taskCategories: [TaskCategory] { dataManager.taskCategories }
|
||||||
@State private var taskFrequencies: [TaskFrequency] = []
|
private var taskFrequencies: [TaskFrequency] { dataManager.taskFrequencies }
|
||||||
@State private var taskPriorities: [TaskPriority] = []
|
private var taskPriorities: [TaskPriority] { dataManager.taskPriorities }
|
||||||
@State private var taskStatuses: [TaskStatus] = []
|
private var taskStatuses: [TaskStatus] { dataManager.taskStatuses }
|
||||||
@State private var isLoadingLookups: Bool = true
|
private var isLoadingLookups: Bool { !dataManager.lookupsInitialized }
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
@State private var selectedResidence: ResidenceResponse?
|
@State private var selectedResidence: ResidenceResponse?
|
||||||
@@ -254,8 +255,16 @@ struct TaskFormView: View {
|
|||||||
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.onAppear {
|
||||||
await loadLookups()
|
// 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
|
.onChange(of: viewModel.taskCreated) { created in
|
||||||
if created {
|
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() {
|
private func setDefaults() {
|
||||||
// Set default values if not already set
|
// Set default values if not already set
|
||||||
if selectedCategory == nil && !taskCategories.isEmpty {
|
if selectedCategory == nil && !taskCategories.isEmpty {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ struct iOSApp: App {
|
|||||||
|
|
||||||
// Initialize TokenStorage once at app startup (legacy support)
|
// Initialize TokenStorage once at app startup (legacy support)
|
||||||
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
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 {
|
var body: some Scene {
|
||||||
|
|||||||
Reference in New Issue
Block a user