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:
Trey t
2025-12-04 15:55:34 -06:00
parent 43f5b9514f
commit fff1032c29
43 changed files with 1055 additions and 923 deletions

View File

@@ -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
}
}

View File

@@ -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!!
}
}
}

View File

@@ -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.

View File

@@ -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> {

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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))

View File

@@ -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,

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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
View 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.

View File

@@ -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"

View 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 coveredinstantly.\"]",".staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's coveredinstantly.\"]"],[[[-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)
}
}

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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")

View File

@@ -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")
} }

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
} }

View File

@@ -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) {}
}

View File

@@ -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"

View File

@@ -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.",

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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)))

View File

@@ -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) },

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}
} }
} }
} }

View File

@@ -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
}
}
}
} }
} }

View File

@@ -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)")
}
} }
} }

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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 {

View File

@@ -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 {