Add 1-hour cache timeout and fix pull-to-refresh across iOS

- Add configurable cache timeout (CACHE_TIMEOUT_MS) to DataManager
- Fix cache to work with empty results (contractors, documents, residences)
- Change Documents/Warranties view to use client-side filtering for cache efficiency
- Add pull-to-refresh support for empty state views in ListAsyncContentView
- Fix ContractorsListView to pass forceRefresh parameter correctly
- Fix TaskViewModel loading spinner not stopping after refresh completes
- Remove duplicate cache checks in iOS ViewModels, delegate to Kotlin APILayer

🤖 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-03 09:50:57 -06:00
parent cf0cd1cda2
commit 63a54434ed
29 changed files with 1284 additions and 1230 deletions

View File

@@ -26,6 +26,47 @@ import kotlin.time.ExperimentalTime
*/ */
object DataManager { object DataManager {
// ==================== CACHE CONFIGURATION ====================
/**
* Cache timeout in milliseconds.
* Data older than this will be refreshed from the API.
* Default: 1 hour (3600000ms)
*/
const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour
// Cache timestamps for each data type (epoch milliseconds)
var residencesCacheTime: Long = 0L
private set
var myResidencesCacheTime: Long = 0L
private set
var tasksCacheTime: Long = 0L
private set
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
private set
var contractorsCacheTime: Long = 0L
private set
var documentsCacheTime: Long = 0L
private set
var summaryCacheTime: Long = 0L
private set
/**
* Check if cache for a given timestamp is still valid (not expired)
*/
@OptIn(ExperimentalTime::class)
fun isCacheValid(cacheTime: Long): Boolean {
if (cacheTime == 0L) return false
val now = Clock.System.now().toEpochMilliseconds()
return (now - cacheTime) < CACHE_TIMEOUT_MS
}
/**
* Get current timestamp in milliseconds
*/
@OptIn(ExperimentalTime::class)
private fun currentTimeMs(): Long = Clock.System.now().toEpochMilliseconds()
// Platform-specific persistence managers (initialized at app start) // Platform-specific persistence managers (initialized at app start)
private var tokenManager: TokenManager? = null private var tokenManager: TokenManager? = null
private var themeManager: ThemeStorageManager? = null private var themeManager: ThemeStorageManager? = null
@@ -58,6 +99,9 @@ object DataManager {
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null) private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow() val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
private val _totalSummary = MutableStateFlow<TotalSummary?>(null)
val totalSummary: StateFlow<TotalSummary?> = _totalSummary.asStateFlow()
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap()) private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow() val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
@@ -78,9 +122,10 @@ object DataManager {
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow() val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
// ==================== CONTRACTORS ==================== // ==================== CONTRACTORS ====================
// Stores ContractorSummary for list views (lighter weight than full Contractor)
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList()) private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow() val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
// ==================== SUBSCRIPTION ==================== // ==================== SUBSCRIPTION ====================
@@ -215,16 +260,31 @@ object DataManager {
fun setResidences(residences: List<Residence>) { fun setResidences(residences: List<Residence>) {
_residences.value = residences _residences.value = residences
residencesCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
persistToDisk() persistToDisk()
} }
fun setMyResidences(response: MyResidencesResponse) { fun setMyResidences(response: MyResidencesResponse) {
_myResidences.value = response _myResidences.value = response
// Also update totalSummary from myResidences response
_totalSummary.value = response.summary
myResidencesCacheTime = currentTimeMs()
summaryCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
persistToDisk() persistToDisk()
} }
fun setTotalSummary(summary: TotalSummary) {
_totalSummary.value = summary
// Also update the summary in myResidences if it exists
_myResidences.value?.let { current ->
_myResidences.value = current.copy(summary = summary)
}
summaryCacheTime = currentTimeMs()
persistToDisk()
}
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) { fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary) _residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
persistToDisk() persistToDisk()
@@ -255,12 +315,14 @@ object DataManager {
fun setAllTasks(response: TaskColumnsResponse) { fun setAllTasks(response: TaskColumnsResponse) {
_allTasks.value = response _allTasks.value = response
tasksCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
persistToDisk() persistToDisk()
} }
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) { fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response) _tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
persistToDisk() persistToDisk()
} }
@@ -332,6 +394,7 @@ object DataManager {
fun setDocuments(documents: List<Document>) { fun setDocuments(documents: List<Document>) {
_documents.value = documents _documents.value = documents
documentsCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
persistToDisk() persistToDisk()
} }
@@ -364,24 +427,40 @@ object DataManager {
// ==================== CONTRACTOR UPDATE METHODS ==================== // ==================== CONTRACTOR UPDATE METHODS ====================
fun setContractors(contractors: List<Contractor>) { fun setContractors(contractors: List<ContractorSummary>) {
_contractors.value = contractors _contractors.value = contractors
contractorsCacheTime = currentTimeMs()
updateLastSyncTime() updateLastSyncTime()
persistToDisk() persistToDisk()
} }
fun addContractor(contractor: Contractor) { fun addContractor(contractor: ContractorSummary) {
_contractors.value = _contractors.value + contractor _contractors.value = _contractors.value + contractor
persistToDisk() persistToDisk()
} }
fun updateContractor(contractor: Contractor) { /** Add a full Contractor (converts to summary for storage) */
fun addContractor(contractor: Contractor) {
_contractors.value = _contractors.value + contractor.toSummary()
persistToDisk()
}
fun updateContractor(contractor: ContractorSummary) {
_contractors.value = _contractors.value.map { _contractors.value = _contractors.value.map {
if (it.id == contractor.id) contractor else it if (it.id == contractor.id) contractor else it
} }
persistToDisk() persistToDisk()
} }
/** Update from a full Contractor (converts to summary for storage) */
fun updateContractor(contractor: Contractor) {
val summary = contractor.toSummary()
_contractors.value = _contractors.value.map {
if (it.id == summary.id) summary else it
}
persistToDisk()
}
fun removeContractor(contractorId: Int) { fun removeContractor(contractorId: Int) {
_contractors.value = _contractors.value.filter { it.id != contractorId } _contractors.value = _contractors.value.filter { it.id != contractorId }
persistToDisk() persistToDisk()
@@ -475,6 +554,7 @@ object DataManager {
// Clear user data // Clear user data
_residences.value = emptyList() _residences.value = emptyList()
_myResidences.value = null _myResidences.value = null
_totalSummary.value = null
_residenceSummaries.value = emptyMap() _residenceSummaries.value = emptyMap()
_allTasks.value = null _allTasks.value = null
_tasksByResidence.value = emptyMap() _tasksByResidence.value = emptyMap()
@@ -503,6 +583,15 @@ object DataManager {
_contractorSpecialtiesMap.value = emptyMap() _contractorSpecialtiesMap.value = emptyMap()
_lookupsInitialized.value = false _lookupsInitialized.value = false
// Clear cache timestamps
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
// Clear metadata // Clear metadata
_lastSyncTime.value = 0L _lastSyncTime.value = 0L
@@ -517,6 +606,7 @@ object DataManager {
_currentUser.value = null _currentUser.value = null
_residences.value = emptyList() _residences.value = emptyList()
_myResidences.value = null _myResidences.value = null
_totalSummary.value = null
_residenceSummaries.value = emptyMap() _residenceSummaries.value = emptyMap()
_allTasks.value = null _allTasks.value = null
_tasksByResidence.value = emptyMap() _tasksByResidence.value = emptyMap()
@@ -527,6 +617,16 @@ object DataManager {
_upgradeTriggers.value = emptyMap() _upgradeTriggers.value = emptyMap()
_featureBenefits.value = emptyList() _featureBenefits.value = emptyList()
_promotions.value = emptyList() _promotions.value = emptyList()
// Clear cache timestamps
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
persistToDisk() persistToDisk()
} }
@@ -539,169 +639,42 @@ object DataManager {
/** /**
* Persist current state to disk. * Persist current state to disk.
* Called automatically after each update. * Only persists user data - all other data is fetched fresh from API.
* No offline mode support - network required for app functionality.
*/ */
private fun persistToDisk() { private fun persistToDisk() {
val manager = persistenceManager ?: return val manager = persistenceManager ?: return
try { try {
// Persist each data type // Only persist user data - everything else is fetched fresh from API
_currentUser.value?.let { _currentUser.value?.let {
manager.save(KEY_CURRENT_USER, json.encodeToString(it)) manager.save(KEY_CURRENT_USER, json.encodeToString(it))
} }
if (_residences.value.isNotEmpty()) {
manager.save(KEY_RESIDENCES, json.encodeToString(_residences.value))
}
_myResidences.value?.let {
manager.save(KEY_MY_RESIDENCES, json.encodeToString(it))
}
_allTasks.value?.let {
manager.save(KEY_ALL_TASKS, json.encodeToString(it))
}
if (_documents.value.isNotEmpty()) {
manager.save(KEY_DOCUMENTS, json.encodeToString(_documents.value))
}
if (_contractors.value.isNotEmpty()) {
manager.save(KEY_CONTRACTORS, json.encodeToString(_contractors.value))
}
_subscription.value?.let {
manager.save(KEY_SUBSCRIPTION, json.encodeToString(it))
}
// Persist lookups
if (_residenceTypes.value.isNotEmpty()) {
manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value))
}
if (_taskFrequencies.value.isNotEmpty()) {
manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value))
}
if (_taskPriorities.value.isNotEmpty()) {
manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value))
}
if (_taskStatuses.value.isNotEmpty()) {
manager.save(KEY_TASK_STATUSES, json.encodeToString(_taskStatuses.value))
}
if (_taskCategories.value.isNotEmpty()) {
manager.save(KEY_TASK_CATEGORIES, json.encodeToString(_taskCategories.value))
}
if (_contractorSpecialties.value.isNotEmpty()) {
manager.save(KEY_CONTRACTOR_SPECIALTIES, json.encodeToString(_contractorSpecialties.value))
}
manager.save(KEY_LAST_SYNC_TIME, _lastSyncTime.value.toString())
} catch (e: Exception) { } catch (e: Exception) {
// Log error but don't crash - persistence is best-effort
println("DataManager: Error persisting to disk: ${e.message}") println("DataManager: Error persisting to disk: ${e.message}")
} }
} }
/** /**
* Load cached state from disk. * Load cached state from disk.
* Called during initialization. * Only loads user data - all other data is fetched fresh from API.
* No offline mode support - network required for app functionality.
*/ */
private fun loadFromDisk() { private fun loadFromDisk() {
val manager = persistenceManager ?: return val manager = persistenceManager ?: return
try { try {
// Only load user data - everything else is fetched fresh from API
manager.load(KEY_CURRENT_USER)?.let { data -> manager.load(KEY_CURRENT_USER)?.let { data ->
_currentUser.value = json.decodeFromString<User>(data) _currentUser.value = json.decodeFromString<User>(data)
} }
manager.load(KEY_RESIDENCES)?.let { data ->
_residences.value = json.decodeFromString<List<Residence>>(data)
}
manager.load(KEY_MY_RESIDENCES)?.let { data ->
_myResidences.value = json.decodeFromString<MyResidencesResponse>(data)
}
manager.load(KEY_ALL_TASKS)?.let { data ->
_allTasks.value = json.decodeFromString<TaskColumnsResponse>(data)
}
manager.load(KEY_DOCUMENTS)?.let { data ->
_documents.value = json.decodeFromString<List<Document>>(data)
}
manager.load(KEY_CONTRACTORS)?.let { data ->
_contractors.value = json.decodeFromString<List<Contractor>>(data)
}
manager.load(KEY_SUBSCRIPTION)?.let { data ->
_subscription.value = json.decodeFromString<SubscriptionStatus>(data)
}
// Load lookups
manager.load(KEY_RESIDENCE_TYPES)?.let { data ->
val types = json.decodeFromString<List<ResidenceType>>(data)
_residenceTypes.value = types
_residenceTypesMap.value = types.associateBy { it.id }
}
manager.load(KEY_TASK_FREQUENCIES)?.let { data ->
val items = json.decodeFromString<List<TaskFrequency>>(data)
_taskFrequencies.value = items
_taskFrequenciesMap.value = items.associateBy { it.id }
}
manager.load(KEY_TASK_PRIORITIES)?.let { data ->
val items = json.decodeFromString<List<TaskPriority>>(data)
_taskPriorities.value = items
_taskPrioritiesMap.value = items.associateBy { it.id }
}
manager.load(KEY_TASK_STATUSES)?.let { data ->
val items = json.decodeFromString<List<TaskStatus>>(data)
_taskStatuses.value = items
_taskStatusesMap.value = items.associateBy { it.id }
}
manager.load(KEY_TASK_CATEGORIES)?.let { data ->
val items = json.decodeFromString<List<TaskCategory>>(data)
_taskCategories.value = items
_taskCategoriesMap.value = items.associateBy { it.id }
}
manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data ->
val items = json.decodeFromString<List<ContractorSpecialty>>(data)
_contractorSpecialties.value = items
_contractorSpecialtiesMap.value = items.associateBy { it.id }
}
manager.load(KEY_LAST_SYNC_TIME)?.let { data ->
_lastSyncTime.value = data.toLongOrNull() ?: 0L
}
// Mark lookups initialized if we have data
if (_residenceTypes.value.isNotEmpty()) {
_lookupsInitialized.value = true
}
} catch (e: Exception) { } catch (e: Exception) {
// Log error but don't crash - cache miss is OK
println("DataManager: Error loading from disk: ${e.message}") println("DataManager: Error loading from disk: ${e.message}")
} }
} }
// ==================== PERSISTENCE KEYS ==================== // ==================== PERSISTENCE KEYS ====================
// Only user data is persisted - all other data fetched fresh from API
private const val KEY_CURRENT_USER = "dm_current_user" private const val KEY_CURRENT_USER = "dm_current_user"
private const val KEY_RESIDENCES = "dm_residences"
private const val KEY_MY_RESIDENCES = "dm_my_residences"
private const val KEY_ALL_TASKS = "dm_all_tasks"
private const val KEY_DOCUMENTS = "dm_documents"
private const val KEY_CONTRACTORS = "dm_contractors"
private const val KEY_SUBSCRIPTION = "dm_subscription"
private const val KEY_RESIDENCE_TYPES = "dm_residence_types"
private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies"
private const val KEY_TASK_PRIORITIES = "dm_task_priorities"
private const val KEY_TASK_STATUSES = "dm_task_statuses"
private const val KEY_TASK_CATEGORIES = "dm_task_categories"
private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties"
private const val KEY_LAST_SYNC_TIME = "dm_last_sync_time"
} }

View File

@@ -86,5 +86,15 @@ data class ContractorSummary(
@SerialName("task_count") val taskCount: Int = 0 @SerialName("task_count") val taskCount: Int = 0
) )
// Note: API returns full Contractor objects for list endpoints // Extension to convert full Contractor to ContractorSummary
// ContractorSummary kept for backward compatibility fun Contractor.toSummary() = ContractorSummary(
id = id,
residenceId = residenceId,
name = name,
company = company,
phone = phone,
specialties = specialties,
rating = rating,
isFavorite = isFavorite,
taskCount = taskCount
)

View File

@@ -248,12 +248,10 @@ object APILayer {
// ==================== Residence Operations ==================== // ==================== Residence Operations ====================
suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> { suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { // Cache is valid even if empty (user has no residences)
val cached = DataManager.residences.value if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
if (cached.isNotEmpty()) { return ApiResult.Success(DataManager.residences.value)
return ApiResult.Success(cached)
}
} }
// Fetch from API // Fetch from API
@@ -269,8 +267,8 @@ object APILayer {
} }
suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> { suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) {
val cached = DataManager.myResidences.value val cached = DataManager.myResidences.value
if (cached != null) { if (cached != null) {
return ApiResult.Success(cached) return ApiResult.Success(cached)
@@ -290,8 +288,8 @@ object APILayer {
} }
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> { suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
val cached = DataManager.residences.value.find { it.id == id } val cached = DataManager.residences.value.find { it.id == id }
if (cached != null) { if (cached != null) {
return ApiResult.Success(cached) return ApiResult.Success(cached)
@@ -310,9 +308,27 @@ object APILayer {
return result return result
} }
suspend fun getResidenceSummary(): ApiResult<ResidenceSummaryResponse> { /**
* Get total summary (task counts across all residences).
* This is a lightweight endpoint for refreshing summary counts.
*/
suspend fun getSummary(forceRefresh: Boolean = false): ApiResult<TotalSummary> {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.summaryCacheTime)) {
val cached = DataManager.totalSummary.value
if (cached != null) {
return ApiResult.Success(cached)
}
}
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return residenceApi.getResidenceSummary(token) val result = residenceApi.getSummary(token)
if (result is ApiResult.Success) {
DataManager.setTotalSummary(result.data)
}
return result
} }
suspend fun createResidence(request: ResidenceCreateRequest): ApiResult<ResidenceResponse> { suspend fun createResidence(request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
@@ -397,8 +413,8 @@ object APILayer {
// ==================== Task Operations ==================== // ==================== Task Operations ====================
suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> { suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
val cached = DataManager.allTasks.value val cached = DataManager.allTasks.value
if (cached != null) { if (cached != null) {
return ApiResult.Success(cached) return ApiResult.Success(cached)
@@ -418,8 +434,8 @@ object APILayer {
} }
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> { suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
val cached = DataManager.tasksByResidence.value[residenceId] val cached = DataManager.tasksByResidence.value[residenceId]
if (cached != null) { if (cached != null) {
return ApiResult.Success(cached) return ApiResult.Success(cached)
@@ -548,6 +564,8 @@ object APILayer {
result.data.updatedTask?.let { updatedTask -> result.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask) DataManager.updateTask(updatedTask)
} }
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
refreshSummary()
} }
return result return result
@@ -566,6 +584,8 @@ object APILayer {
result.data.updatedTask?.let { updatedTask -> result.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask) DataManager.updateTask(updatedTask)
} }
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
refreshSummary()
} }
return result return result
@@ -596,12 +616,10 @@ object APILayer {
contractorId != null || isActive != null || expiringSoon != null || contractorId != null || isActive != null || expiringSoon != null ||
tags != null || search != null tags != null || search != null
// Check DataManager first if no filters // Check DataManager first if no filters - return cached if valid and not forcing refresh
if (!forceRefresh && !hasFilters) { // Cache is valid even if empty (user has no documents)
val cached = DataManager.documents.value if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
if (cached.isNotEmpty()) { return ApiResult.Success(DataManager.documents.value)
return ApiResult.Success(cached)
}
} }
// Fetch from API // Fetch from API
@@ -620,8 +638,8 @@ object APILayer {
} }
suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> { suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> {
// Check DataManager first // Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh) { if (!forceRefresh && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
val cached = DataManager.documents.value.find { it.id == id } val cached = DataManager.documents.value.find { it.id == id }
if (cached != null) { if (cached != null) {
return ApiResult.Success(cached) return ApiResult.Success(cached)
@@ -764,25 +782,32 @@ object APILayer {
search: String? = null, search: String? = null,
forceRefresh: Boolean = false forceRefresh: Boolean = false
): ApiResult<List<ContractorSummary>> { ): ApiResult<List<ContractorSummary>> {
// Fetch from API (API returns summaries, not full contractors) val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
}
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> { // Check cache first (only if no filters applied) - return cached if valid and not forcing refresh
// Check DataManager first // Cache is valid even if empty (user has no contractors)
if (!forceRefresh) { if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
val cached = DataManager.contractors.value.find { it.id == id } return ApiResult.Success(DataManager.contractors.value)
if (cached != null) {
return ApiResult.Success(cached)
}
} }
// Fetch from API // Fetch from API
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
// Update DataManager on success (only for unfiltered results)
if (result is ApiResult.Success && !hasFilters) {
DataManager.setContractors(result.data)
}
return result
}
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
// Fetch from API (summaries don't have full detail, always fetch)
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = contractorApi.getContractor(token, id) val result = contractorApi.getContractor(token, id)
// Update DataManager on success // Update the summary in DataManager on success
if (result is ApiResult.Success) { if (result is ApiResult.Success) {
DataManager.updateContractor(result.data) DataManager.updateContractor(result.data)
} }
@@ -1030,6 +1055,13 @@ object APILayer {
getMyResidences(forceRefresh = true) getMyResidences(forceRefresh = true)
} }
/**
* Refresh just the summary counts (lightweight)
*/
private suspend fun refreshSummary() {
getSummary(forceRefresh = true)
}
/** /**
* Prefetch all data after login * Prefetch all data after login
*/ */

View File

@@ -9,7 +9,7 @@ package com.example.casera.network
*/ */
object ApiConfig { object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.DEV val CURRENT_ENV = Environment.LOCAL
enum class Environment { enum class Environment {
LOCAL, LOCAL,

View File

@@ -93,7 +93,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
suspend fun getResidenceSummary(token: String): ApiResult<ResidenceSummaryResponse> { suspend fun getSummary(token: String): ApiResult<TotalSummary> {
return try { return try {
val response = client.get("$baseUrl/residences/summary/") { val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token") header("Authorization", "Token $token")
@@ -102,7 +102,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
ApiResult.Success(response.body()) ApiResult.Success(response.body())
} else { } else {
ApiResult.Error("Failed to fetch residence summary", response.status.value) ApiResult.Error("Failed to fetch summary", response.status.value)
} }
} catch (e: Exception) { } catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred") ApiResult.Error(e.message ?: "Unknown error occurred")

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.casera.models.* import com.example.casera.models.*
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.network.AuthApi import com.example.casera.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -19,7 +19,6 @@ enum class PasswordResetStep {
class PasswordResetViewModel( class PasswordResetViewModel(
private val deepLinkToken: String? = null private val deepLinkToken: String? = null
) : ViewModel() { ) : ViewModel() {
private val authApi = AuthApi()
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle) private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
@@ -48,7 +47,7 @@ class PasswordResetViewModel(
fun requestPasswordReset(email: String) { fun requestPasswordReset(email: String) {
viewModelScope.launch { viewModelScope.launch {
_forgotPasswordState.value = ApiResult.Loading _forgotPasswordState.value = ApiResult.Loading
val result = authApi.forgotPassword(ForgotPasswordRequest(email)) val result = APILayer.forgotPassword(ForgotPasswordRequest(email))
_forgotPasswordState.value = when (result) { _forgotPasswordState.value = when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
_email.value = email _email.value = email
@@ -66,7 +65,7 @@ class PasswordResetViewModel(
fun verifyResetCode(email: String, code: String) { fun verifyResetCode(email: String, code: String) {
viewModelScope.launch { viewModelScope.launch {
_verifyCodeState.value = ApiResult.Loading _verifyCodeState.value = ApiResult.Loading
val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code)) val result = APILayer.verifyResetCode(VerifyResetCodeRequest(email, code))
_verifyCodeState.value = when (result) { _verifyCodeState.value = when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
_resetToken.value = result.data.resetToken _resetToken.value = result.data.resetToken
@@ -91,7 +90,7 @@ class PasswordResetViewModel(
viewModelScope.launch { viewModelScope.launch {
_resetPasswordState.value = ApiResult.Loading _resetPasswordState.value = ApiResult.Loading
// Note: confirmPassword is for UI validation only, not sent to API // Note: confirmPassword is for UI validation only, not sent to API
val result = authApi.resetPassword( val result = APILayer.resetPassword(
ResetPasswordRequest( ResetPasswordRequest(
resetToken = token, resetToken = token,
newPassword = newPassword newPassword = newPassword

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.casera.models.Residence import com.example.casera.models.Residence
import com.example.casera.models.ResidenceCreateRequest import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceSummaryResponse import com.example.casera.models.TotalSummary
import com.example.casera.models.MyResidencesResponse import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskColumnsResponse import com.example.casera.models.TaskColumnsResponse
import com.example.casera.models.ContractorSummary import com.example.casera.models.ContractorSummary
@@ -19,8 +19,8 @@ class ResidenceViewModel : ViewModel() {
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle) private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle)
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
private val _residenceSummaryState = MutableStateFlow<ApiResult<ResidenceSummaryResponse>>(ApiResult.Idle) private val _summaryState = MutableStateFlow<ApiResult<TotalSummary>>(ApiResult.Idle)
val residenceSummaryState: StateFlow<ApiResult<ResidenceSummaryResponse>> = _residenceSummaryState val summaryState: StateFlow<ApiResult<TotalSummary>> = _summaryState
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle) private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
@@ -63,10 +63,10 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun loadResidenceSummary() { fun loadSummary(forceRefresh: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
_residenceSummaryState.value = ApiResult.Loading _summaryState.value = ApiResult.Loading
_residenceSummaryState.value = APILayer.getResidenceSummary() _summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh)
} }
} }

View File

@@ -2,31 +2,25 @@ package com.example.casera.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.casera.data.DataManager
import com.example.casera.models.TaskCompletion
import com.example.casera.models.TaskCompletionCreateRequest import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.models.TaskCompletionResponse
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.network.TaskCompletionApi import com.example.casera.network.APILayer
import com.example.casera.util.ImageCompressor import com.example.casera.util.ImageCompressor
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class TaskCompletionViewModel : ViewModel() { class TaskCompletionViewModel : ViewModel() {
private val taskCompletionApi = TaskCompletionApi()
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletion>>(ApiResult.Idle) private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletionResponse>>(ApiResult.Idle)
val createCompletionState: StateFlow<ApiResult<TaskCompletion>> = _createCompletionState val createCompletionState: StateFlow<ApiResult<TaskCompletionResponse>> = _createCompletionState
fun createTaskCompletion(request: TaskCompletionCreateRequest) { fun createTaskCompletion(request: TaskCompletionCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading _createCompletionState.value = ApiResult.Loading
val token = DataManager.authToken.value // Use APILayer which handles DataManager updates and summary refresh
if (token != null) { _createCompletionState.value = APILayer.createTaskCompletion(request)
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
} else {
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
}
} }
} }
@@ -42,31 +36,27 @@ class TaskCompletionViewModel : ViewModel() {
) { ) {
viewModelScope.launch { viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading _createCompletionState.value = ApiResult.Loading
val token = DataManager.authToken.value
if (token != null) {
// Compress images and prepare for upload
val compressedImages = images.map { ImageCompressor.compressImage(it) }
val imageFileNames = images.mapIndexed { index, image ->
// Always use .jpg extension since we compress to JPEG
val baseName = image.fileName.ifBlank { "completion_$index" }
if (baseName.endsWith(".jpg", ignoreCase = true) ||
baseName.endsWith(".jpeg", ignoreCase = true)) {
baseName
} else {
// Remove any existing extension and add .jpg
baseName.substringBeforeLast('.', baseName) + ".jpg"
}
}
_createCompletionState.value = taskCompletionApi.createCompletionWithImages( // Compress images and prepare for upload
token = token, val compressedImages = images.map { ImageCompressor.compressImage(it) }
request = request, val imageFileNames = images.mapIndexed { index, image ->
images = compressedImages, // Always use .jpg extension since we compress to JPEG
imageFileNames = imageFileNames val baseName = image.fileName.ifBlank { "completion_$index" }
) if (baseName.endsWith(".jpg", ignoreCase = true) ||
} else { baseName.endsWith(".jpeg", ignoreCase = true)) {
_createCompletionState.value = ApiResult.Error("Not authenticated", 401) baseName
} else {
// Remove any existing extension and add .jpg
baseName.substringBeforeLast('.', baseName) + ".jpg"
}
} }
// Use APILayer which handles DataManager updates and summary refresh
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
request = request,
images = compressedImages,
imageFileNames = imageFileNames
)
} }
} }

View File

@@ -2,6 +2,9 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for contractor management.
/// Observes DataManagerObservable for contractors list (automatically updated after mutations).
/// Calls APILayer for operations - DataManager updates propagate automatically via observation.
@MainActor @MainActor
class ContractorViewModel: ObservableObject { class ContractorViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -15,145 +18,168 @@ class ContractorViewModel: ObservableObject {
@Published var successMessage: String? @Published var successMessage: String?
// MARK: - Private Properties // MARK: - Private Properties
private let sharedViewModel: ComposeApp.ContractorViewModel private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init(sharedViewModel: ComposeApp.ContractorViewModel? = nil) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.ContractorViewModel() init() {
// Observe contractors from DataManagerObservable
DataManagerObservable.shared.$contractors
.receive(on: DispatchQueue.main)
.sink { [weak self] contractors in
self?.contractors = contractors
}
.store(in: &cancellables)
} }
// MARK: - Public Methods // MARK: - Public Methods
func loadContractors(
specialty: String? = nil, /// Load contractors list - delegates to APILayer which handles cache timeout
isFavorite: Bool? = nil, func loadContractors(forceRefresh: Bool = false) {
isActive: Bool? = nil,
search: String? = nil,
forceRefresh: Bool = false
) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.loadContractors( Task {
specialty: specialty, do {
isFavorite: isFavorite.asKotlin, let result = try await APILayer.shared.getContractors(
isActive: isActive.asKotlin, specialty: nil,
search: search, isFavorite: nil,
forceRefresh: forceRefresh isActive: nil,
) search: nil,
forceRefresh: forceRefresh
)
StateFlowObserver.observe( // API updates DataManager on success, which triggers our observation
sharedViewModel.contractorsState, if result is ApiResultSuccess<NSArray> {
onLoading: { [weak self] in self?.isLoading = true }, self.isLoading = false
onSuccess: { [weak self] (data: NSArray) in } else if let error = result as? ApiResultError {
self?.contractors = data as? [ContractorSummary] ?? [] self.errorMessage = ErrorMessageParser.parse(error.message)
self?.isLoading = false self.isLoading = false
}, }
onError: { [weak self] error in } catch {
self?.errorMessage = error self.errorMessage = error.localizedDescription
self?.isLoading = false self.isLoading = false
} }
) }
} }
func loadContractorDetail(id: Int32) { func loadContractorDetail(id: Int32) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.loadContractorDetail(id: id) Task {
do {
let result = try await APILayer.shared.getContractor(id: id, forceRefresh: false)
StateFlowObserver.observeWithState( if let success = result as? ApiResultSuccess<Contractor> {
sharedViewModel.contractorDetailState, self.selectedContractor = success.data
loadingSetter: { [weak self] in self?.isLoading = $0 }, self.isLoading = false
errorSetter: { [weak self] in self?.errorMessage = $0 }, } else if let error = result as? ApiResultError {
onSuccess: { [weak self] (data: Contractor) in self.errorMessage = ErrorMessageParser.parse(error.message)
self?.selectedContractor = data self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
} }
) }
} }
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) { func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
isCreating = true isCreating = true
errorMessage = nil errorMessage = nil
sharedViewModel.createContractor(request: request) Task {
do {
let result = try await APILayer.shared.createContractor(request: request)
StateFlowObserver.observe( if result is ApiResultSuccess<Contractor> {
sharedViewModel.createState, self.successMessage = "Contractor added successfully"
onLoading: { [weak self] in self?.isCreating = true }, self.isCreating = false
onSuccess: { [weak self] (_: Contractor) in // DataManager is updated by APILayer, view updates via observation
self?.successMessage = "Contractor added successfully" completion(true)
self?.isCreating = false } else if let error = result as? ApiResultError {
completion(true) self.errorMessage = ErrorMessageParser.parse(error.message)
}, self.isCreating = false
onError: { [weak self] error in completion(false)
self?.errorMessage = error }
self?.isCreating = false } catch {
self.errorMessage = error.localizedDescription
self.isCreating = false
completion(false) completion(false)
}, }
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } }
)
} }
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) { func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
isUpdating = true isUpdating = true
errorMessage = nil errorMessage = nil
sharedViewModel.updateContractor(id: id, request: request) Task {
do {
let result = try await APILayer.shared.updateContractor(id: id, request: request)
StateFlowObserver.observe( if result is ApiResultSuccess<Contractor> {
sharedViewModel.updateState, self.successMessage = "Contractor updated successfully"
onLoading: { [weak self] in self?.isUpdating = true }, self.isUpdating = false
onSuccess: { [weak self] (_: Contractor) in // DataManager is updated by APILayer, view updates via observation
self?.successMessage = "Contractor updated successfully" completion(true)
self?.isUpdating = false } else if let error = result as? ApiResultError {
completion(true) self.errorMessage = ErrorMessageParser.parse(error.message)
}, self.isUpdating = false
onError: { [weak self] error in completion(false)
self?.errorMessage = error }
self?.isUpdating = false } catch {
self.errorMessage = error.localizedDescription
self.isUpdating = false
completion(false) completion(false)
}, }
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } }
)
} }
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) { func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
isDeleting = true isDeleting = true
errorMessage = nil errorMessage = nil
sharedViewModel.deleteContractor(id: id) Task {
do {
let result = try await APILayer.shared.deleteContractor(id: id)
StateFlowObserver.observe( if result is ApiResultSuccess<KotlinUnit> {
sharedViewModel.deleteState, self.successMessage = "Contractor deleted successfully"
onLoading: { [weak self] in self?.isDeleting = true }, self.isDeleting = false
onSuccess: { [weak self] (_: KotlinUnit) in // DataManager is updated by APILayer, view updates via observation
self?.successMessage = "Contractor deleted successfully" completion(true)
self?.isDeleting = false } else if let error = result as? ApiResultError {
completion(true) self.errorMessage = ErrorMessageParser.parse(error.message)
}, self.isDeleting = false
onError: { [weak self] error in completion(false)
self?.errorMessage = error }
self?.isDeleting = false } catch {
self.errorMessage = error.localizedDescription
self.isDeleting = false
completion(false) completion(false)
}, }
resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } }
)
} }
func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) { func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) {
sharedViewModel.toggleFavorite(id: id) Task {
do {
let result = try await APILayer.shared.toggleFavorite(id: id)
StateFlowObserver.observe( if result is ApiResultSuccess<Contractor> {
sharedViewModel.toggleFavoriteState, // DataManager is updated by APILayer, view updates via observation
onSuccess: { (_: Contractor) in completion(true)
completion(true) } else if let error = result as? ApiResultError {
}, self.errorMessage = ErrorMessageParser.parse(error.message)
onError: { [weak self] error in completion(false)
self?.errorMessage = error }
} catch {
self.errorMessage = error.localizedDescription
completion(false) completion(false)
}, }
resetState: { [weak self] in self?.sharedViewModel.resetToggleFavoriteState() } }
)
} }
func clearMessages() { func clearMessages() {
@@ -161,4 +187,3 @@ class ContractorViewModel: ObservableObject {
successMessage = nil successMessage = nil
} }
} }

View File

@@ -178,7 +178,7 @@ struct ContractorsListView: View {
private func loadContractors(forceRefresh: Bool = false) { private func loadContractors(forceRefresh: Bool = false) {
// Load all contractors, filtering is done client-side // Load all contractors, filtering is done client-side
viewModel.loadContractors() viewModel.loadContractors(forceRefresh: forceRefresh)
} }
private func loadContractorSpecialties() { private func loadContractorSpecialties() {

View File

@@ -223,11 +223,17 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
var body: some View { var body: some View {
Group { Group {
if let errorMessage = errorMessage, items.isEmpty { if let errorMessage = errorMessage, items.isEmpty {
DefaultErrorView(message: errorMessage, onRetry: onRetry) // Wrap in ScrollView for pull-to-refresh support
.frame(maxWidth: .infinity, maxHeight: .infinity) ScrollView {
DefaultErrorView(message: errorMessage, onRetry: onRetry)
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
}
} else if items.isEmpty && !isLoading { } else if items.isEmpty && !isLoading {
emptyContent() // Wrap in ScrollView for pull-to-refresh support
.frame(maxWidth: .infinity, maxHeight: .infinity) ScrollView {
emptyContent()
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
}
} else { } else {
content(items) content(items)
} }

View File

@@ -35,6 +35,7 @@ class DataManagerObservable: ObservableObject {
@Published var residences: [ResidenceResponse] = [] @Published var residences: [ResidenceResponse] = []
@Published var myResidences: MyResidencesResponse? @Published var myResidences: MyResidencesResponse?
@Published var totalSummary: TotalSummary?
@Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:] @Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:]
// MARK: - Tasks // MARK: - Tasks
@@ -49,7 +50,7 @@ class DataManagerObservable: ObservableObject {
// MARK: - Contractors // MARK: - Contractors
@Published var contractors: [Contractor] = [] @Published var contractors: [ContractorSummary] = []
// MARK: - Subscription // MARK: - Subscription
@@ -138,6 +139,16 @@ class DataManagerObservable: ObservableObject {
} }
observationTasks.append(myResidencesTask) observationTasks.append(myResidencesTask)
// TotalSummary
let totalSummaryTask = Task {
for await summary in DataManager.shared.totalSummary {
await MainActor.run {
self.totalSummary = summary
}
}
}
observationTasks.append(totalSummaryTask)
// ResidenceSummaries // ResidenceSummaries
let residenceSummariesTask = Task { let residenceSummariesTask = Task {
for await summaries in DataManager.shared.residenceSummaries { for await summaries in DataManager.shared.residenceSummaries {
@@ -338,26 +349,35 @@ 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]
private func convertIntMap<V>(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] { private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
guard let map = kotlinMap as? [KotlinInt: V] else {
return [:]
}
var result: [Int32: V] = [:] var result: [Int32: V] = [:]
for (key, value) in kotlinMap { for (key, value) in map {
result[key.int32Value] = value result[key.int32Value] = value
} }
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: [KotlinInt: [V]]) -> [Int32: [V]] { private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
guard let map = kotlinMap as? [KotlinInt: [V]] else {
return [:]
}
var result: [Int32: [V]] = [:] var result: [Int32: [V]] = [:]
for (key, value) in kotlinMap { for (key, value) in map {
result[key.int32Value] = value result[key.int32Value] = value
} }
return result return result
} }
/// Convert Kotlin Map<String, V> to Swift [String: V] /// Convert Kotlin Map<String, V> to Swift [String: V]
private func convertStringMap<V>(_ kotlinMap: [String: V]) -> [String: V] { private func convertStringMap<V>(_ kotlinMap: Any?) -> [String: V] {
return kotlinMap guard let map = kotlinMap as? [String: V] else {
return [:]
}
return map
} }
// MARK: - Convenience Lookup Methods // MARK: - Convenience Lookup Methods

View File

@@ -3,16 +3,26 @@ import UIKit
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for document management.
/// Observes DataManagerObservable for documents list.
/// Calls APILayer directly for all operations.
@MainActor @MainActor
class DocumentViewModel: ObservableObject { class DocumentViewModel: ObservableObject {
@Published var documents: [Document] = [] @Published var documents: [Document] = []
@Published var isLoading = false @Published var isLoading = false
@Published var errorMessage: String? @Published var errorMessage: String?
private let sharedViewModel: ComposeApp.DocumentViewModel // MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
init(sharedViewModel: ComposeApp.DocumentViewModel? = nil) { init() {
self.sharedViewModel = sharedViewModel ?? ComposeApp.DocumentViewModel() // Observe documents from DataManagerObservable
DataManagerObservable.shared.$documents
.receive(on: DispatchQueue.main)
.sink { [weak self] documents in
self?.documents = documents
}
.store(in: &cancellables)
} }
func loadDocuments( func loadDocuments(
@@ -29,30 +39,32 @@ class DocumentViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.loadDocuments( Task {
residenceId: residenceId.asKotlin, do {
documentType: documentType, let result = try await APILayer.shared.getDocuments(
category: category, residenceId: residenceId.asKotlin,
contractorId: contractorId.asKotlin, documentType: documentType,
isActive: isActive.asKotlin, category: category,
expiringSoon: expiringSoon.asKotlin, contractorId: contractorId.asKotlin,
tags: tags, isActive: isActive.asKotlin,
search: search, expiringSoon: expiringSoon.asKotlin,
forceRefresh: forceRefresh tags: tags,
) search: search,
forceRefresh: forceRefresh
)
StateFlowObserver.observe( // API updates DataManager on success, which triggers our observation
sharedViewModel.documentsState, if result is ApiResultSuccess<NSArray> {
onLoading: { [weak self] in self?.isLoading = true }, self.isLoading = false
onSuccess: { [weak self] (data: NSArray) in } else if let error = result as? ApiResultError {
self?.documents = data as? [Document] ?? [] self.errorMessage = ErrorMessageParser.parse(error.message)
self?.isLoading = false self.isLoading = false
}, }
onError: { [weak self] error in } catch {
self?.errorMessage = error self.errorMessage = error.localizedDescription
self?.isLoading = false self.isLoading = false
} }
) }
} }
func createDocument( func createDocument(
@@ -82,53 +94,52 @@ class DocumentViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
// Convert UIImages to ImageData Task {
var imageDataList: [Any] = [] do {
for (index, image) in images.enumerated() { let result = try await APILayer.shared.createDocument(
if let jpegData = image.jpegData(compressionQuality: 0.8) { title: title,
// This would need platform-specific ImageData implementation documentType: documentType,
// For now, skip image conversion - would need to be handled differently residenceId: residenceId,
description: description,
category: category,
tags: tags,
notes: notes,
contractorId: contractorId.asKotlin,
isActive: isActive,
itemName: itemName,
modelNumber: modelNumber,
serialNumber: serialNumber,
provider: provider,
providerContact: providerContact,
claimPhone: claimPhone,
claimEmail: claimEmail,
claimWebsite: claimWebsite,
purchaseDate: purchaseDate,
startDate: startDate,
endDate: endDate,
fileBytes: nil,
fileName: nil,
mimeType: nil,
fileBytesList: nil,
fileNamesList: nil,
mimeTypesList: nil
)
if result is ApiResultSuccess<Document> {
self.isLoading = false
// DataManager is updated by APILayer, view updates via observation
completion(true, nil)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false, self.errorMessage)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false, self.errorMessage)
} }
} }
sharedViewModel.createDocument(
title: title,
documentType: documentType,
residenceId: residenceId,
description: description,
category: category,
tags: tags,
notes: notes,
contractorId: contractorId.asKotlin,
isActive: isActive,
itemName: itemName,
modelNumber: modelNumber,
serialNumber: serialNumber,
provider: provider,
providerContact: providerContact,
claimPhone: claimPhone,
claimEmail: claimEmail,
claimWebsite: claimWebsite,
purchaseDate: purchaseDate,
startDate: startDate,
endDate: endDate,
images: [] // Image handling needs platform-specific implementation
)
StateFlowObserver.observe(
sharedViewModel.createState,
onLoading: { [weak self] in self?.isLoading = true },
onSuccess: { [weak self] (_: Document) in
self?.isLoading = false
completion(true, nil)
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isLoading = false
completion(false, error)
},
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
)
} }
func updateDocument( func updateDocument(
@@ -157,65 +168,77 @@ class DocumentViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.updateDocument( Task {
id: id, do {
title: title, let result = try await APILayer.shared.updateDocument(
documentType: "", // Required but not changing id: id,
description: description, title: title,
category: category, documentType: "", // Required but not changing
tags: tags, description: description,
notes: notes, category: category,
contractorId: contractorId.asKotlin, tags: tags,
isActive: isActive, notes: notes,
itemName: itemName, contractorId: contractorId.asKotlin,
modelNumber: modelNumber, isActive: isActive,
serialNumber: serialNumber, itemName: itemName,
provider: provider, modelNumber: modelNumber,
providerContact: providerContact, serialNumber: serialNumber,
claimPhone: claimPhone, provider: provider,
claimEmail: claimEmail, providerContact: providerContact,
claimWebsite: claimWebsite, claimPhone: claimPhone,
purchaseDate: purchaseDate, claimEmail: claimEmail,
startDate: startDate, claimWebsite: claimWebsite,
endDate: endDate, purchaseDate: purchaseDate,
images: [] // Image handling needs platform-specific implementation startDate: startDate,
) endDate: endDate
)
StateFlowObserver.observe( if result is ApiResultSuccess<Document> {
sharedViewModel.updateState, self.isLoading = false
onLoading: { [weak self] in self?.isLoading = true }, // DataManager is updated by APILayer, view updates via observation
onSuccess: { [weak self] (_: Document) in completion(true, nil)
self?.isLoading = false } else if let error = result as? ApiResultError {
completion(true, nil) self.errorMessage = ErrorMessageParser.parse(error.message)
}, self.isLoading = false
onError: { [weak self] error in completion(false, self.errorMessage)
self?.errorMessage = error }
self?.isLoading = false } catch {
completion(false, error) self.errorMessage = error.localizedDescription
}, self.isLoading = false
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } completion(false, self.errorMessage)
) }
}
} }
func deleteDocument(id: Int32) { func deleteDocument(id: Int32, completion: @escaping (Bool) -> Void = { _ in }) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.deleteDocument(id: id) Task {
do {
let result = try await APILayer.shared.deleteDocument(id: id)
StateFlowObserver.observeWithState( if result is ApiResultSuccess<KotlinUnit> {
sharedViewModel.deleteState, self.isLoading = false
loadingSetter: { [weak self] in self?.isLoading = $0 }, // DataManager is updated by APILayer, view updates via observation
errorSetter: { [weak self] in self?.errorMessage = $0 }, completion(true)
onSuccess: { (_: KotlinUnit) in }, } else if let error = result as? ApiResultError {
resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } self.errorMessage = ErrorMessageParser.parse(error.message)
) self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
} }
func downloadDocument(url: String) -> Task<Data?, Error> { func downloadDocument(url: String) -> Task<Data?, Error> {
return Task { return Task {
do { do {
let result = try await sharedViewModel.downloadDocument(url: url) let result = try await APILayer.shared.downloadDocument(url: url)
if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data { if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data {
// Convert Kotlin ByteArray to Swift Data // Convert Kotlin ByteArray to Swift Data

View File

@@ -56,8 +56,6 @@ class DocumentViewModelWrapper: ObservableObject {
@Published var deleteState: DeleteState = DeleteStateIdle() @Published var deleteState: DeleteState = DeleteStateIdle()
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle() @Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient())
func loadDocuments( func loadDocuments(
residenceId: Int32? = nil, residenceId: Int32? = nil,
documentType: String? = nil, documentType: String? = nil,
@@ -68,29 +66,22 @@ class DocumentViewModelWrapper: ObservableObject {
tags: String? = nil, tags: String? = nil,
search: String? = nil search: String? = nil
) { ) {
guard let token = TokenStorage.shared.getToken() else {
DispatchQueue.main.async {
self.documentsState = DocumentStateError(message: "Not authenticated")
}
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.documentsState = DocumentStateLoading() self.documentsState = DocumentStateLoading()
} }
Task { Task {
do { do {
let result = try await documentApi.getDocuments( let result = try await APILayer.shared.getDocuments(
token: token, residenceId: residenceId != nil ? KotlinInt(int: residenceId!) : nil,
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
documentType: documentType, documentType: documentType,
category: category, category: category,
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, contractorId: contractorId != nil ? KotlinInt(int: contractorId!) : nil,
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil, isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil, expiringSoon: expiringSoon != nil ? KotlinInt(int: expiringSoon!) : nil,
tags: tags, tags: tags,
search: search search: search,
forceRefresh: false
) )
await MainActor.run { await MainActor.run {
@@ -110,20 +101,13 @@ class DocumentViewModelWrapper: ObservableObject {
} }
func loadDocumentDetail(id: Int32) { func loadDocumentDetail(id: Int32) {
guard let token = TokenStorage.shared.getToken() else {
DispatchQueue.main.async {
self.documentDetailState = DocumentDetailStateError(message: "Not authenticated")
}
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.documentDetailState = DocumentDetailStateLoading() self.documentDetailState = DocumentDetailStateLoading()
} }
Task { Task {
do { do {
let result = try await documentApi.getDocument(token: token, id: id) let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
await MainActor.run { await MainActor.run {
if let success = result as? ApiResultSuccess<Document>, let document = success.data { if let success = result as? ApiResultSuccess<Document>, let document = success.data {
@@ -161,21 +145,13 @@ class DocumentViewModelWrapper: ObservableObject {
startDate: String? = nil, startDate: String? = nil,
endDate: String? = nil endDate: String? = nil
) { ) {
guard let token = TokenStorage.shared.getToken() else {
DispatchQueue.main.async {
self.updateState = UpdateStateError(message: "Not authenticated")
}
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateState = UpdateStateLoading() self.updateState = UpdateStateLoading()
} }
Task { Task {
do { do {
let result = try await documentApi.updateDocument( let result = try await APILayer.shared.updateDocument(
token: token,
id: id, id: id,
title: title, title: title,
documentType: documentType, documentType: documentType,
@@ -184,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject {
tags: tags, tags: tags,
notes: notes, notes: notes,
contractorId: nil, contractorId: nil,
isActive: KotlinBoolean(bool: isActive), isActive: isActive,
itemName: itemName, itemName: itemName,
modelNumber: modelNumber, modelNumber: modelNumber,
serialNumber: serialNumber, serialNumber: serialNumber,
@@ -195,10 +171,7 @@ class DocumentViewModelWrapper: ObservableObject {
claimWebsite: claimWebsite, claimWebsite: claimWebsite,
purchaseDate: purchaseDate, purchaseDate: purchaseDate,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate
fileBytes: nil,
fileName: nil,
mimeType: nil
) )
await MainActor.run { await MainActor.run {
@@ -219,20 +192,13 @@ class DocumentViewModelWrapper: ObservableObject {
} }
func deleteDocument(id: Int32) { func deleteDocument(id: Int32) {
guard let token = TokenStorage.shared.getToken() else {
DispatchQueue.main.async {
self.deleteState = DeleteStateError(message: "Not authenticated")
}
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.deleteState = DeleteStateLoading() self.deleteState = DeleteStateLoading()
} }
Task { Task {
do { do {
let result = try await documentApi.deleteDocument(token: token, id: id) let result = try await APILayer.shared.deleteDocument(id: id)
await MainActor.run { await MainActor.run {
if result is ApiResultSuccess<KotlinUnit> { if result is ApiResultSuccess<KotlinUnit> {
@@ -262,20 +228,13 @@ class DocumentViewModelWrapper: ObservableObject {
} }
func deleteDocumentImage(imageId: Int32) { func deleteDocumentImage(imageId: Int32) {
guard let token = TokenStorage.shared.getToken() else {
DispatchQueue.main.async {
self.deleteImageState = DeleteImageStateError(message: "Not authenticated")
}
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.deleteImageState = DeleteImageStateLoading() self.deleteImageState = DeleteImageStateLoading()
} }
Task { Task {
do { do {
let result = try await documentApi.deleteDocumentImage(token: token, imageId: imageId) let result = try await APILayer.shared.deleteDocumentImage(imageId: imageId)
await MainActor.run { await MainActor.run {
if result is ApiResultSuccess<KotlinUnit> { if result is ApiResultSuccess<KotlinUnit> {

View File

@@ -20,12 +20,32 @@ struct DocumentsWarrantiesView: View {
let residenceId: Int32? let residenceId: Int32?
// Client-side filtering for warranties tab
var warranties: [Document] { var warranties: [Document] {
documentViewModel.documents.filter { $0.documentType == "warranty" } documentViewModel.documents.filter { doc in
guard doc.documentType == "warranty" else { return false }
// Apply active filter if enabled
if showActiveOnly && doc.isActive != true {
return false
}
// Apply category filter if selected
if let category = selectedCategory, doc.category != category {
return false
}
return true
}
} }
// Client-side filtering for documents tab
var documents: [Document] { var documents: [Document] {
documentViewModel.documents.filter { $0.documentType != "warranty" } documentViewModel.documents.filter { doc in
guard doc.documentType != "warranty" else { return false }
// Apply document type filter if selected
if let docType = selectedDocType, doc.documentType != docType {
return false
}
return true
}
} }
// Check if upgrade screen should be shown (disables add button) // Check if upgrade screen should be shown (disables add button)
@@ -104,23 +124,21 @@ struct DocumentsWarrantiesView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: AppSpacing.sm) {
// Active Filter (for warranties) // Active Filter (for warranties) - client-side, no API call
if selectedTab == .warranties { if selectedTab == .warranties {
Button(action: { Button(action: {
showActiveOnly.toggle() showActiveOnly.toggle()
loadWarranties()
}) { }) {
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle") Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary) .foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
} }
} }
// Filter Menu // Filter Menu - client-side filtering, no API calls
Menu { Menu {
if selectedTab == .warranties { if selectedTab == .warranties {
Button(action: { Button(action: {
selectedCategory = nil selectedCategory = nil
loadWarranties()
}) { }) {
Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "") Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "")
} }
@@ -130,7 +148,6 @@ struct DocumentsWarrantiesView: View {
ForEach(DocumentCategory.allCases, id: \.self) { category in ForEach(DocumentCategory.allCases, id: \.self) { category in
Button(action: { Button(action: {
selectedCategory = category.displayName selectedCategory = category.displayName
loadWarranties()
}) { }) {
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "") Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
} }
@@ -138,7 +155,6 @@ struct DocumentsWarrantiesView: View {
} else { } else {
Button(action: { Button(action: {
selectedDocType = nil selectedDocType = nil
loadDocuments()
}) { }) {
Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "") Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "")
} }
@@ -148,7 +164,6 @@ struct DocumentsWarrantiesView: View {
ForEach(DocumentType.allCases, id: \.self) { type in ForEach(DocumentType.allCases, id: \.self) { type in
Button(action: { Button(action: {
selectedDocType = type.displayName selectedDocType = type.displayName
loadDocuments()
}) { }) {
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "") Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
} }
@@ -177,16 +192,10 @@ struct DocumentsWarrantiesView: View {
} }
} }
.onAppear { .onAppear {
loadWarranties() // Load all documents once - filtering is client-side
loadDocuments() loadAllDocuments()
}
.onChange(of: selectedTab) { _ in
if selectedTab == .warranties {
loadWarranties()
} else {
loadDocuments()
}
} }
// No need for onChange on selectedTab - filtering is client-side
.sheet(isPresented: $showAddSheet) { .sheet(isPresented: $showAddSheet) {
AddDocumentView( AddDocumentView(
residenceId: residenceId, residenceId: residenceId,
@@ -200,20 +209,20 @@ struct DocumentsWarrantiesView: View {
} }
} }
private func loadAllDocuments(forceRefresh: Bool = false) {
// Load all documents without filters to use cache
// Filtering is done client-side in the computed properties
documentViewModel.loadDocuments(forceRefresh: forceRefresh)
}
private func loadWarranties() { private func loadWarranties() {
documentViewModel.loadDocuments( // Just reload all - filtering happens client-side
residenceId: residenceId, loadAllDocuments()
documentType: "warranty",
category: selectedCategory,
isActive: showActiveOnly ? true : nil
)
} }
private func loadDocuments() { private func loadDocuments() {
documentViewModel.loadDocuments( // Just reload all - filtering happens client-side
residenceId: residenceId, loadAllDocuments()
documentType: selectedDocType
)
} }
} }

View File

@@ -2,8 +2,8 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for handling Apple Sign In flow /// ViewModel for handling Apple Sign In flow.
/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin) /// Calls APILayer directly for backend authentication.
@MainActor @MainActor
class AppleSignInViewModel: ObservableObject { class AppleSignInViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -13,21 +13,10 @@ class AppleSignInViewModel: ObservableObject {
// MARK: - Private Properties // MARK: - Private Properties
private let appleSignInManager = AppleSignInManager() private let appleSignInManager = AppleSignInManager()
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol
// MARK: - Callbacks // MARK: - Callbacks
var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
}
// MARK: - Public Methods // MARK: - Public Methods
/// Initiates the Apple Sign In flow /// Initiates the Apple Sign In flow
@@ -58,70 +47,43 @@ class AppleSignInViewModel: ObservableObject {
/// Sends Apple credential to backend for verification/authentication /// Sends Apple credential to backend for verification/authentication
private func sendCredentialToBackend(_ credential: AppleSignInCredential) { private func sendCredentialToBackend(_ credential: AppleSignInCredential) {
sharedViewModel.appleSignIn(
idToken: credential.identityToken,
userId: credential.userIdentifier,
email: credential.email,
firstName: credential.firstName,
lastName: credential.lastName
)
// Observe the result
Task { Task {
for await state in sharedViewModel.appleSignInState { do {
if state is ApiResultLoading { let request = AppleSignInRequest(
await MainActor.run { idToken: credential.identityToken,
self.isLoading = true userId: credential.userIdentifier,
} email: credential.email,
} else if let success = state as? ApiResultSuccess<AppleSignInResponse> { firstName: credential.firstName,
await MainActor.run { lastName: credential.lastName
self.handleSuccess(success.data) )
} let result = try await APILayer.shared.appleSignIn(request: request)
break
} else if let error = state as? ApiResultError { if let success = result as? ApiResultSuccess<AppleSignInResponse>, let response = success.data {
await MainActor.run { self.handleSuccess(response)
self.handleBackendError(error) } else if let error = result as? ApiResultError {
} self.handleBackendError(error)
break
} }
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
} }
} }
} }
/// Handles successful authentication /// Handles successful authentication
private func handleSuccess(_ response: AppleSignInResponse?) { private func handleSuccess(_ response: AppleSignInResponse) {
isLoading = false isLoading = false
guard let response = response,
let token = response.token as String? else {
errorMessage = "Invalid response from server"
return
}
let user = response.user let user = response.user
// Store the token
tokenStorage.saveToken(token: token)
// Track if this is a new user // Track if this is a new user
isNewUser = response.isNewUser isNewUser = response.isNewUser
// Initialize lookups // APILayer.appleSignIn already:
Task { // - Stores token in DataManager
_ = try? await APILayer.shared.initializeLookups() // - Sets current user in DataManager
} // - Initializes lookups
// - Prefetches all data
// Prefetch data
Task {
do {
print("Starting data prefetch after Apple Sign In...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try await prefetchManager.prefetchAllData()
print("Data prefetch completed successfully")
} catch {
print("Data prefetch failed: \(error.localizedDescription)")
}
}
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)") print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")
@@ -147,7 +109,6 @@ class AppleSignInViewModel: ObservableObject {
/// Handles backend API errors /// Handles backend API errors
private func handleBackendError(_ error: ApiResultError) { private func handleBackendError(_ error: ApiResultError) {
isLoading = false isLoading = false
sharedViewModel.resetAppleSignInState()
if let code = error.code?.intValue { if let code = error.code?.intValue {
switch code { switch code {

View File

@@ -2,30 +2,44 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for user login.
/// Observes DataManagerObservable for authentication state.
/// Kicks off API calls that update DataManager, letting views react to cache updates.
@MainActor @MainActor
class LoginViewModel: ObservableObject { class LoginViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties (from DataManager observation)
@Published var currentUser: User?
@Published var isAuthenticated: Bool = false
// MARK: - Local State
@Published var username: String = "" @Published var username: String = ""
@Published var password: String = "" @Published var password: String = ""
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var isVerified: Bool = false @Published var isVerified: Bool = false
@Published var currentUser: User?
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol
// Callback for successful login // Callback for successful login
var onLoginSuccess: ((Bool) -> Void)? var onLoginSuccess: ((Bool) -> Void)?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init( init() {
sharedViewModel: ComposeApp.AuthViewModel? = nil, // Observe DataManagerObservable for authentication state
tokenStorage: TokenStorageProtocol? = nil DataManagerObservable.shared.$currentUser
) { .receive(on: DispatchQueue.main)
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() .sink { [weak self] user in
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() self?.currentUser = user
}
.store(in: &cancellables)
DataManagerObservable.shared.$isAuthenticated
.receive(on: DispatchQueue.main)
.sink { [weak self] isAuth in
self?.isAuthenticated = isAuth
}
.store(in: &cancellables)
} }
// MARK: - Public Methods // MARK: - Public Methods
@@ -43,175 +57,94 @@ class LoginViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.login(username: username, password: password)
Task { Task {
for await state in sharedViewModel.loginState { do {
if state is ApiResultLoading { let result = try await APILayer.shared.login(
await MainActor.run { request: LoginRequest(username: username, password: password)
self.isLoading = true )
if let success = result as? ApiResultSuccess<AuthResponse>,
let response = success.data {
// APILayer.login already stores token in DataManager
// currentUser will be updated via DataManagerObservable observation
self.isVerified = response.user.verified
self.isLoading = false
print("Login successful!")
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
// Initialize lookups via APILayer
Task {
_ = try? await APILayer.shared.initializeLookups()
} }
} else if let success = state as? ApiResultSuccess<AuthResponse> {
await MainActor.run {
if let token = success.data?.token,
let user = success.data?.user {
self.tokenStorage.saveToken(token: token)
// Store user data and verification status // Prefetch all data for caching
self.currentUser = user Task {
self.isVerified = user.verified do {
self.isLoading = false print("Starting data prefetch...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
print("Login successful! Token: token") _ = try await prefetchManager.prefetchAllData()
print("User: \(user.username), Verified: \(user.verified)") print("Data prefetch completed successfully")
print("isVerified set to: \(self.isVerified)") } catch {
print("Data prefetch failed: \(error.localizedDescription)")
// Initialize lookups via APILayer // Don't block login on prefetch failure
Task {
_ = try? await APILayer.shared.initializeLookups()
}
// Prefetch all data for caching
Task {
do {
print("Starting data prefetch...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try await prefetchManager.prefetchAllData()
print("Data prefetch completed successfully")
} catch {
print("Data prefetch failed: \(error.localizedDescription)")
// Don't block login on prefetch failure
}
}
// Call login success callback
self.onLoginSuccess?(user.verified)
} }
} }
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.isLoading = false
// Check for specific error codes and provide user-friendly messages // Call login success callback
if let code = error.code?.intValue { self.onLoginSuccess?(self.isVerified)
switch code { } else if let error = result as? ApiResultError {
case 400, 401: self.isLoading = false
self.errorMessage = "Invalid username or password" self.handleLoginError(error)
case 403:
self.errorMessage = "Access denied. Please check your credentials."
case 404:
self.errorMessage = "Service not found. Please try again later."
case 500...599:
self.errorMessage = "Server error. Please try again later."
default:
self.errorMessage = ErrorMessageParser.parse(error.message)
}
} else {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
print("API Error: \(error.message)")
}
break
} }
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
} }
} }
} }
// Helper function to clean up error messages private func handleLoginError(_ error: ApiResultError) {
private func cleanErrorMessage(_ message: String) -> String { // Check for specific error codes and provide user-friendly messages
// Remove common API error prefixes and technical details if let code = error.code?.intValue {
var cleaned = message switch code {
case 400, 401:
// Remove JSON-like error structures self.errorMessage = "Invalid username or password"
if let range = cleaned.range(of: #"[{\[]"#, options: .regularExpression) { case 403:
cleaned = String(cleaned[..<range.lowerBound]) self.errorMessage = "Access denied. Please check your credentials."
case 404:
self.errorMessage = "Service not found. Please try again later."
case 500...599:
self.errorMessage = "Server error. Please try again later."
default:
self.errorMessage = ErrorMessageParser.parse(error.message)
}
} else {
self.errorMessage = ErrorMessageParser.parse(error.message)
} }
print("API Error: \(error.message)")
// Remove "Error:" prefix if present
cleaned = cleaned.replacingOccurrences(of: "Error:", with: "")
// Trim whitespace
cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
// If message is too technical or empty, provide a generic message
if cleaned.isEmpty || cleaned.count > 100 || cleaned.contains("Exception") {
return "Unable to sign in. Please check your credentials and try again."
}
// Capitalize first letter
if let first = cleaned.first {
cleaned = first.uppercased() + cleaned.dropFirst()
}
// Ensure it ends with a period
if !cleaned.hasSuffix(".") && !cleaned.hasSuffix("!") && !cleaned.hasSuffix("?") {
cleaned += "."
}
return cleaned
} }
func logout() { func logout() {
// Call shared ViewModel logout Task {
sharedViewModel.logout() // APILayer.logout clears DataManager
try? await APILayer.shared.logout()
// Clear token from storage // Clear widget task data
tokenStorage.clearToken() WidgetDataManager.shared.clearCache()
// Clear lookups data on logout via DataCache // Reset local state
DataCache.shared.clearLookups() self.isVerified = false
self.currentUser = nil
self.username = ""
self.password = ""
self.errorMessage = nil
// Clear all cached data print("Logged out - all state reset")
DataCache.shared.clearAll() }
// Reset state
isVerified = false
currentUser = nil
username = ""
password = ""
errorMessage = nil
print("Logged out - all state reset")
} }
func clearError() { func clearError() {
errorMessage = nil errorMessage = nil
} }
// MARK: - Private Methods
private func checkAuthenticationStatus() {
guard tokenStorage.getToken() != nil else {
isVerified = false
return
}
// Fetch current user to check verification status
sharedViewModel.getCurrentUser(forceRefresh: false)
StateFlowObserver.observe(
sharedViewModel.currentUserState,
onSuccess: { [weak self] (user: User) in
self?.currentUser = user
self?.isVerified = user.verified
// Initialize lookups if verified
if user.verified {
Task {
_ = try? await APILayer.shared.initializeLookups()
}
}
print("Auth check - User: \(user.username), Verified: \(user.verified)")
},
onError: { [weak self] _ in
// Token invalid or expired, clear it
self?.tokenStorage.clearToken()
self?.isVerified = false
},
resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() }
)
}
} }

View File

@@ -141,27 +141,12 @@ struct OnboardingJoinResidenceContent: View {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
Task { viewModel.joinWithCode(code: shareCode) { success in
// Call the shared ViewModel which uses APILayer isLoading = false
await viewModel.sharedViewModel.joinWithCode(code: shareCode) if success {
onJoined()
// Observe the result } else {
for await state in viewModel.sharedViewModel.joinResidenceState { errorMessage = viewModel.errorMessage
if state is ApiResultSuccess<JoinResidenceResponse> {
await MainActor.run {
viewModel.sharedViewModel.resetJoinResidenceState()
isLoading = false
onJoined()
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
errorMessage = ErrorMessageParser.parse(error.message)
viewModel.sharedViewModel.resetJoinResidenceState()
isLoading = false
}
break
}
} }
} }
} }

View File

@@ -9,6 +9,8 @@ enum PasswordResetStep: CaseIterable {
case success // Final: Success confirmation case success // Final: Success confirmation
} }
/// ViewModel for password reset flow.
/// Calls APILayer directly for all password reset operations.
@MainActor @MainActor
class PasswordResetViewModel: ObservableObject { class PasswordResetViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -22,16 +24,8 @@ class PasswordResetViewModel: ObservableObject {
@Published var currentStep: PasswordResetStep = .requestCode @Published var currentStep: PasswordResetStep = .requestCode
@Published var resetToken: String? @Published var resetToken: String?
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
// MARK: - Initialization // MARK: - Initialization
init( init(resetToken: String? = nil) {
resetToken: String? = nil,
sharedViewModel: ComposeApp.AuthViewModel? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
// If we have a reset token from deep link, skip to password reset step // If we have a reset token from deep link, skip to password reset step
if let token = resetToken { if let token = resetToken {
self.resetToken = token self.resetToken = token
@@ -51,27 +45,29 @@ class PasswordResetViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.forgotPassword(email: email) Task {
do {
let request = ForgotPasswordRequest(email: email)
let result = try await APILayer.shared.forgotPassword(request: request)
StateFlowObserver.observe( if result is ApiResultSuccess<ForgotPasswordResponse> {
sharedViewModel.forgotPasswordState, self.isLoading = false
onLoading: { [weak self] in self?.isLoading = true }, self.successMessage = "Check your email for a 6-digit verification code"
onSuccess: { [weak self] (_: ForgotPasswordResponse) in
self?.isLoading = false
self?.successMessage = "Check your email for a 6-digit verification code"
// Automatically move to next step after short delay // Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self?.successMessage = nil self.successMessage = nil
self?.currentStep = .verifyCode self.currentStep = .verifyCode
}
} else if let error = result as? ApiResultError {
self.isLoading = false
self.errorMessage = ErrorMessageParser.parse(error.message)
} }
}, } catch {
onError: { [weak self] error in self.isLoading = false
self?.isLoading = false self.errorMessage = error.localizedDescription
self?.errorMessage = error }
}, }
resetState: { [weak self] in self?.sharedViewModel.resetForgotPasswordState() }
)
} }
/// Step 2: Verify reset code /// Step 2: Verify reset code
@@ -84,30 +80,31 @@ class PasswordResetViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.verifyResetCode(email: email, code: code) Task {
do {
let request = VerifyResetCodeRequest(email: email, code: code)
let result = try await APILayer.shared.verifyResetCode(request: request)
StateFlowObserver.observe( if let success = result as? ApiResultSuccess<VerifyResetCodeResponse>, let response = success.data {
sharedViewModel.verifyResetCodeState, let token = response.resetToken
onLoading: { [weak self] in self?.isLoading = true }, self.resetToken = token
onSuccess: { [weak self] (response: VerifyResetCodeResponse) in self.isLoading = false
guard let self = self else { return } self.successMessage = "Code verified! Now set your new password"
let token = response.resetToken
self.resetToken = token
self.isLoading = false
self.successMessage = "Code verified! Now set your new password"
// Automatically move to next step after short delay // Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.successMessage = nil self.successMessage = nil
self.currentStep = .resetPassword self.currentStep = .resetPassword
}
} else if let error = result as? ApiResultError {
self.isLoading = false
self.handleVerifyError(ErrorMessageParser.parse(error.message))
} }
}, } catch {
onError: { [weak self] error in self.isLoading = false
self?.isLoading = false self.errorMessage = error.localizedDescription
self?.handleVerifyError(error) }
}, }
resetState: { [weak self] in self?.sharedViewModel.resetVerifyResetCodeState() }
)
} }
/// Step 3: Reset password /// Step 3: Reset password
@@ -135,22 +132,27 @@ class PasswordResetViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.resetPassword(resetToken: token, newPassword: newPassword, confirmPassword: confirmPassword) Task {
do {
let request = ResetPasswordRequest(
resetToken: token,
newPassword: newPassword
)
let result = try await APILayer.shared.resetPassword(request: request)
StateFlowObserver.observe( if result is ApiResultSuccess<ResetPasswordResponse> {
sharedViewModel.resetPasswordState, self.isLoading = false
onLoading: { [weak self] in self?.isLoading = true }, self.successMessage = "Password reset successfully! You can now log in with your new password."
onSuccess: { [weak self] (_: ResetPasswordResponse) in self.currentStep = .success
self?.isLoading = false } else if let error = result as? ApiResultError {
self?.successMessage = "Password reset successfully! You can now log in with your new password." self.isLoading = false
self?.currentStep = .success self.errorMessage = ErrorMessageParser.parse(error.message)
}, }
onError: { [weak self] error in } catch {
self?.isLoading = false self.isLoading = false
self?.errorMessage = error self.errorMessage = error.localizedDescription
}, }
resetState: { [weak self] in self?.sharedViewModel.resetResetPasswordState() } }
)
} }
/// Navigate to next step /// Navigate to next step

View File

@@ -228,48 +228,30 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var isSaving: Bool = false @Published var isSaving: Bool = false
private let sharedViewModel = ComposeApp.NotificationPreferencesViewModel()
private var preferencesTask: Task<Void, Never>?
private var updateTask: Task<Void, Never>?
func loadPreferences() { func loadPreferences() {
preferencesTask?.cancel()
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.loadPreferences() Task {
do {
let result = try await APILayer.shared.getNotificationPreferences()
preferencesTask = Task { if let success = result as? ApiResultSuccess<NotificationPreference>, let prefs = success.data {
for await state in sharedViewModel.preferencesState { self.taskDueSoon = prefs.taskDueSoon
if Task.isCancelled { break } self.taskOverdue = prefs.taskOverdue
self.taskCompleted = prefs.taskCompleted
await MainActor.run { self.taskAssigned = prefs.taskAssigned
switch state { self.residenceShared = prefs.residenceShared
case let success as ApiResultSuccess<NotificationPreference>: self.warrantyExpiring = prefs.warrantyExpiring
if let prefs = success.data { self.isLoading = false
self.taskDueSoon = prefs.taskDueSoon self.errorMessage = nil
self.taskOverdue = prefs.taskOverdue } else if let error = result as? ApiResultError {
self.taskCompleted = prefs.taskCompleted self.errorMessage = ErrorMessageParser.parse(error.message)
self.taskAssigned = prefs.taskAssigned self.isLoading = false
self.residenceShared = prefs.residenceShared
self.warrantyExpiring = prefs.warrantyExpiring
}
self.isLoading = false
self.errorMessage = nil
case let error as ApiResultError:
self.errorMessage = error.message
self.isLoading = false
case is ApiResultLoading:
self.isLoading = true
default:
break
}
}
// Break after success or error
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
break
} }
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
} }
} }
} }
@@ -282,50 +264,32 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
residenceShared: Bool? = nil, residenceShared: Bool? = nil,
warrantyExpiring: Bool? = nil warrantyExpiring: Bool? = nil
) { ) {
updateTask?.cancel()
isSaving = true isSaving = true
sharedViewModel.updatePreference( Task {
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) }, do {
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) }, let request = UpdateNotificationPreferencesRequest(
taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) }, taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) }, taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) }, taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) },
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) } taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
) residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) }
)
let result = try await APILayer.shared.updateNotificationPreferences(request: request)
updateTask = Task { if result is ApiResultSuccess<NotificationPreference> {
for await state in sharedViewModel.updateState { self.isSaving = false
if Task.isCancelled { break } } else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
await MainActor.run { self.isSaving = false
switch state {
case is ApiResultSuccess<NotificationPreference>:
self.isSaving = false
self.sharedViewModel.resetUpdateState()
case let error as ApiResultError:
self.errorMessage = error.message
self.isSaving = false
self.sharedViewModel.resetUpdateState()
case is ApiResultLoading:
self.isSaving = true
default:
break
}
}
// Break after success or error
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
break
} }
} catch {
self.errorMessage = error.localizedDescription
self.isSaving = false
} }
} }
} }
deinit {
preferencesTask?.cancel()
updateTask?.cancel()
}
} }
#Preview { #Preview {

View File

@@ -2,6 +2,9 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for user profile management.
/// Observes DataManagerObservable for current user.
/// Calls APILayer directly for profile updates.
@MainActor @MainActor
class ProfileViewModel: ObservableObject { class ProfileViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -14,17 +17,26 @@ class ProfileViewModel: ObservableObject {
@Published var successMessage: String? @Published var successMessage: String?
// MARK: - Private Properties // MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol private let tokenStorage: TokenStorageProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init( init(tokenStorage: TokenStorageProtocol? = nil) {
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
// Observe current user from DataManagerObservable
DataManagerObservable.shared.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
if let user = user {
self?.firstName = user.firstName ?? ""
self?.lastName = user.lastName ?? ""
self?.email = user.email
self?.isLoadingUser = false
}
}
.store(in: &cancellables)
// Load current user data // Load current user data
loadCurrentUser() loadCurrentUser()
} }
@@ -37,27 +49,32 @@ class ProfileViewModel: ObservableObject {
return return
} }
// Check if we already have user data
if DataManagerObservable.shared.currentUser != nil {
isLoadingUser = false
return
}
isLoadingUser = true isLoadingUser = true
errorMessage = nil errorMessage = nil
sharedViewModel.getCurrentUser(forceRefresh: false) Task {
do {
let result = try await APILayer.shared.getCurrentUser(forceRefresh: false)
StateFlowObserver.observe( // DataManager is updated by APILayer, UI updates via Combine observation
sharedViewModel.currentUserState, if result is ApiResultSuccess<User> {
onLoading: { [weak self] in self?.isLoadingUser = true }, self.isLoadingUser = false
onSuccess: { [weak self] (user: User) in self.errorMessage = nil
self?.firstName = user.firstName ?? "" } else if let error = result as? ApiResultError {
self?.lastName = user.lastName ?? "" self.errorMessage = ErrorMessageParser.parse(error.message)
self?.email = user.email self.isLoadingUser = false
self?.isLoadingUser = false }
self?.errorMessage = nil } catch {
}, self.errorMessage = error.localizedDescription
onError: { [weak self] error in self.isLoadingUser = false
self?.errorMessage = error }
self?.isLoadingUser = false }
},
resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() }
)
} }
func updateProfile() { func updateProfile() {
@@ -66,7 +83,7 @@ class ProfileViewModel: ObservableObject {
return return
} }
guard tokenStorage.getToken() != nil else { guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated" errorMessage = "Not authenticated"
return return
} }
@@ -75,31 +92,31 @@ class ProfileViewModel: ObservableObject {
errorMessage = nil errorMessage = nil
successMessage = nil successMessage = nil
sharedViewModel.updateProfile( Task {
firstName: firstName.isEmpty ? nil : firstName, do {
lastName: lastName.isEmpty ? nil : lastName, let request = UpdateProfileRequest(
email: email firstName: firstName.isEmpty ? nil : firstName,
) lastName: lastName.isEmpty ? nil : lastName,
email: email
)
let result = try await APILayer.shared.updateProfile(token: token, request: request)
StateFlowObserver.observe( // DataManager is updated by APILayer, UI updates via Combine observation
sharedViewModel.updateProfileState, if result is ApiResultSuccess<User> {
onLoading: { [weak self] in self?.isLoading = true }, self.isLoading = false
onSuccess: { [weak self] (user: User) in self.errorMessage = nil
self?.firstName = user.firstName ?? "" self.successMessage = "Profile updated successfully"
self?.lastName = user.lastName ?? "" } else if let error = result as? ApiResultError {
self?.email = user.email self.isLoading = false
self?.isLoading = false self.errorMessage = ErrorMessageParser.parse(error.message)
self?.errorMessage = nil self.successMessage = nil
self?.successMessage = "Profile updated successfully" }
print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")") } catch {
}, self.isLoading = false
onError: { [weak self] error in self.errorMessage = error.localizedDescription
self?.isLoading = false self.successMessage = nil
self?.errorMessage = error }
self?.successMessage = nil }
},
resetState: { [weak self] in self?.sharedViewModel.resetUpdateProfileState() }
)
} }
func clearMessages() { func clearMessages() {

View File

@@ -2,6 +2,8 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for user registration.
/// Calls APILayer directly for registration.
@MainActor @MainActor
class RegisterViewModel: ObservableObject { class RegisterViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -14,15 +16,10 @@ class RegisterViewModel: ObservableObject {
@Published var isRegistered: Bool = false @Published var isRegistered: Bool = false
// MARK: - Private Properties // MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol private let tokenStorage: TokenStorageProtocol
// MARK: - Initialization // MARK: - Initialization
init( init(tokenStorage: TokenStorageProtocol? = nil) {
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
} }
@@ -52,33 +49,32 @@ class RegisterViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.register(username: username, email: email, password: password) Task {
do {
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
let result = try await APILayer.shared.register(request: request)
StateFlowObserver.observe( if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
sharedViewModel.registerState, let token = response.token
onLoading: { [weak self] in self?.isLoading = true }, self.tokenStorage.saveToken(token: token)
onSuccess: { [weak self] (response: AuthResponse) in
guard let self = self else { return }
let token = response.token
self.tokenStorage.saveToken(token: token)
// 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 // Initialize lookups via APILayer after successful registration
Task {
_ = try? await APILayer.shared.initializeLookups() _ = try? await APILayer.shared.initializeLookups()
}
self.isRegistered = true self.isRegistered = true
self.isLoading = false
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false self.isLoading = false
}, }
onError: { [weak self] error in }
self?.errorMessage = error
self?.isLoading = false
},
resetState: { [weak self] in self?.sharedViewModel.resetRegisterState() }
)
} }
func clearError() { func clearError() {

View File

@@ -80,27 +80,12 @@ struct JoinResidenceView: View {
return return
} }
Task { viewModel.joinWithCode(code: shareCode) { success in
// Call the shared ViewModel which uses APILayer if success {
await viewModel.sharedViewModel.joinWithCode(code: shareCode) onJoined()
dismiss()
// Observe the result
for await state in viewModel.sharedViewModel.joinResidenceState {
if state is ApiResultSuccess<JoinResidenceResponse> {
await MainActor.run {
viewModel.sharedViewModel.resetJoinResidenceState()
onJoined()
dismiss()
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
viewModel.errorMessage = ErrorMessageParser.parse(error.message)
viewModel.sharedViewModel.resetJoinResidenceState()
}
break
}
} }
// Error is handled by ViewModel and displayed via viewModel.errorMessage
} }
} }
} }

View File

@@ -2,11 +2,17 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for residence management.
/// Observes DataManagerObservable for cached data.
/// Kicks off API calls that update DataManager, letting views react to cache updates.
@MainActor @MainActor
class ResidenceViewModel: ObservableObject { class ResidenceViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties (from DataManager observation)
@Published var residenceSummary: ResidenceSummaryResponse?
@Published var myResidences: MyResidencesResponse? @Published var myResidences: MyResidencesResponse?
@Published var residences: [ResidenceResponse] = []
@Published var totalSummary: TotalSummary?
// MARK: - Local State
@Published var selectedResidence: ResidenceResponse? @Published var selectedResidence: ResidenceResponse?
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var errorMessage: String? @Published var errorMessage: String?
@@ -14,57 +20,105 @@ class ResidenceViewModel: ObservableObject {
@Published var reportMessage: String? @Published var reportMessage: String?
// MARK: - Private Properties // MARK: - Private Properties
public let sharedViewModel: ComposeApp.ResidenceViewModel private var cancellables = Set<AnyCancellable>()
private let tokenStorage: TokenStorageProtocol
// MARK: - Initialization // MARK: - Initialization
init( init() {
sharedViewModel: ComposeApp.ResidenceViewModel? = nil, // Observe DataManagerObservable for residence data
tokenStorage: TokenStorageProtocol? = nil DataManagerObservable.shared.$myResidences
) { .receive(on: DispatchQueue.main)
self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel() .sink { [weak self] myResidences in
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() self?.myResidences = myResidences
// Clear loading state when data arrives
if myResidences != nil {
self?.isLoading = false
}
}
.store(in: &cancellables)
DataManagerObservable.shared.$residences
.receive(on: DispatchQueue.main)
.sink { [weak self] residences in
self?.residences = residences
}
.store(in: &cancellables)
DataManagerObservable.shared.$totalSummary
.receive(on: DispatchQueue.main)
.sink { [weak self] summary in
self?.totalSummary = summary
}
.store(in: &cancellables)
} }
// MARK: - Public Methods // MARK: - Public Methods
func loadResidenceSummary() {
isLoading = true /// Load summary - kicks off API call that updates DataManager
func loadSummary(forceRefresh: Bool = false) {
errorMessage = nil errorMessage = nil
sharedViewModel.loadResidenceSummary() // Check if we have cached data and don't need to refresh
if !forceRefresh && totalSummary != nil {
return
}
StateFlowObserver.observeWithState( isLoading = true
sharedViewModel.residenceSummaryState,
loadingSetter: { [weak self] in self?.isLoading = $0 }, // Kick off API call - DataManager will be updated, which updates DataManagerObservable
errorSetter: { [weak self] in self?.errorMessage = $0 }, Task {
onSuccess: { [weak self] (data: ResidenceSummaryResponse) in do {
self?.residenceSummary = data let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
} }
) }
} }
/// Load my residences - checks cache first, then fetches if needed
func loadMyResidences(forceRefresh: Bool = false) { func loadMyResidences(forceRefresh: Bool = false) {
isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.loadMyResidences(forceRefresh: forceRefresh) // Check if we have cached data and don't need to refresh
if !forceRefresh && DataManagerObservable.shared.myResidences != nil {
// Data already available via observation, no API call needed
return
}
StateFlowObserver.observeWithState( isLoading = true
sharedViewModel.myResidencesState,
loadingSetter: { [weak self] in self?.isLoading = $0 }, // Kick off API call - DataManager will be updated, which updates DataManagerObservable,
errorSetter: { [weak self] in self?.errorMessage = $0 }, // which updates our @Published myResidences via the sink above
onSuccess: { [weak self] (data: MyResidencesResponse) in Task {
self?.myResidences = data do {
let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
} }
) }
} }
func getResidence(id: Int32) { func getResidence(id: Int32) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.getResidence(id: id) { result in Task {
Task { @MainActor in do {
let result = try await APILayer.shared.getResidence(id: id, forceRefresh: false)
if let success = result as? ApiResultSuccess<ResidenceResponse> { if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data self.selectedResidence = success.data
self.isLoading = false self.isLoading = false
@@ -72,6 +126,9 @@ class ResidenceViewModel: ObservableObject {
self.errorMessage = ErrorMessageParser.parse(error.message) self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false self.isLoading = false
} }
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
} }
} }
} }
@@ -80,56 +137,77 @@ class ResidenceViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.createResidence(request: request) Task {
do {
let result = try await APILayer.shared.createResidence(request: request)
StateFlowObserver.observeWithCompletion( if result is ApiResultSuccess<ResidenceResponse> {
sharedViewModel.createResidenceState, self.isLoading = false
loadingSetter: { [weak self] in self?.isLoading = $0 }, // DataManager is updated by APILayer (including refreshMyResidences),
errorSetter: { [weak self] in self?.errorMessage = $0 }, // which updates DataManagerObservable, which updates our @Published
completion: completion, // myResidences via Combine subscription
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } completion(true)
) } else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
} }
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) { func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.updateResidence(residenceId: id, request: request) Task {
do {
let result = try await APILayer.shared.updateResidence(id: id, request: request)
StateFlowObserver.observeWithCompletion( if let success = result as? ApiResultSuccess<ResidenceResponse> {
sharedViewModel.updateResidenceState, self.selectedResidence = success.data
loadingSetter: { [weak self] in self?.isLoading = $0 }, self.isLoading = false
errorSetter: { [weak self] in self?.errorMessage = $0 }, // DataManager is updated by APILayer (including refreshMyResidences),
onSuccess: { [weak self] (data: ResidenceResponse) in // which updates DataManagerObservable, which updates our @Published
self?.selectedResidence = data // myResidences via Combine subscription
}, completion(true)
completion: completion, } else if let error = result as? ApiResultError {
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } self.errorMessage = ErrorMessageParser.parse(error.message)
) self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
} }
func generateTasksReport(residenceId: Int32, email: String? = nil) { func generateTasksReport(residenceId: Int32, email: String? = nil) {
isGeneratingReport = true isGeneratingReport = true
reportMessage = nil reportMessage = nil
sharedViewModel.generateTasksReport(residenceId: residenceId, email: email) Task {
do {
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
StateFlowObserver.observe( if let success = result as? ApiResultSuccess<GenerateReportResponse> {
sharedViewModel.generateReportState, self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
onLoading: { [weak self] in self.isGeneratingReport = false
self?.isGeneratingReport = true } else if let error = result as? ApiResultError {
}, self.reportMessage = ErrorMessageParser.parse(error.message)
onSuccess: { [weak self] (response: GenerateReportResponse) in self.isGeneratingReport = false
self?.reportMessage = response.message ?? "Report generated, but no message returned." }
self?.isGeneratingReport = false } catch {
}, self.reportMessage = error.localizedDescription
onError: { [weak self] error in self.isGeneratingReport = false
self?.reportMessage = error }
self?.isGeneratingReport = false }
},
resetState: { [weak self] in self?.sharedViewModel.resetGenerateReportState() }
)
} }
func clearError() { func clearError() {
@@ -137,6 +215,34 @@ class ResidenceViewModel: ObservableObject {
} }
func loadResidenceContractors(residenceId: Int32) { func loadResidenceContractors(residenceId: Int32) {
sharedViewModel.loadResidenceContractors(residenceId: residenceId) // This can now be handled directly via APILayer if needed
// or through DataManagerObservable.shared.contractors
}
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.joinWithCode(code: code)
if result is ApiResultSuccess<JoinResidenceResponse> {
self.isLoading = false
// APILayer updates DataManager with refreshMyResidences,
// which updates DataManagerObservable, which updates our
// @Published myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
} }
} }

View File

@@ -21,7 +21,7 @@ struct ResidencesListView: View {
errorMessage: viewModel.errorMessage, errorMessage: viewModel.errorMessage,
content: { residences in content: { residences in
ResidencesContent( ResidencesContent(
response: response, summary: viewModel.totalSummary ?? response.summary,
residences: residences residences: residences
) )
}, },
@@ -120,14 +120,14 @@ struct ResidencesListView: View {
// MARK: - Residences Content View // MARK: - Residences Content View
private struct ResidencesContent: View { private struct ResidencesContent: View {
let response: MyResidencesResponse let summary: TotalSummary
let residences: [ResidenceResponse] let residences: [ResidenceResponse]
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.lg) { VStack(spacing: AppSpacing.lg) {
// Summary Card // Summary Card
SummaryCard(summary: response.summary) SummaryCard(summary: summary)
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm) .padding(.top, AppSpacing.sm)

View File

@@ -8,10 +8,8 @@ class AuthenticationManager: ObservableObject {
@Published var isAuthenticated: Bool = false @Published var isAuthenticated: Bool = false
@Published var isVerified: Bool = false @Published var isVerified: Bool = false
@Published var isCheckingAuth: Bool = true @Published var isCheckingAuth: Bool = true
private let sharedViewModel: ComposeApp.AuthViewModel
private init() { private init() {
self.sharedViewModel = ComposeApp.AuthViewModel()
checkAuthenticationStatus() checkAuthenticationStatus()
} }
@@ -85,8 +83,10 @@ class AuthenticationManager: ObservableObject {
} }
func logout() { func logout() {
// Call shared ViewModel logout which clears DataManager // Call APILayer logout which clears DataManager
sharedViewModel.logout() Task {
_ = try? await APILayer.shared.logout()
}
// Clear widget task data // Clear widget task data
WidgetDataManager.shared.clearCache() WidgetDataManager.shared.clearCache()

View File

@@ -19,7 +19,6 @@ class StoreKitManager: ObservableObject {
@Published var purchaseError: String? @Published var purchaseError: String?
private var transactionListener: Task<Void, Error>? private var transactionListener: Task<Void, Error>?
private let subscriptionApi = SubscriptionApi(client: ApiClient.shared.httpClient)
private init() { private init() {
// Start listening for transactions // Start listening for transactions
@@ -173,13 +172,8 @@ class StoreKitManager: ObservableObject {
/// Fetch latest subscription status from backend and update cache /// Fetch latest subscription status from backend and update cache
private func refreshSubscriptionFromBackend() async { private func refreshSubscriptionFromBackend() async {
guard let token = TokenStorage.shared.getToken() else {
print("⚠️ StoreKit: No auth token, skipping backend status refresh")
return
}
do { do {
let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token) let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>, if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
let subscription = statusSuccess.data { let subscription = statusSuccess.data {
@@ -242,18 +236,11 @@ class StoreKitManager: ObservableObject {
/// Verify transaction with backend API /// Verify transaction with backend API
private func verifyTransactionWithBackend(_ transaction: Transaction) async { private func verifyTransactionWithBackend(_ transaction: Transaction) async {
do { do {
// Get auth token
guard let token = TokenStorage.shared.getToken() else {
print("⚠️ StoreKit: No auth token, skipping backend verification")
return
}
// Get transaction receipt data // Get transaction receipt data
let receiptData = String(transaction.id) let receiptData = String(transaction.id)
// Call backend verification endpoint // Call backend verification endpoint via APILayer
let result = try await subscriptionApi.verifyIOSReceipt( let result = try await APILayer.shared.verifyIOSReceipt(
token: token,
receiptData: receiptData, receiptData: receiptData,
transactionId: String(transaction.id) transactionId: String(transaction.id)
) )
@@ -264,8 +251,8 @@ class StoreKitManager: ObservableObject {
response.success { response.success {
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")") print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
// Fetch updated subscription status from backend // Fetch updated subscription status from backend via APILayer
let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token) let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>, if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
let subscription = statusSuccess.data { let subscription = statusSuccess.data {

View File

@@ -2,17 +2,20 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for task management.
/// Observes DataManagerObservable for cached data.
/// Calls APILayer directly for all operations.
@MainActor @MainActor
class TaskViewModel: ObservableObject { class TaskViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties (from DataManager observation)
@Published var tasksResponse: TaskColumnsResponse?
// MARK: - Local State
@Published var actionState: ActionState<TaskActionType> = .idle @Published var actionState: ActionState<TaskActionType> = .idle
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var completions: [TaskCompletionResponse] = [] @Published var completions: [TaskCompletionResponse] = []
@Published var isLoadingCompletions: Bool = false @Published var isLoadingCompletions: Bool = false
@Published var completionsError: String? @Published var completionsError: String?
// MARK: - Kanban Board State (shared across views)
@Published var tasksResponse: TaskColumnsResponse?
@Published var isLoadingTasks: Bool = false @Published var isLoadingTasks: Bool = false
@Published var tasksError: String? @Published var tasksError: String?
@@ -31,11 +34,36 @@ class TaskViewModel: ObservableObject {
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) } var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
// MARK: - Private Properties // MARK: - Private Properties
private let sharedViewModel: ComposeApp.TaskViewModel private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization // MARK: - Initialization
init(sharedViewModel: ComposeApp.TaskViewModel? = nil) { init() {
self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel() // Observe DataManagerObservable for all tasks data
DataManagerObservable.shared.$allTasks
.receive(on: DispatchQueue.main)
.sink { [weak self] allTasks in
// Only update if we're showing all tasks (no residence filter)
if self?.currentResidenceId == nil {
self?.tasksResponse = allTasks
if allTasks != nil {
self?.isLoadingTasks = false
}
}
}
.store(in: &cancellables)
// Observe tasks by residence
DataManagerObservable.shared.$tasksByResidence
.receive(on: DispatchQueue.main)
.sink { [weak self] tasksByResidence in
// Only update if we're filtering by residence
if let resId = self?.currentResidenceId,
let tasks = tasksByResidence[resId] {
self?.tasksResponse = tasks
self?.isLoadingTasks = false
}
}
.store(in: &cancellables)
} }
// MARK: - Public Methods // MARK: - Public Methods
@@ -43,42 +71,48 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.create) actionState = .loading(.create)
errorMessage = nil errorMessage = nil
sharedViewModel.createNewTask(request: request) Task {
do {
let result = try await APILayer.shared.createTask(request: request)
StateFlowObserver.observeWithCompletion( if result is ApiResultSuccess<TaskResponse> {
sharedViewModel.taskAddNewCustomTaskState, self.actionState = .success(.create)
loadingSetter: { [weak self] loading in // DataManager is updated by APILayer, view updates via observation
if loading { self?.actionState = .loading(.create) } completion(true)
}, } else if let error = result as? ApiResultError {
errorSetter: { [weak self] error in self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
if let error = error { self.errorMessage = ErrorMessageParser.parse(error.message)
self?.actionState = .error(.create, error) completion(false)
self?.errorMessage = error
} }
}, } catch {
onSuccess: { [weak self] (_: TaskResponse) in self.actionState = .error(.create, error.localizedDescription)
self?.actionState = .success(.create) self.errorMessage = error.localizedDescription
}, completion(false)
completion: completion, }
resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() } }
)
} }
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) { func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
actionState = .loading(.cancel) actionState = .loading(.cancel)
errorMessage = nil errorMessage = nil
sharedViewModel.cancelTask(taskId: id) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.cancelTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.cancel) self.actionState = .success(.cancel)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to cancel task" self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
self.actionState = .error(.cancel, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.cancel, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -87,17 +121,23 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.uncancel) actionState = .loading(.uncancel)
errorMessage = nil errorMessage = nil
sharedViewModel.uncancelTask(taskId: id) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.uncancelTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.uncancel) self.actionState = .success(.uncancel)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to uncancel task" self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
self.actionState = .error(.uncancel, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.uncancel, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -106,17 +146,23 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.markInProgress) actionState = .loading(.markInProgress)
errorMessage = nil errorMessage = nil
sharedViewModel.markInProgress(taskId: id) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.markInProgress(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.markInProgress) self.actionState = .success(.markInProgress)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to mark task in progress" self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
self.actionState = .error(.markInProgress, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.markInProgress, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -125,17 +171,23 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.archive) actionState = .loading(.archive)
errorMessage = nil errorMessage = nil
sharedViewModel.archiveTask(taskId: id) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.archiveTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.archive) self.actionState = .success(.archive)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to archive task" self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
self.actionState = .error(.archive, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.archive, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -144,17 +196,23 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.unarchive) actionState = .loading(.unarchive)
errorMessage = nil errorMessage = nil
sharedViewModel.unarchiveTask(taskId: id) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.unarchiveTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.unarchive) self.actionState = .success(.unarchive)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to unarchive task" self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
self.actionState = .error(.unarchive, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.unarchive, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -163,17 +221,23 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.update) actionState = .loading(.update)
errorMessage = nil errorMessage = nil
sharedViewModel.updateTask(taskId: id, request: request) { success in Task {
Task { @MainActor in do {
if success.boolValue { let result = try await APILayer.shared.updateTask(id: id, request: request)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.update) self.actionState = .success(.update)
// DataManager is updated by APILayer, view updates via observation
completion(true) completion(true)
} else { } else if let error = result as? ApiResultError {
let errorMsg = "Failed to update task" self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
self.actionState = .error(.update, errorMsg) self.errorMessage = ErrorMessageParser.parse(error.message)
self.errorMessage = errorMsg
completion(false) completion(false)
} }
} catch {
self.actionState = .error(.update, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
} }
} }
} }
@@ -196,27 +260,20 @@ class TaskViewModel: ObservableObject {
isLoadingCompletions = true isLoadingCompletions = true
completionsError = nil completionsError = nil
sharedViewModel.loadTaskCompletions(taskId: taskId)
Task { Task {
for await state in sharedViewModel.taskCompletionsState { do {
if let success = state as? ApiResultSuccess<NSArray> { let result = try await APILayer.shared.getTaskCompletions(taskId: taskId)
await MainActor.run {
self.completions = (success.data as? [TaskCompletionResponse]) ?? [] if let success = result as? ApiResultSuccess<NSArray> {
self.isLoadingCompletions = false self.completions = (success.data as? [TaskCompletionResponse]) ?? []
} self.isLoadingCompletions = false
break } else if let error = result as? ApiResultError {
} else if let error = state as? ApiResultError { self.completionsError = ErrorMessageParser.parse(error.message)
await MainActor.run { self.isLoadingCompletions = false
self.completionsError = error.message
self.isLoadingCompletions = false
}
break
} else if state is ApiResultLoading {
await MainActor.run {
self.isLoadingCompletions = true
}
} }
} catch {
self.completionsError = error.localizedDescription
self.isLoadingCompletions = false
} }
} }
} }
@@ -225,7 +282,6 @@ class TaskViewModel: ObservableObject {
completions = [] completions = []
completionsError = nil completionsError = nil
isLoadingCompletions = false isLoadingCompletions = false
sharedViewModel.resetTaskCompletionsState()
} }
// MARK: - Kanban Board Methods // MARK: - Kanban Board Methods
@@ -248,6 +304,7 @@ class TaskViewModel: ObservableObject {
} }
/// Load tasks - either all tasks or filtered by residence /// Load tasks - either all tasks or filtered by residence
/// Checks cache first, then fetches if needed.
/// - Parameters: /// - Parameters:
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks. /// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
/// - forceRefresh: Whether to bypass cache /// - forceRefresh: Whether to bypass cache
@@ -255,9 +312,25 @@ class TaskViewModel: ObservableObject {
guard DataManager.shared.isAuthenticated() else { return } guard DataManager.shared.isAuthenticated() else { return }
currentResidenceId = residenceId currentResidenceId = residenceId
isLoadingTasks = true
tasksError = nil tasksError = nil
// Check if we have cached data and don't need to refresh
if !forceRefresh {
if let resId = residenceId {
if DataManagerObservable.shared.tasksByResidence[resId] != nil {
// Data already available via observation, no API call needed
return
}
} else if DataManagerObservable.shared.allTasks != nil {
// Data already available via observation, no API call needed
return
}
}
isLoadingTasks = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
// which updates our @Published tasksResponse via the sink above
Task { Task {
do { do {
let result: Any let result: Any
@@ -270,17 +343,17 @@ class TaskViewModel: ObservableObject {
result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh) result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh)
} }
// Handle all result states
await MainActor.run { await MainActor.run {
if let success = result as? ApiResultSuccess<TaskColumnsResponse>, if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data { let data = success.data {
self.tasksResponse = data
self.isLoadingTasks = false
self.tasksError = nil
// Update widget data if loading all tasks // Update widget data if loading all tasks
if residenceId == nil { if residenceId == nil {
WidgetDataManager.shared.saveTasks(from: data) WidgetDataManager.shared.saveTasks(from: data)
} }
// tasksResponse is updated via DataManagerObservable observation
// Ensure loading state is cleared on success
self.isLoadingTasks = false
} else if let error = result as? ApiResultError { } else if let error = result as? ApiResultError {
self.tasksError = error.message self.tasksError = error.message
self.isLoadingTasks = false self.isLoadingTasks = false

View File

@@ -2,6 +2,8 @@ import Foundation
import ComposeApp import ComposeApp
import Combine import Combine
/// ViewModel for email verification.
/// Calls APILayer directly for verification.
@MainActor @MainActor
class VerifyEmailViewModel: ObservableObject { class VerifyEmailViewModel: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@@ -11,15 +13,10 @@ class VerifyEmailViewModel: ObservableObject {
@Published var isVerified: Bool = false @Published var isVerified: Bool = false
// MARK: - Private Properties // MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol private let tokenStorage: TokenStorageProtocol
// MARK: - Initialization // MARK: - Initialization
init( init(tokenStorage: TokenStorageProtocol? = nil) {
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
} }
@@ -31,7 +28,7 @@ class VerifyEmailViewModel: ObservableObject {
return return
} }
guard tokenStorage.getToken() != nil else { guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated" errorMessage = "Not authenticated"
return return
} }
@@ -39,29 +36,31 @@ class VerifyEmailViewModel: ObservableObject {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
sharedViewModel.verifyEmail(code: code) Task {
do {
let request = VerifyEmailRequest(code: code)
let result = try await APILayer.shared.verifyEmail(token: token, request: request)
StateFlowObserver.observe( if let success = result as? ApiResultSuccess<VerifyEmailResponse>, let response = success.data {
sharedViewModel.verifyEmailState, print("VerifyEmailViewModel: onSuccess called, verified=\(response.verified)")
onLoading: { [weak self] in self?.isLoading = true }, if response.verified {
onSuccess: { [weak self] (response: VerifyEmailResponse) in print("VerifyEmailViewModel: Setting isVerified = true")
print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)") self.isVerified = true
if response.verified { self.isLoading = false
print("🏠 VerifyEmailViewModel: Setting isVerified = true") print("VerifyEmailViewModel: isVerified is now \(self.isVerified)")
self?.isVerified = true } else {
self?.isLoading = false self.errorMessage = "Verification failed"
print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)") self.isLoading = false
} else { }
self?.errorMessage = "Verification failed" } else if let error = result as? ApiResultError {
self?.isLoading = false self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
} }
}, } catch {
onError: { [weak self] error in self.errorMessage = error.localizedDescription
self?.errorMessage = error self.isLoading = false
self?.isLoading = false }
}, }
resetState: { [weak self] in self?.sharedViewModel.resetVerifyEmailState() }
)
} }
func clearError() { func clearError() {