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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user