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 {
// ==================== 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)
private var tokenManager: TokenManager? = null
private var themeManager: ThemeStorageManager? = null
@@ -58,6 +99,9 @@ object DataManager {
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
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())
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
@@ -78,9 +122,10 @@ object DataManager {
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
// ==================== CONTRACTORS ====================
// Stores ContractorSummary for list views (lighter weight than full Contractor)
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
// ==================== SUBSCRIPTION ====================
@@ -215,16 +260,31 @@ object DataManager {
fun setResidences(residences: List<Residence>) {
_residences.value = residences
residencesCacheTime = currentTimeMs()
updateLastSyncTime()
persistToDisk()
}
fun setMyResidences(response: MyResidencesResponse) {
_myResidences.value = response
// Also update totalSummary from myResidences response
_totalSummary.value = response.summary
myResidencesCacheTime = currentTimeMs()
summaryCacheTime = currentTimeMs()
updateLastSyncTime()
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) {
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
persistToDisk()
@@ -255,12 +315,14 @@ object DataManager {
fun setAllTasks(response: TaskColumnsResponse) {
_allTasks.value = response
tasksCacheTime = currentTimeMs()
updateLastSyncTime()
persistToDisk()
}
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
persistToDisk()
}
@@ -332,6 +394,7 @@ object DataManager {
fun setDocuments(documents: List<Document>) {
_documents.value = documents
documentsCacheTime = currentTimeMs()
updateLastSyncTime()
persistToDisk()
}
@@ -364,24 +427,40 @@ object DataManager {
// ==================== CONTRACTOR UPDATE METHODS ====================
fun setContractors(contractors: List<Contractor>) {
fun setContractors(contractors: List<ContractorSummary>) {
_contractors.value = contractors
contractorsCacheTime = currentTimeMs()
updateLastSyncTime()
persistToDisk()
}
fun addContractor(contractor: Contractor) {
fun addContractor(contractor: ContractorSummary) {
_contractors.value = _contractors.value + contractor
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 {
if (it.id == contractor.id) contractor else it
}
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) {
_contractors.value = _contractors.value.filter { it.id != contractorId }
persistToDisk()
@@ -475,6 +554,7 @@ object DataManager {
// Clear user data
_residences.value = emptyList()
_myResidences.value = null
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
@@ -503,6 +583,15 @@ object DataManager {
_contractorSpecialtiesMap.value = emptyMap()
_lookupsInitialized.value = false
// Clear cache timestamps
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
// Clear metadata
_lastSyncTime.value = 0L
@@ -517,6 +606,7 @@ object DataManager {
_currentUser.value = null
_residences.value = emptyList()
_myResidences.value = null
_totalSummary.value = null
_residenceSummaries.value = emptyMap()
_allTasks.value = null
_tasksByResidence.value = emptyMap()
@@ -527,6 +617,16 @@ object DataManager {
_upgradeTriggers.value = emptyMap()
_featureBenefits.value = emptyList()
_promotions.value = emptyList()
// Clear cache timestamps
residencesCacheTime = 0L
myResidencesCacheTime = 0L
tasksCacheTime = 0L
tasksByResidenceCacheTime.clear()
contractorsCacheTime = 0L
documentsCacheTime = 0L
summaryCacheTime = 0L
persistToDisk()
}
@@ -539,169 +639,42 @@ object DataManager {
/**
* 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() {
val manager = persistenceManager ?: return
try {
// Persist each data type
// Only persist user data - everything else is fetched fresh from API
_currentUser.value?.let {
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) {
// Log error but don't crash - persistence is best-effort
println("DataManager: Error persisting to disk: ${e.message}")
}
}
/**
* 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() {
val manager = persistenceManager ?: return
try {
// Only load user data - everything else is fetched fresh from API
manager.load(KEY_CURRENT_USER)?.let { 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) {
// Log error but don't crash - cache miss is OK
println("DataManager: Error loading from disk: ${e.message}")
}
}
// ==================== 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_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
)
// Note: API returns full Contractor objects for list endpoints
// ContractorSummary kept for backward compatibility
// Extension to convert full Contractor to ContractorSummary
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 ====================
suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> {
// Check DataManager first
if (!forceRefresh) {
val cached = DataManager.residences.value
if (cached.isNotEmpty()) {
return ApiResult.Success(cached)
}
// Check DataManager first - return cached if valid and not forcing refresh
// Cache is valid even if empty (user has no residences)
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
return ApiResult.Success(DataManager.residences.value)
}
// Fetch from API
@@ -269,8 +267,8 @@ object APILayer {
}
suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> {
// Check DataManager first
if (!forceRefresh) {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) {
val cached = DataManager.myResidences.value
if (cached != null) {
return ApiResult.Success(cached)
@@ -290,8 +288,8 @@ object APILayer {
}
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
// Check DataManager first
if (!forceRefresh) {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
val cached = DataManager.residences.value.find { it.id == id }
if (cached != null) {
return ApiResult.Success(cached)
@@ -310,9 +308,27 @@ object APILayer {
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)
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> {
@@ -397,8 +413,8 @@ object APILayer {
// ==================== Task Operations ====================
suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// Check DataManager first
if (!forceRefresh) {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
val cached = DataManager.allTasks.value
if (cached != null) {
return ApiResult.Success(cached)
@@ -418,8 +434,8 @@ object APILayer {
}
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
// Check DataManager first
if (!forceRefresh) {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
val cached = DataManager.tasksByResidence.value[residenceId]
if (cached != null) {
return ApiResult.Success(cached)
@@ -548,6 +564,8 @@ object APILayer {
result.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask)
}
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
refreshSummary()
}
return result
@@ -566,6 +584,8 @@ object APILayer {
result.data.updatedTask?.let { updatedTask ->
DataManager.updateTask(updatedTask)
}
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
refreshSummary()
}
return result
@@ -596,12 +616,10 @@ object APILayer {
contractorId != null || isActive != null || expiringSoon != null ||
tags != null || search != null
// Check DataManager first if no filters
if (!forceRefresh && !hasFilters) {
val cached = DataManager.documents.value
if (cached.isNotEmpty()) {
return ApiResult.Success(cached)
}
// Check DataManager first if no filters - return cached if valid and not forcing refresh
// Cache is valid even if empty (user has no documents)
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
return ApiResult.Success(DataManager.documents.value)
}
// Fetch from API
@@ -620,8 +638,8 @@ object APILayer {
}
suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> {
// Check DataManager first
if (!forceRefresh) {
// Check DataManager first - return cached if valid and not forcing refresh
if (!forceRefresh && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
val cached = DataManager.documents.value.find { it.id == id }
if (cached != null) {
return ApiResult.Success(cached)
@@ -764,25 +782,32 @@ object APILayer {
search: String? = null,
forceRefresh: Boolean = false
): ApiResult<List<ContractorSummary>> {
// Fetch from API (API returns summaries, not full contractors)
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
return contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
}
val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
// Check DataManager first
if (!forceRefresh) {
val cached = DataManager.contractors.value.find { it.id == id }
if (cached != null) {
return ApiResult.Success(cached)
}
// Check cache first (only if no filters applied) - return cached if valid and not forcing refresh
// Cache is valid even if empty (user has no contractors)
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
return ApiResult.Success(DataManager.contractors.value)
}
// Fetch from API
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)
// Update DataManager on success
// Update the summary in DataManager on success
if (result is ApiResult.Success) {
DataManager.updateContractor(result.data)
}
@@ -1030,6 +1055,13 @@ object APILayer {
getMyResidences(forceRefresh = true)
}
/**
* Refresh just the summary counts (lightweight)
*/
private suspend fun refreshSummary() {
getSummary(forceRefresh = true)
}
/**
* Prefetch all data after login
*/

View File

@@ -9,7 +9,7 @@ package com.example.casera.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.DEV
val CURRENT_ENV = Environment.LOCAL
enum class Environment {
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 {
val response = client.get("$baseUrl/residences/summary/") {
header("Authorization", "Token $token")
@@ -102,7 +102,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch residence summary", response.status.value)
ApiResult.Error("Failed to fetch summary", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")

View File

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

View File

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

View File

@@ -2,31 +2,25 @@ package com.example.casera.viewmodel
import androidx.lifecycle.ViewModel
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.TaskCompletionResponse
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class TaskCompletionViewModel : ViewModel() {
private val taskCompletionApi = TaskCompletionApi()
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletion>>(ApiResult.Idle)
val createCompletionState: StateFlow<ApiResult<TaskCompletion>> = _createCompletionState
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletionResponse>>(ApiResult.Idle)
val createCompletionState: StateFlow<ApiResult<TaskCompletionResponse>> = _createCompletionState
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
val token = DataManager.authToken.value
if (token != null) {
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
} else {
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
}
// Use APILayer which handles DataManager updates and summary refresh
_createCompletionState.value = APILayer.createTaskCompletion(request)
}
}
@@ -42,31 +36,27 @@ class TaskCompletionViewModel : ViewModel() {
) {
viewModelScope.launch {
_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(
token = token,
request = request,
images = compressedImages,
imageFileNames = imageFileNames
)
} else {
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
// 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"
}
}
// 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 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
class ContractorViewModel: ObservableObject {
// MARK: - Published Properties
@@ -15,145 +18,168 @@ class ContractorViewModel: ObservableObject {
@Published var successMessage: String?
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.ContractorViewModel
private var cancellables = Set<AnyCancellable>()
// 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
func loadContractors(
specialty: String? = nil,
isFavorite: Bool? = nil,
isActive: Bool? = nil,
search: String? = nil,
forceRefresh: Bool = false
) {
/// Load contractors list - delegates to APILayer which handles cache timeout
func loadContractors(forceRefresh: Bool = false) {
isLoading = true
errorMessage = nil
sharedViewModel.loadContractors(
specialty: specialty,
isFavorite: isFavorite.asKotlin,
isActive: isActive.asKotlin,
search: search,
forceRefresh: forceRefresh
)
Task {
do {
let result = try await APILayer.shared.getContractors(
specialty: nil,
isFavorite: nil,
isActive: nil,
search: nil,
forceRefresh: forceRefresh
)
StateFlowObserver.observe(
sharedViewModel.contractorsState,
onLoading: { [weak self] in self?.isLoading = true },
onSuccess: { [weak self] (data: NSArray) in
self?.contractors = data as? [ContractorSummary] ?? []
self?.isLoading = false
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isLoading = false
// API updates DataManager on success, which triggers our observation
if result is ApiResultSuccess<NSArray> {
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
}
)
}
}
func loadContractorDetail(id: Int32) {
isLoading = true
errorMessage = nil
sharedViewModel.loadContractorDetail(id: id)
Task {
do {
let result = try await APILayer.shared.getContractor(id: id, forceRefresh: false)
StateFlowObserver.observeWithState(
sharedViewModel.contractorDetailState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: Contractor) in
self?.selectedContractor = data
if let success = result as? ApiResultSuccess<Contractor> {
self.selectedContractor = success.data
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
}
)
}
}
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
isCreating = true
errorMessage = nil
sharedViewModel.createContractor(request: request)
Task {
do {
let result = try await APILayer.shared.createContractor(request: request)
StateFlowObserver.observe(
sharedViewModel.createState,
onLoading: { [weak self] in self?.isCreating = true },
onSuccess: { [weak self] (_: Contractor) in
self?.successMessage = "Contractor added successfully"
self?.isCreating = false
completion(true)
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isCreating = false
if result is ApiResultSuccess<Contractor> {
self.successMessage = "Contractor added successfully"
self.isCreating = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isCreating = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isCreating = false
completion(false)
},
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
)
}
}
}
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
isUpdating = true
errorMessage = nil
sharedViewModel.updateContractor(id: id, request: request)
Task {
do {
let result = try await APILayer.shared.updateContractor(id: id, request: request)
StateFlowObserver.observe(
sharedViewModel.updateState,
onLoading: { [weak self] in self?.isUpdating = true },
onSuccess: { [weak self] (_: Contractor) in
self?.successMessage = "Contractor updated successfully"
self?.isUpdating = false
completion(true)
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isUpdating = false
if result is ApiResultSuccess<Contractor> {
self.successMessage = "Contractor updated successfully"
self.isUpdating = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isUpdating = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isUpdating = false
completion(false)
},
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
)
}
}
}
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
isDeleting = true
errorMessage = nil
sharedViewModel.deleteContractor(id: id)
Task {
do {
let result = try await APILayer.shared.deleteContractor(id: id)
StateFlowObserver.observe(
sharedViewModel.deleteState,
onLoading: { [weak self] in self?.isDeleting = true },
onSuccess: { [weak self] (_: KotlinUnit) in
self?.successMessage = "Contractor deleted successfully"
self?.isDeleting = false
completion(true)
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isDeleting = false
if result is ApiResultSuccess<KotlinUnit> {
self.successMessage = "Contractor deleted successfully"
self.isDeleting = false
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isDeleting = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isDeleting = false
completion(false)
},
resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() }
)
}
}
}
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(
sharedViewModel.toggleFavoriteState,
onSuccess: { (_: Contractor) in
completion(true)
},
onError: { [weak self] error in
self?.errorMessage = error
if result is ApiResultSuccess<Contractor> {
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
completion(false)
},
resetState: { [weak self] in self?.sharedViewModel.resetToggleFavoriteState() }
)
}
}
}
func clearMessages() {
@@ -161,4 +187,3 @@ class ContractorViewModel: ObservableObject {
successMessage = nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,32 @@ struct DocumentsWarrantiesView: View {
let residenceId: Int32?
// Client-side filtering for warranties tab
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] {
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)
@@ -104,23 +124,21 @@ struct DocumentsWarrantiesView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) {
// Active Filter (for warranties)
// Active Filter (for warranties) - client-side, no API call
if selectedTab == .warranties {
Button(action: {
showActiveOnly.toggle()
loadWarranties()
}) {
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
}
}
// Filter Menu
// Filter Menu - client-side filtering, no API calls
Menu {
if selectedTab == .warranties {
Button(action: {
selectedCategory = nil
loadWarranties()
}) {
Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "")
}
@@ -130,7 +148,6 @@ struct DocumentsWarrantiesView: View {
ForEach(DocumentCategory.allCases, id: \.self) { category in
Button(action: {
selectedCategory = category.displayName
loadWarranties()
}) {
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
}
@@ -138,7 +155,6 @@ struct DocumentsWarrantiesView: View {
} else {
Button(action: {
selectedDocType = nil
loadDocuments()
}) {
Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "")
}
@@ -148,7 +164,6 @@ struct DocumentsWarrantiesView: View {
ForEach(DocumentType.allCases, id: \.self) { type in
Button(action: {
selectedDocType = type.displayName
loadDocuments()
}) {
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
}
@@ -177,16 +192,10 @@ struct DocumentsWarrantiesView: View {
}
}
.onAppear {
loadWarranties()
loadDocuments()
}
.onChange(of: selectedTab) { _ in
if selectedTab == .warranties {
loadWarranties()
} else {
loadDocuments()
}
// Load all documents once - filtering is client-side
loadAllDocuments()
}
// No need for onChange on selectedTab - filtering is client-side
.sheet(isPresented: $showAddSheet) {
AddDocumentView(
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() {
documentViewModel.loadDocuments(
residenceId: residenceId,
documentType: "warranty",
category: selectedCategory,
isActive: showActiveOnly ? true : nil
)
// Just reload all - filtering happens client-side
loadAllDocuments()
}
private func loadDocuments() {
documentViewModel.loadDocuments(
residenceId: residenceId,
documentType: selectedDocType
)
// Just reload all - filtering happens client-side
loadAllDocuments()
}
}

View File

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

View File

@@ -2,32 +2,46 @@ import Foundation
import ComposeApp
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
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 password: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@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
var onLoginSuccess: ((Bool) -> Void)?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
init() {
// Observe DataManagerObservable for authentication state
DataManagerObservable.shared.$currentUser
.receive(on: DispatchQueue.main)
.sink { [weak self] user in
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
func login() {
guard !username.isEmpty else {
@@ -43,175 +57,94 @@ class LoginViewModel: ObservableObject {
isLoading = true
errorMessage = nil
sharedViewModel.login(username: username, password: password)
Task {
for await state in sharedViewModel.loginState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} 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)
do {
let result = try await APILayer.shared.login(
request: LoginRequest(username: username, password: password)
)
// Store user data and verification status
self.currentUser = user
self.isVerified = user.verified
self.isLoading = false
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! Token: token")
print("User: \(user.username), Verified: \(user.verified)")
print("isVerified set to: \(self.isVerified)")
print("Login successful!")
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
// Initialize lookups via APILayer
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
if let code = error.code?.intValue {
switch code {
case 400, 401:
self.errorMessage = "Invalid username or password"
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
}
}
}
}
// Helper function to clean up error messages
private func cleanErrorMessage(_ message: String) -> String {
// Remove common API error prefixes and technical details
var cleaned = message
// Remove JSON-like error structures
if let range = cleaned.range(of: #"[{\[]"#, options: .regularExpression) {
cleaned = String(cleaned[..<range.lowerBound])
}
// 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() {
// Call shared ViewModel logout
sharedViewModel.logout()
// Clear token from storage
tokenStorage.clearToken()
// Clear lookups data on logout via DataCache
DataCache.shared.clearLookups()
// Clear all cached data
DataCache.shared.clearAll()
// Reset state
isVerified = false
currentUser = nil
username = ""
password = ""
errorMessage = nil
print("Logged out - all state reset")
}
func clearError() {
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 {
// Initialize lookups via APILayer
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() }
)
// Prefetch all data for caching
Task {
do {
print("Starting data prefetch...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try await prefetchManager.prefetchAllData()
print("Data prefetch completed successfully")
} catch {
print("Data prefetch failed: \(error.localizedDescription)")
// Don't block login on prefetch failure
}
}
// Call login success callback
self.onLoginSuccess?(self.isVerified)
} else if let error = result as? ApiResultError {
self.isLoading = false
self.handleLoginError(error)
}
} catch {
self.isLoading = false
self.errorMessage = error.localizedDescription
}
}
}
private func handleLoginError(_ error: ApiResultError) {
// Check for specific error codes and provide user-friendly messages
if let code = error.code?.intValue {
switch code {
case 400, 401:
self.errorMessage = "Invalid username or password"
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)")
}
func logout() {
Task {
// APILayer.logout clears DataManager
try? await APILayer.shared.logout()
// Clear widget task data
WidgetDataManager.shared.clearCache()
// Reset local state
self.isVerified = false
self.currentUser = nil
self.username = ""
self.password = ""
self.errorMessage = nil
print("Logged out - all state reset")
}
}
func clearError() {
errorMessage = nil
}
}

View File

@@ -141,27 +141,12 @@ struct OnboardingJoinResidenceContent: View {
isLoading = true
errorMessage = nil
Task {
// Call the shared ViewModel which uses APILayer
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
// Observe the result
for await state in viewModel.sharedViewModel.joinResidenceState {
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
}
viewModel.joinWithCode(code: shareCode) { success in
isLoading = false
if success {
onJoined()
} else {
errorMessage = viewModel.errorMessage
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import Foundation
import ComposeApp
import Combine
/// ViewModel for user registration.
/// Calls APILayer directly for registration.
@MainActor
class RegisterViewModel: ObservableObject {
// MARK: - Published Properties
@@ -14,15 +16,10 @@ class RegisterViewModel: ObservableObject {
@Published var isRegistered: Bool = false
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
init(tokenStorage: TokenStorageProtocol? = nil) {
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
}
@@ -52,33 +49,32 @@ class RegisterViewModel: ObservableObject {
isLoading = true
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(
sharedViewModel.registerState,
onLoading: { [weak self] in self?.isLoading = true },
onSuccess: { [weak self] (response: AuthResponse) in
guard let self = self else { return }
let token = response.token
self.tokenStorage.saveToken(token: token)
if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
let token = response.token
self.tokenStorage.saveToken(token: token)
// Update AuthenticationManager - user is authenticated but NOT verified
AuthenticationManager.shared.login(verified: false)
// Update AuthenticationManager - user is authenticated but NOT verified
AuthenticationManager.shared.login(verified: false)
// Initialize lookups via APILayer after successful registration
Task {
// Initialize lookups via APILayer after successful registration
_ = 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
},
onError: { [weak self] error in
self?.errorMessage = error
self?.isLoading = false
},
resetState: { [weak self] in self?.sharedViewModel.resetRegisterState() }
)
}
}
}
func clearError() {

View File

@@ -80,27 +80,12 @@ struct JoinResidenceView: View {
return
}
Task {
// Call the shared ViewModel which uses APILayer
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
// 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
}
viewModel.joinWithCode(code: shareCode) { success in
if success {
onJoined()
dismiss()
}
// Error is handled by ViewModel and displayed via viewModel.errorMessage
}
}
}

View File

@@ -2,11 +2,17 @@ import Foundation
import ComposeApp
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
class ResidenceViewModel: ObservableObject {
// MARK: - Published Properties
@Published var residenceSummary: ResidenceSummaryResponse?
// MARK: - Published Properties (from DataManager observation)
@Published var myResidences: MyResidencesResponse?
@Published var residences: [ResidenceResponse] = []
@Published var totalSummary: TotalSummary?
// MARK: - Local State
@Published var selectedResidence: ResidenceResponse?
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@@ -14,57 +20,105 @@ class ResidenceViewModel: ObservableObject {
@Published var reportMessage: String?
// MARK: - Private Properties
public let sharedViewModel: ComposeApp.ResidenceViewModel
private let tokenStorage: TokenStorageProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.ResidenceViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
init() {
// Observe DataManagerObservable for residence data
DataManagerObservable.shared.$myResidences
.receive(on: DispatchQueue.main)
.sink { [weak self] myResidences in
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
func loadResidenceSummary() {
isLoading = true
/// Load summary - kicks off API call that updates DataManager
func loadSummary(forceRefresh: Bool = false) {
errorMessage = nil
sharedViewModel.loadResidenceSummary()
// Check if we have cached data and don't need to refresh
if !forceRefresh && totalSummary != nil {
return
}
StateFlowObserver.observeWithState(
sharedViewModel.residenceSummaryState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: ResidenceSummaryResponse) in
self?.residenceSummary = data
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable
Task {
do {
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) {
isLoading = true
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(
sharedViewModel.myResidencesState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: MyResidencesResponse) in
self?.myResidences = data
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
// which updates our @Published myResidences via the sink above
Task {
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) {
isLoading = true
errorMessage = nil
sharedViewModel.getResidence(id: id) { result in
Task { @MainActor in
Task {
do {
let result = try await APILayer.shared.getResidence(id: id, forceRefresh: false)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
@@ -72,6 +126,9 @@ class ResidenceViewModel: ObservableObject {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
@@ -80,56 +137,77 @@ class ResidenceViewModel: ObservableObject {
isLoading = true
errorMessage = nil
sharedViewModel.createResidence(request: request)
Task {
do {
let result = try await APILayer.shared.createResidence(request: request)
StateFlowObserver.observeWithCompletion(
sharedViewModel.createResidenceState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
completion: completion,
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
)
if result is ApiResultSuccess<ResidenceResponse> {
self.isLoading = false
// DataManager is updated by APILayer (including refreshMyResidences),
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
sharedViewModel.updateResidence(residenceId: id, request: request)
Task {
do {
let result = try await APILayer.shared.updateResidence(id: id, request: request)
StateFlowObserver.observeWithCompletion(
sharedViewModel.updateResidenceState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: ResidenceResponse) in
self?.selectedResidence = data
},
completion: completion,
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
// DataManager is updated by APILayer (including refreshMyResidences),
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func generateTasksReport(residenceId: Int32, email: String? = nil) {
isGeneratingReport = true
reportMessage = nil
sharedViewModel.generateTasksReport(residenceId: residenceId, email: email)
Task {
do {
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
StateFlowObserver.observe(
sharedViewModel.generateReportState,
onLoading: { [weak self] in
self?.isGeneratingReport = true
},
onSuccess: { [weak self] (response: GenerateReportResponse) in
self?.reportMessage = response.message ?? "Report generated, but no message returned."
self?.isGeneratingReport = false
},
onError: { [weak self] error in
self?.reportMessage = error
self?.isGeneratingReport = false
},
resetState: { [weak self] in self?.sharedViewModel.resetGenerateReportState() }
)
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
self.isGeneratingReport = false
} else if let error = result as? ApiResultError {
self.reportMessage = ErrorMessageParser.parse(error.message)
self.isGeneratingReport = false
}
} catch {
self.reportMessage = error.localizedDescription
self.isGeneratingReport = false
}
}
}
func clearError() {
@@ -137,6 +215,34 @@ class ResidenceViewModel: ObservableObject {
}
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,
content: { residences in
ResidencesContent(
response: response,
summary: viewModel.totalSummary ?? response.summary,
residences: residences
)
},
@@ -120,14 +120,14 @@ struct ResidencesListView: View {
// MARK: - Residences Content View
private struct ResidencesContent: View {
let response: MyResidencesResponse
let summary: TotalSummary
let residences: [ResidenceResponse]
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.lg) {
// Summary Card
SummaryCard(summary: response.summary)
SummaryCard(summary: summary)
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)

View File

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

View File

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

View File

@@ -2,17 +2,20 @@ import Foundation
import ComposeApp
import Combine
/// ViewModel for task management.
/// Observes DataManagerObservable for cached data.
/// Calls APILayer directly for all operations.
@MainActor
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 errorMessage: String?
@Published var completions: [TaskCompletionResponse] = []
@Published var isLoadingCompletions: Bool = false
@Published var completionsError: String?
// MARK: - Kanban Board State (shared across views)
@Published var tasksResponse: TaskColumnsResponse?
@Published var isLoadingTasks: Bool = false
@Published var tasksError: String?
@@ -31,11 +34,36 @@ class TaskViewModel: ObservableObject {
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.TaskViewModel
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(sharedViewModel: ComposeApp.TaskViewModel? = nil) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel()
init() {
// 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
@@ -43,42 +71,48 @@ class TaskViewModel: ObservableObject {
actionState = .loading(.create)
errorMessage = nil
sharedViewModel.createNewTask(request: request)
Task {
do {
let result = try await APILayer.shared.createTask(request: request)
StateFlowObserver.observeWithCompletion(
sharedViewModel.taskAddNewCustomTaskState,
loadingSetter: { [weak self] loading in
if loading { self?.actionState = .loading(.create) }
},
errorSetter: { [weak self] error in
if let error = error {
self?.actionState = .error(.create, error)
self?.errorMessage = error
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.create)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else if let error = result as? ApiResultError {
self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
}
},
onSuccess: { [weak self] (_: TaskResponse) in
self?.actionState = .success(.create)
},
completion: completion,
resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() }
)
} catch {
self.actionState = .error(.create, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
}
}
}
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
actionState = .loading(.cancel)
errorMessage = nil
sharedViewModel.cancelTask(taskId: id) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.cancelTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.cancel)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to cancel task"
self.actionState = .error(.cancel, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
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)
errorMessage = nil
sharedViewModel.uncancelTask(taskId: id) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.uncancelTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.uncancel)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to uncancel task"
self.actionState = .error(.uncancel, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
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)
errorMessage = nil
sharedViewModel.markInProgress(taskId: id) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.markInProgress(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.markInProgress)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to mark task in progress"
self.actionState = .error(.markInProgress, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
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)
errorMessage = nil
sharedViewModel.archiveTask(taskId: id) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.archiveTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.archive)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to archive task"
self.actionState = .error(.archive, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
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)
errorMessage = nil
sharedViewModel.unarchiveTask(taskId: id) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.unarchiveTask(taskId: id)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.unarchive)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to unarchive task"
self.actionState = .error(.unarchive, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
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)
errorMessage = nil
sharedViewModel.updateTask(taskId: id, request: request) { success in
Task { @MainActor in
if success.boolValue {
Task {
do {
let result = try await APILayer.shared.updateTask(id: id, request: request)
if result is ApiResultSuccess<TaskResponse> {
self.actionState = .success(.update)
// DataManager is updated by APILayer, view updates via observation
completion(true)
} else {
let errorMsg = "Failed to update task"
self.actionState = .error(.update, errorMsg)
self.errorMessage = errorMsg
} else if let error = result as? ApiResultError {
self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
self.errorMessage = ErrorMessageParser.parse(error.message)
completion(false)
}
} catch {
self.actionState = .error(.update, error.localizedDescription)
self.errorMessage = error.localizedDescription
completion(false)
}
}
}
@@ -196,27 +260,20 @@ class TaskViewModel: ObservableObject {
isLoadingCompletions = true
completionsError = nil
sharedViewModel.loadTaskCompletions(taskId: taskId)
Task {
for await state in sharedViewModel.taskCompletionsState {
if let success = state as? ApiResultSuccess<NSArray> {
await MainActor.run {
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
self.isLoadingCompletions = false
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.completionsError = error.message
self.isLoadingCompletions = false
}
break
} else if state is ApiResultLoading {
await MainActor.run {
self.isLoadingCompletions = true
}
do {
let result = try await APILayer.shared.getTaskCompletions(taskId: taskId)
if let success = result as? ApiResultSuccess<NSArray> {
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
self.isLoadingCompletions = false
} else if let error = result as? ApiResultError {
self.completionsError = ErrorMessageParser.parse(error.message)
self.isLoadingCompletions = false
}
} catch {
self.completionsError = error.localizedDescription
self.isLoadingCompletions = false
}
}
}
@@ -225,7 +282,6 @@ class TaskViewModel: ObservableObject {
completions = []
completionsError = nil
isLoadingCompletions = false
sharedViewModel.resetTaskCompletionsState()
}
// MARK: - Kanban Board Methods
@@ -248,6 +304,7 @@ class TaskViewModel: ObservableObject {
}
/// Load tasks - either all tasks or filtered by residence
/// Checks cache first, then fetches if needed.
/// - Parameters:
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
/// - forceRefresh: Whether to bypass cache
@@ -255,9 +312,25 @@ class TaskViewModel: ObservableObject {
guard DataManager.shared.isAuthenticated() else { return }
currentResidenceId = residenceId
isLoadingTasks = true
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 {
do {
let result: Any
@@ -270,17 +343,17 @@ class TaskViewModel: ObservableObject {
result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh)
}
// Handle all result states
await MainActor.run {
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
let data = success.data {
self.tasksResponse = data
self.isLoadingTasks = false
self.tasksError = nil
// Update widget data if loading all tasks
if residenceId == nil {
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 {
self.tasksError = error.message
self.isLoadingTasks = false

View File

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