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.
*
* - /static_data/ and /upgrade-triggers/ are public endpoints (no auth required)
* - /subscription/status/ requires auth and is only called if user is authenticated
*/
suspend fun initializeLookups(): ApiResult<Unit> {
val token = getToken()
if (DataManager.lookupsInitialized.value) {
// Lookups already initialized, but refresh subscription status
// Lookups already initialized, but refresh subscription status if authenticated
println("📋 [APILayer] Lookups already initialized, refreshing subscription status only...")
refreshSubscriptionStatus()
if (token != null) {
refreshSubscriptionStatus()
}
return ApiResult.Success(Unit)
}
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
try {
// Load all lookups in a single API call using static_data endpoint
// Load all lookups in a single API call using static_data endpoint (PUBLIC - no auth required)
println("🔄 Fetching static data (all lookups)...")
val staticDataResult = lookupsApi.getStaticData(token)
val staticDataResult = lookupsApi.getStaticData(token) // token is optional
println("📦 Static data result: $staticDataResult")
// Update DataManager with all lookups at once
@@ -86,24 +91,11 @@ object APILayer {
return ApiResult.Error("Failed to load lookups: ${staticDataResult.message}")
}
// Load subscription status to get limitationsEnabled, usage, and limits from backend
println("🔄 Fetching subscription status...")
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
println("📦 Subscription status result: $subscriptionStatusResult")
// Load upgrade triggers
// Load upgrade triggers (PUBLIC - no auth required)
println("🔄 Fetching upgrade triggers...")
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token)
val upgradeTriggersResult = subscriptionApi.getUpgradeTriggers(token) // token is optional
println("📦 Upgrade triggers result: $upgradeTriggersResult")
if (subscriptionStatusResult is ApiResult.Success) {
println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}")
DataManager.setSubscription(subscriptionStatusResult.data)
println("✅ Subscription updated successfully")
} else if (subscriptionStatusResult is ApiResult.Error) {
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
}
if (upgradeTriggersResult is ApiResult.Success) {
println("✅ Updating upgrade triggers with ${upgradeTriggersResult.data.size} triggers")
DataManager.setUpgradeTriggers(upgradeTriggersResult.data)
@@ -112,6 +104,23 @@ object APILayer {
println("❌ Failed to fetch upgrade triggers: ${upgradeTriggersResult.message}")
}
// Load subscription status only if authenticated (requires auth for user-specific data)
if (token != null) {
println("🔄 Fetching subscription status...")
val subscriptionStatusResult = subscriptionApi.getSubscriptionStatus(token)
println("📦 Subscription status result: $subscriptionStatusResult")
if (subscriptionStatusResult is ApiResult.Success) {
println("✅ Updating DataManager with subscription: ${subscriptionStatusResult.data}")
DataManager.setSubscription(subscriptionStatusResult.data)
println("✅ Subscription updated successfully")
} else if (subscriptionStatusResult is ApiResult.Error) {
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
}
} else {
println("⏭️ Skipping subscription status (not authenticated)")
}
DataManager.markLookupsInitialized()
return ApiResult.Success(Unit)
} catch (e: Exception) {
@@ -887,7 +896,17 @@ object APILayer {
}
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
return authApi.register(request)
val result = authApi.register(request)
// Update DataManager on success (same as login)
if (result is ApiResult.Success) {
DataManager.setAuthToken(result.data.token)
DataManager.setCurrentUser(result.data.user)
// Initialize lookups after successful registration
initializeLookups()
}
return result
}
suspend fun logout(): ApiResult<Unit> {

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

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

View File

@@ -153,7 +153,7 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
// DEPRECATED: These methods now use PATCH internally.
// They're kept for backward compatibility with existing ViewModel calls.
// New code should use patchTask directly with status IDs from DataCache.
// New code should use patchTask directly with status IDs from DataManager.
suspend fun cancelTask(token: String, id: Int, cancelledStatusId: Int): ApiResult<TaskResponse> {
return patchTask(token, id, TaskPatchRequest(status = cancelledStatusId))

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.cache.DataCache
import com.example.casera.data.DataManager
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
@@ -117,7 +117,7 @@ fun ContractorDetailScreen(
.background(Color(0xFFF9FAFB))
) {
val uriHandler = LocalUriHandler.current
val residences = DataCache.residences.value
val residences = DataManager.residences.value
ApiResultHandler(
state = contractorState,

View File

@@ -34,7 +34,7 @@ import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.cache.DataCache
import com.example.casera.data.DataManager
import com.example.casera.util.DateUtils
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -77,7 +77,7 @@ fun ResidenceDetailScreen(
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Get current user for ownership checks
val currentUser by DataCache.currentUser.collectAsState()
val currentUser by DataManager.currentUser.collectAsState()
// Check if tasks are blocked (limit=0) - this hides the FAB
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()

View File

@@ -28,14 +28,14 @@ import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import com.example.casera.util.DateUtils
import org.jetbrains.compose.resources.stringResource
import java.util.UUID
import kotlin.random.Random
private fun generateId(): String = Random.nextLong().toString(36)
data class OnboardingTaskTemplate(
val id: UUID = UUID.randomUUID(),
val id: String = generateId(),
val icon: ImageVector,
val title: String,
val category: String,
@@ -43,7 +43,7 @@ data class OnboardingTaskTemplate(
)
data class OnboardingTaskCategory(
val id: UUID = UUID.randomUUID(),
val id: String = generateId(),
val name: String,
val icon: ImageVector,
val color: Color,
@@ -56,8 +56,8 @@ fun OnboardingFirstTaskContent(
onTasksAdded: () -> Unit
) {
val maxTasksAllowed = 5
var selectedTaskIds by remember { mutableStateOf(setOf<UUID>()) }
var expandedCategoryId by remember { mutableStateOf<UUID?>(null) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var expandedCategoryId by remember { mutableStateOf<String?>(null) }
var isCreatingTasks by remember { mutableStateOf(false) }
val createTasksState by viewModel.createTasksState.collectAsState()
@@ -328,10 +328,7 @@ fun OnboardingFirstTaskContent(
val residences = DataManager.residences.value
val residence = residences.firstOrNull()
if (residence != null) {
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
.toString()
val today = DateUtils.getTodayString()
val selectedTemplates = allTasks.filter { it.id in selectedTaskIds }
val taskRequests = selectedTemplates.map { template ->
@@ -397,11 +394,11 @@ fun OnboardingFirstTaskContent(
@Composable
private fun TaskCategorySection(
category: OnboardingTaskCategory,
selectedTaskIds: Set<UUID>,
selectedTaskIds: Set<String>,
isExpanded: Boolean,
isAtMaxSelection: Boolean,
onToggleExpand: () -> Unit,
onToggleTask: (UUID) -> Unit
onToggleTask: (String) -> Unit
) {
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }

View File

@@ -21,6 +21,13 @@ object DateUtils {
return instant.toLocalDateTime(TimeZone.currentSystemDefault()).date
}
/**
* Get today's date as an ISO string (YYYY-MM-DD)
*/
fun getTodayString(): String {
return getToday().toString()
}
/**
* Format a date string (YYYY-MM-DD) to a human-readable format
* Returns "Today", "Tomorrow", "Yesterday", or "Mon, Dec 15" format