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 {
|
object DataManager {
|
||||||
|
|
||||||
|
// ==================== CACHE CONFIGURATION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache timeout in milliseconds.
|
||||||
|
* Data older than this will be refreshed from the API.
|
||||||
|
* Default: 1 hour (3600000ms)
|
||||||
|
*/
|
||||||
|
const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour
|
||||||
|
|
||||||
|
// Cache timestamps for each data type (epoch milliseconds)
|
||||||
|
var residencesCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
var myResidencesCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
var tasksCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
|
||||||
|
private set
|
||||||
|
var contractorsCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
var documentsCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
var summaryCacheTime: Long = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cache for a given timestamp is still valid (not expired)
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun isCacheValid(cacheTime: Long): Boolean {
|
||||||
|
if (cacheTime == 0L) return false
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
return (now - cacheTime) < CACHE_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current timestamp in milliseconds
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun currentTimeMs(): Long = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
// Platform-specific persistence managers (initialized at app start)
|
// Platform-specific persistence managers (initialized at app start)
|
||||||
private var tokenManager: TokenManager? = null
|
private var tokenManager: TokenManager? = null
|
||||||
private var themeManager: ThemeStorageManager? = null
|
private var themeManager: ThemeStorageManager? = null
|
||||||
@@ -58,6 +99,9 @@ object DataManager {
|
|||||||
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
||||||
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
||||||
|
|
||||||
|
private val _totalSummary = MutableStateFlow<TotalSummary?>(null)
|
||||||
|
val totalSummary: StateFlow<TotalSummary?> = _totalSummary.asStateFlow()
|
||||||
|
|
||||||
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
||||||
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
||||||
|
|
||||||
@@ -78,9 +122,10 @@ object DataManager {
|
|||||||
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
||||||
|
|
||||||
// ==================== CONTRACTORS ====================
|
// ==================== CONTRACTORS ====================
|
||||||
|
// Stores ContractorSummary for list views (lighter weight than full Contractor)
|
||||||
|
|
||||||
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
|
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
|
||||||
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
|
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
|
||||||
|
|
||||||
// ==================== SUBSCRIPTION ====================
|
// ==================== SUBSCRIPTION ====================
|
||||||
|
|
||||||
@@ -215,16 +260,31 @@ object DataManager {
|
|||||||
|
|
||||||
fun setResidences(residences: List<Residence>) {
|
fun setResidences(residences: List<Residence>) {
|
||||||
_residences.value = residences
|
_residences.value = residences
|
||||||
|
residencesCacheTime = currentTimeMs()
|
||||||
updateLastSyncTime()
|
updateLastSyncTime()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMyResidences(response: MyResidencesResponse) {
|
fun setMyResidences(response: MyResidencesResponse) {
|
||||||
_myResidences.value = response
|
_myResidences.value = response
|
||||||
|
// Also update totalSummary from myResidences response
|
||||||
|
_totalSummary.value = response.summary
|
||||||
|
myResidencesCacheTime = currentTimeMs()
|
||||||
|
summaryCacheTime = currentTimeMs()
|
||||||
updateLastSyncTime()
|
updateLastSyncTime()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTotalSummary(summary: TotalSummary) {
|
||||||
|
_totalSummary.value = summary
|
||||||
|
// Also update the summary in myResidences if it exists
|
||||||
|
_myResidences.value?.let { current ->
|
||||||
|
_myResidences.value = current.copy(summary = summary)
|
||||||
|
}
|
||||||
|
summaryCacheTime = currentTimeMs()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
||||||
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
@@ -255,12 +315,14 @@ object DataManager {
|
|||||||
|
|
||||||
fun setAllTasks(response: TaskColumnsResponse) {
|
fun setAllTasks(response: TaskColumnsResponse) {
|
||||||
_allTasks.value = response
|
_allTasks.value = response
|
||||||
|
tasksCacheTime = currentTimeMs()
|
||||||
updateLastSyncTime()
|
updateLastSyncTime()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
||||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
||||||
|
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +394,7 @@ object DataManager {
|
|||||||
|
|
||||||
fun setDocuments(documents: List<Document>) {
|
fun setDocuments(documents: List<Document>) {
|
||||||
_documents.value = documents
|
_documents.value = documents
|
||||||
|
documentsCacheTime = currentTimeMs()
|
||||||
updateLastSyncTime()
|
updateLastSyncTime()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
@@ -364,24 +427,40 @@ object DataManager {
|
|||||||
|
|
||||||
// ==================== CONTRACTOR UPDATE METHODS ====================
|
// ==================== CONTRACTOR UPDATE METHODS ====================
|
||||||
|
|
||||||
fun setContractors(contractors: List<Contractor>) {
|
fun setContractors(contractors: List<ContractorSummary>) {
|
||||||
_contractors.value = contractors
|
_contractors.value = contractors
|
||||||
|
contractorsCacheTime = currentTimeMs()
|
||||||
updateLastSyncTime()
|
updateLastSyncTime()
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addContractor(contractor: Contractor) {
|
fun addContractor(contractor: ContractorSummary) {
|
||||||
_contractors.value = _contractors.value + contractor
|
_contractors.value = _contractors.value + contractor
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateContractor(contractor: Contractor) {
|
/** Add a full Contractor (converts to summary for storage) */
|
||||||
|
fun addContractor(contractor: Contractor) {
|
||||||
|
_contractors.value = _contractors.value + contractor.toSummary()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateContractor(contractor: ContractorSummary) {
|
||||||
_contractors.value = _contractors.value.map {
|
_contractors.value = _contractors.value.map {
|
||||||
if (it.id == contractor.id) contractor else it
|
if (it.id == contractor.id) contractor else it
|
||||||
}
|
}
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update from a full Contractor (converts to summary for storage) */
|
||||||
|
fun updateContractor(contractor: Contractor) {
|
||||||
|
val summary = contractor.toSummary()
|
||||||
|
_contractors.value = _contractors.value.map {
|
||||||
|
if (it.id == summary.id) summary else it
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
fun removeContractor(contractorId: Int) {
|
fun removeContractor(contractorId: Int) {
|
||||||
_contractors.value = _contractors.value.filter { it.id != contractorId }
|
_contractors.value = _contractors.value.filter { it.id != contractorId }
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
@@ -475,6 +554,7 @@ object DataManager {
|
|||||||
// Clear user data
|
// Clear user data
|
||||||
_residences.value = emptyList()
|
_residences.value = emptyList()
|
||||||
_myResidences.value = null
|
_myResidences.value = null
|
||||||
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
_tasksByResidence.value = emptyMap()
|
_tasksByResidence.value = emptyMap()
|
||||||
@@ -503,6 +583,15 @@ object DataManager {
|
|||||||
_contractorSpecialtiesMap.value = emptyMap()
|
_contractorSpecialtiesMap.value = emptyMap()
|
||||||
_lookupsInitialized.value = false
|
_lookupsInitialized.value = false
|
||||||
|
|
||||||
|
// Clear cache timestamps
|
||||||
|
residencesCacheTime = 0L
|
||||||
|
myResidencesCacheTime = 0L
|
||||||
|
tasksCacheTime = 0L
|
||||||
|
tasksByResidenceCacheTime.clear()
|
||||||
|
contractorsCacheTime = 0L
|
||||||
|
documentsCacheTime = 0L
|
||||||
|
summaryCacheTime = 0L
|
||||||
|
|
||||||
// Clear metadata
|
// Clear metadata
|
||||||
_lastSyncTime.value = 0L
|
_lastSyncTime.value = 0L
|
||||||
|
|
||||||
@@ -517,6 +606,7 @@ object DataManager {
|
|||||||
_currentUser.value = null
|
_currentUser.value = null
|
||||||
_residences.value = emptyList()
|
_residences.value = emptyList()
|
||||||
_myResidences.value = null
|
_myResidences.value = null
|
||||||
|
_totalSummary.value = null
|
||||||
_residenceSummaries.value = emptyMap()
|
_residenceSummaries.value = emptyMap()
|
||||||
_allTasks.value = null
|
_allTasks.value = null
|
||||||
_tasksByResidence.value = emptyMap()
|
_tasksByResidence.value = emptyMap()
|
||||||
@@ -527,6 +617,16 @@ object DataManager {
|
|||||||
_upgradeTriggers.value = emptyMap()
|
_upgradeTriggers.value = emptyMap()
|
||||||
_featureBenefits.value = emptyList()
|
_featureBenefits.value = emptyList()
|
||||||
_promotions.value = emptyList()
|
_promotions.value = emptyList()
|
||||||
|
|
||||||
|
// Clear cache timestamps
|
||||||
|
residencesCacheTime = 0L
|
||||||
|
myResidencesCacheTime = 0L
|
||||||
|
tasksCacheTime = 0L
|
||||||
|
tasksByResidenceCacheTime.clear()
|
||||||
|
contractorsCacheTime = 0L
|
||||||
|
documentsCacheTime = 0L
|
||||||
|
summaryCacheTime = 0L
|
||||||
|
|
||||||
persistToDisk()
|
persistToDisk()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,169 +639,42 @@ object DataManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist current state to disk.
|
* Persist current state to disk.
|
||||||
* Called automatically after each update.
|
* Only persists user data - all other data is fetched fresh from API.
|
||||||
|
* No offline mode support - network required for app functionality.
|
||||||
*/
|
*/
|
||||||
private fun persistToDisk() {
|
private fun persistToDisk() {
|
||||||
val manager = persistenceManager ?: return
|
val manager = persistenceManager ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Persist each data type
|
// Only persist user data - everything else is fetched fresh from API
|
||||||
_currentUser.value?.let {
|
_currentUser.value?.let {
|
||||||
manager.save(KEY_CURRENT_USER, json.encodeToString(it))
|
manager.save(KEY_CURRENT_USER, json.encodeToString(it))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_residences.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_RESIDENCES, json.encodeToString(_residences.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
_myResidences.value?.let {
|
|
||||||
manager.save(KEY_MY_RESIDENCES, json.encodeToString(it))
|
|
||||||
}
|
|
||||||
|
|
||||||
_allTasks.value?.let {
|
|
||||||
manager.save(KEY_ALL_TASKS, json.encodeToString(it))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_documents.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_DOCUMENTS, json.encodeToString(_documents.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_contractors.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_CONTRACTORS, json.encodeToString(_contractors.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
_subscription.value?.let {
|
|
||||||
manager.save(KEY_SUBSCRIPTION, json.encodeToString(it))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist lookups
|
|
||||||
if (_residenceTypes.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value))
|
|
||||||
}
|
|
||||||
if (_taskFrequencies.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value))
|
|
||||||
}
|
|
||||||
if (_taskPriorities.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value))
|
|
||||||
}
|
|
||||||
if (_taskStatuses.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_TASK_STATUSES, json.encodeToString(_taskStatuses.value))
|
|
||||||
}
|
|
||||||
if (_taskCategories.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_TASK_CATEGORIES, json.encodeToString(_taskCategories.value))
|
|
||||||
}
|
|
||||||
if (_contractorSpecialties.value.isNotEmpty()) {
|
|
||||||
manager.save(KEY_CONTRACTOR_SPECIALTIES, json.encodeToString(_contractorSpecialties.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.save(KEY_LAST_SYNC_TIME, _lastSyncTime.value.toString())
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Log error but don't crash - persistence is best-effort
|
|
||||||
println("DataManager: Error persisting to disk: ${e.message}")
|
println("DataManager: Error persisting to disk: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load cached state from disk.
|
* Load cached state from disk.
|
||||||
* Called during initialization.
|
* Only loads user data - all other data is fetched fresh from API.
|
||||||
|
* No offline mode support - network required for app functionality.
|
||||||
*/
|
*/
|
||||||
private fun loadFromDisk() {
|
private fun loadFromDisk() {
|
||||||
val manager = persistenceManager ?: return
|
val manager = persistenceManager ?: return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Only load user data - everything else is fetched fresh from API
|
||||||
manager.load(KEY_CURRENT_USER)?.let { data ->
|
manager.load(KEY_CURRENT_USER)?.let { data ->
|
||||||
_currentUser.value = json.decodeFromString<User>(data)
|
_currentUser.value = json.decodeFromString<User>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.load(KEY_RESIDENCES)?.let { data ->
|
|
||||||
_residences.value = json.decodeFromString<List<Residence>>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_MY_RESIDENCES)?.let { data ->
|
|
||||||
_myResidences.value = json.decodeFromString<MyResidencesResponse>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_ALL_TASKS)?.let { data ->
|
|
||||||
_allTasks.value = json.decodeFromString<TaskColumnsResponse>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_DOCUMENTS)?.let { data ->
|
|
||||||
_documents.value = json.decodeFromString<List<Document>>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_CONTRACTORS)?.let { data ->
|
|
||||||
_contractors.value = json.decodeFromString<List<Contractor>>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_SUBSCRIPTION)?.let { data ->
|
|
||||||
_subscription.value = json.decodeFromString<SubscriptionStatus>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load lookups
|
|
||||||
manager.load(KEY_RESIDENCE_TYPES)?.let { data ->
|
|
||||||
val types = json.decodeFromString<List<ResidenceType>>(data)
|
|
||||||
_residenceTypes.value = types
|
|
||||||
_residenceTypesMap.value = types.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_TASK_FREQUENCIES)?.let { data ->
|
|
||||||
val items = json.decodeFromString<List<TaskFrequency>>(data)
|
|
||||||
_taskFrequencies.value = items
|
|
||||||
_taskFrequenciesMap.value = items.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_TASK_PRIORITIES)?.let { data ->
|
|
||||||
val items = json.decodeFromString<List<TaskPriority>>(data)
|
|
||||||
_taskPriorities.value = items
|
|
||||||
_taskPrioritiesMap.value = items.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_TASK_STATUSES)?.let { data ->
|
|
||||||
val items = json.decodeFromString<List<TaskStatus>>(data)
|
|
||||||
_taskStatuses.value = items
|
|
||||||
_taskStatusesMap.value = items.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_TASK_CATEGORIES)?.let { data ->
|
|
||||||
val items = json.decodeFromString<List<TaskCategory>>(data)
|
|
||||||
_taskCategories.value = items
|
|
||||||
_taskCategoriesMap.value = items.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data ->
|
|
||||||
val items = json.decodeFromString<List<ContractorSpecialty>>(data)
|
|
||||||
_contractorSpecialties.value = items
|
|
||||||
_contractorSpecialtiesMap.value = items.associateBy { it.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.load(KEY_LAST_SYNC_TIME)?.let { data ->
|
|
||||||
_lastSyncTime.value = data.toLongOrNull() ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark lookups initialized if we have data
|
|
||||||
if (_residenceTypes.value.isNotEmpty()) {
|
|
||||||
_lookupsInitialized.value = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Log error but don't crash - cache miss is OK
|
|
||||||
println("DataManager: Error loading from disk: ${e.message}")
|
println("DataManager: Error loading from disk: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PERSISTENCE KEYS ====================
|
// ==================== PERSISTENCE KEYS ====================
|
||||||
|
// Only user data is persisted - all other data fetched fresh from API
|
||||||
|
|
||||||
private const val KEY_CURRENT_USER = "dm_current_user"
|
private const val KEY_CURRENT_USER = "dm_current_user"
|
||||||
private const val KEY_RESIDENCES = "dm_residences"
|
|
||||||
private const val KEY_MY_RESIDENCES = "dm_my_residences"
|
|
||||||
private const val KEY_ALL_TASKS = "dm_all_tasks"
|
|
||||||
private const val KEY_DOCUMENTS = "dm_documents"
|
|
||||||
private const val KEY_CONTRACTORS = "dm_contractors"
|
|
||||||
private const val KEY_SUBSCRIPTION = "dm_subscription"
|
|
||||||
private const val KEY_RESIDENCE_TYPES = "dm_residence_types"
|
|
||||||
private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies"
|
|
||||||
private const val KEY_TASK_PRIORITIES = "dm_task_priorities"
|
|
||||||
private const val KEY_TASK_STATUSES = "dm_task_statuses"
|
|
||||||
private const val KEY_TASK_CATEGORIES = "dm_task_categories"
|
|
||||||
private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties"
|
|
||||||
private const val KEY_LAST_SYNC_TIME = "dm_last_sync_time"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,5 +86,15 @@ data class ContractorSummary(
|
|||||||
@SerialName("task_count") val taskCount: Int = 0
|
@SerialName("task_count") val taskCount: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note: API returns full Contractor objects for list endpoints
|
// Extension to convert full Contractor to ContractorSummary
|
||||||
// ContractorSummary kept for backward compatibility
|
fun Contractor.toSummary() = ContractorSummary(
|
||||||
|
id = id,
|
||||||
|
residenceId = residenceId,
|
||||||
|
name = name,
|
||||||
|
company = company,
|
||||||
|
phone = phone,
|
||||||
|
specialties = specialties,
|
||||||
|
rating = rating,
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
taskCount = taskCount
|
||||||
|
)
|
||||||
|
|||||||
@@ -248,12 +248,10 @@ object APILayer {
|
|||||||
// ==================== Residence Operations ====================
|
// ==================== Residence Operations ====================
|
||||||
|
|
||||||
suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> {
|
suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
// Cache is valid even if empty (user has no residences)
|
||||||
val cached = DataManager.residences.value
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
|
||||||
if (cached.isNotEmpty()) {
|
return ApiResult.Success(DataManager.residences.value)
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from API
|
// Fetch from API
|
||||||
@@ -269,8 +267,8 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> {
|
suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) {
|
||||||
val cached = DataManager.myResidences.value
|
val cached = DataManager.myResidences.value
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
@@ -290,8 +288,8 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
|
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
|
||||||
val cached = DataManager.residences.value.find { it.id == id }
|
val cached = DataManager.residences.value.find { it.id == id }
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
@@ -310,9 +308,27 @@ object APILayer {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getResidenceSummary(): ApiResult<ResidenceSummaryResponse> {
|
/**
|
||||||
|
* Get total summary (task counts across all residences).
|
||||||
|
* This is a lightweight endpoint for refreshing summary counts.
|
||||||
|
*/
|
||||||
|
suspend fun getSummary(forceRefresh: Boolean = false): ApiResult<TotalSummary> {
|
||||||
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.summaryCacheTime)) {
|
||||||
|
val cached = DataManager.totalSummary.value
|
||||||
|
if (cached != null) {
|
||||||
|
return ApiResult.Success(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
return residenceApi.getResidenceSummary(token)
|
val result = residenceApi.getSummary(token)
|
||||||
|
|
||||||
|
if (result is ApiResult.Success) {
|
||||||
|
DataManager.setTotalSummary(result.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createResidence(request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
|
suspend fun createResidence(request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
|
||||||
@@ -397,8 +413,8 @@ object APILayer {
|
|||||||
// ==================== Task Operations ====================
|
// ==================== Task Operations ====================
|
||||||
|
|
||||||
suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
||||||
val cached = DataManager.allTasks.value
|
val cached = DataManager.allTasks.value
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
@@ -418,8 +434,8 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
||||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
val cached = DataManager.tasksByResidence.value[residenceId]
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
@@ -548,6 +564,8 @@ object APILayer {
|
|||||||
result.data.updatedTask?.let { updatedTask ->
|
result.data.updatedTask?.let { updatedTask ->
|
||||||
DataManager.updateTask(updatedTask)
|
DataManager.updateTask(updatedTask)
|
||||||
}
|
}
|
||||||
|
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
|
||||||
|
refreshSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -566,6 +584,8 @@ object APILayer {
|
|||||||
result.data.updatedTask?.let { updatedTask ->
|
result.data.updatedTask?.let { updatedTask ->
|
||||||
DataManager.updateTask(updatedTask)
|
DataManager.updateTask(updatedTask)
|
||||||
}
|
}
|
||||||
|
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
|
||||||
|
refreshSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -596,12 +616,10 @@ object APILayer {
|
|||||||
contractorId != null || isActive != null || expiringSoon != null ||
|
contractorId != null || isActive != null || expiringSoon != null ||
|
||||||
tags != null || search != null
|
tags != null || search != null
|
||||||
|
|
||||||
// Check DataManager first if no filters
|
// Check DataManager first if no filters - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh && !hasFilters) {
|
// Cache is valid even if empty (user has no documents)
|
||||||
val cached = DataManager.documents.value
|
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
|
||||||
if (cached.isNotEmpty()) {
|
return ApiResult.Success(DataManager.documents.value)
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from API
|
// Fetch from API
|
||||||
@@ -620,8 +638,8 @@ object APILayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> {
|
suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> {
|
||||||
// Check DataManager first
|
// Check DataManager first - return cached if valid and not forcing refresh
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
|
||||||
val cached = DataManager.documents.value.find { it.id == id }
|
val cached = DataManager.documents.value.find { it.id == id }
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return ApiResult.Success(cached)
|
return ApiResult.Success(cached)
|
||||||
@@ -764,25 +782,32 @@ object APILayer {
|
|||||||
search: String? = null,
|
search: String? = null,
|
||||||
forceRefresh: Boolean = false
|
forceRefresh: Boolean = false
|
||||||
): ApiResult<List<ContractorSummary>> {
|
): ApiResult<List<ContractorSummary>> {
|
||||||
// Fetch from API (API returns summaries, not full contractors)
|
val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
|
||||||
return contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
|
// Check cache first (only if no filters applied) - return cached if valid and not forcing refresh
|
||||||
// Check DataManager first
|
// Cache is valid even if empty (user has no contractors)
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
|
||||||
val cached = DataManager.contractors.value.find { it.id == id }
|
return ApiResult.Success(DataManager.contractors.value)
|
||||||
if (cached != null) {
|
|
||||||
return ApiResult.Success(cached)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from API
|
// Fetch from API
|
||||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
|
val result = contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
|
||||||
|
|
||||||
|
// Update DataManager on success (only for unfiltered results)
|
||||||
|
if (result is ApiResult.Success && !hasFilters) {
|
||||||
|
DataManager.setContractors(result.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
|
||||||
|
// Fetch from API (summaries don't have full detail, always fetch)
|
||||||
|
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||||
val result = contractorApi.getContractor(token, id)
|
val result = contractorApi.getContractor(token, id)
|
||||||
|
|
||||||
// Update DataManager on success
|
// Update the summary in DataManager on success
|
||||||
if (result is ApiResult.Success) {
|
if (result is ApiResult.Success) {
|
||||||
DataManager.updateContractor(result.data)
|
DataManager.updateContractor(result.data)
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1055,13 @@ object APILayer {
|
|||||||
getMyResidences(forceRefresh = true)
|
getMyResidences(forceRefresh = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh just the summary counts (lightweight)
|
||||||
|
*/
|
||||||
|
private suspend fun refreshSummary() {
|
||||||
|
getSummary(forceRefresh = true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefetch all data after login
|
* Prefetch all data after login
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ package com.example.casera.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.DEV
|
val CURRENT_ENV = Environment.LOCAL
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getResidenceSummary(token: String): ApiResult<ResidenceSummaryResponse> {
|
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residences/summary/") {
|
val response = client.get("$baseUrl/residences/summary/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -102,7 +102,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(response.body())
|
||||||
} else {
|
} else {
|
||||||
ApiResult.Error("Failed to fetch residence summary", response.status.value)
|
ApiResult.Error("Failed to fetch summary", response.status.value)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.casera.models.*
|
import com.example.casera.models.*
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.AuthApi
|
import com.example.casera.network.APILayer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -19,7 +19,6 @@ enum class PasswordResetStep {
|
|||||||
class PasswordResetViewModel(
|
class PasswordResetViewModel(
|
||||||
private val deepLinkToken: String? = null
|
private val deepLinkToken: String? = null
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val authApi = AuthApi()
|
|
||||||
|
|
||||||
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||||
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||||
@@ -48,7 +47,7 @@ class PasswordResetViewModel(
|
|||||||
fun requestPasswordReset(email: String) {
|
fun requestPasswordReset(email: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_forgotPasswordState.value = ApiResult.Loading
|
_forgotPasswordState.value = ApiResult.Loading
|
||||||
val result = authApi.forgotPassword(ForgotPasswordRequest(email))
|
val result = APILayer.forgotPassword(ForgotPasswordRequest(email))
|
||||||
_forgotPasswordState.value = when (result) {
|
_forgotPasswordState.value = when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
_email.value = email
|
_email.value = email
|
||||||
@@ -66,7 +65,7 @@ class PasswordResetViewModel(
|
|||||||
fun verifyResetCode(email: String, code: String) {
|
fun verifyResetCode(email: String, code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_verifyCodeState.value = ApiResult.Loading
|
_verifyCodeState.value = ApiResult.Loading
|
||||||
val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code))
|
val result = APILayer.verifyResetCode(VerifyResetCodeRequest(email, code))
|
||||||
_verifyCodeState.value = when (result) {
|
_verifyCodeState.value = when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
_resetToken.value = result.data.resetToken
|
_resetToken.value = result.data.resetToken
|
||||||
@@ -91,7 +90,7 @@ class PasswordResetViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_resetPasswordState.value = ApiResult.Loading
|
_resetPasswordState.value = ApiResult.Loading
|
||||||
// Note: confirmPassword is for UI validation only, not sent to API
|
// Note: confirmPassword is for UI validation only, not sent to API
|
||||||
val result = authApi.resetPassword(
|
val result = APILayer.resetPassword(
|
||||||
ResetPasswordRequest(
|
ResetPasswordRequest(
|
||||||
resetToken = token,
|
resetToken = token,
|
||||||
newPassword = newPassword
|
newPassword = newPassword
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.casera.models.Residence
|
import com.example.casera.models.Residence
|
||||||
import com.example.casera.models.ResidenceCreateRequest
|
import com.example.casera.models.ResidenceCreateRequest
|
||||||
import com.example.casera.models.ResidenceSummaryResponse
|
import com.example.casera.models.TotalSummary
|
||||||
import com.example.casera.models.MyResidencesResponse
|
import com.example.casera.models.MyResidencesResponse
|
||||||
import com.example.casera.models.TaskColumnsResponse
|
import com.example.casera.models.TaskColumnsResponse
|
||||||
import com.example.casera.models.ContractorSummary
|
import com.example.casera.models.ContractorSummary
|
||||||
@@ -19,8 +19,8 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle)
|
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle)
|
||||||
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
|
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
|
||||||
|
|
||||||
private val _residenceSummaryState = MutableStateFlow<ApiResult<ResidenceSummaryResponse>>(ApiResult.Idle)
|
private val _summaryState = MutableStateFlow<ApiResult<TotalSummary>>(ApiResult.Idle)
|
||||||
val residenceSummaryState: StateFlow<ApiResult<ResidenceSummaryResponse>> = _residenceSummaryState
|
val summaryState: StateFlow<ApiResult<TotalSummary>> = _summaryState
|
||||||
|
|
||||||
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
||||||
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
|
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
|
||||||
@@ -63,10 +63,10 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadResidenceSummary() {
|
fun loadSummary(forceRefresh: Boolean = false) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_residenceSummaryState.value = ApiResult.Loading
|
_summaryState.value = ApiResult.Loading
|
||||||
_residenceSummaryState.value = APILayer.getResidenceSummary()
|
_summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,31 +2,25 @@ package com.example.casera.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.casera.data.DataManager
|
|
||||||
import com.example.casera.models.TaskCompletion
|
|
||||||
import com.example.casera.models.TaskCompletionCreateRequest
|
import com.example.casera.models.TaskCompletionCreateRequest
|
||||||
|
import com.example.casera.models.TaskCompletionResponse
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.TaskCompletionApi
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.util.ImageCompressor
|
import com.example.casera.util.ImageCompressor
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class TaskCompletionViewModel : ViewModel() {
|
class TaskCompletionViewModel : ViewModel() {
|
||||||
private val taskCompletionApi = TaskCompletionApi()
|
|
||||||
|
|
||||||
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletion>>(ApiResult.Idle)
|
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletionResponse>>(ApiResult.Idle)
|
||||||
val createCompletionState: StateFlow<ApiResult<TaskCompletion>> = _createCompletionState
|
val createCompletionState: StateFlow<ApiResult<TaskCompletionResponse>> = _createCompletionState
|
||||||
|
|
||||||
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_createCompletionState.value = ApiResult.Loading
|
||||||
val token = DataManager.authToken.value
|
// Use APILayer which handles DataManager updates and summary refresh
|
||||||
if (token != null) {
|
_createCompletionState.value = APILayer.createTaskCompletion(request)
|
||||||
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
|
|
||||||
} else {
|
|
||||||
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,31 +36,27 @@ class TaskCompletionViewModel : ViewModel() {
|
|||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_createCompletionState.value = ApiResult.Loading
|
||||||
val token = DataManager.authToken.value
|
|
||||||
if (token != null) {
|
|
||||||
// Compress images and prepare for upload
|
|
||||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
|
||||||
val imageFileNames = images.mapIndexed { index, image ->
|
|
||||||
// Always use .jpg extension since we compress to JPEG
|
|
||||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
|
||||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
|
||||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
|
||||||
baseName
|
|
||||||
} else {
|
|
||||||
// Remove any existing extension and add .jpg
|
|
||||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_createCompletionState.value = taskCompletionApi.createCompletionWithImages(
|
// Compress images and prepare for upload
|
||||||
token = token,
|
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||||
request = request,
|
val imageFileNames = images.mapIndexed { index, image ->
|
||||||
images = compressedImages,
|
// Always use .jpg extension since we compress to JPEG
|
||||||
imageFileNames = imageFileNames
|
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||||
)
|
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||||
} else {
|
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||||
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
baseName
|
||||||
|
} else {
|
||||||
|
// Remove any existing extension and add .jpg
|
||||||
|
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use APILayer which handles DataManager updates and summary refresh
|
||||||
|
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
||||||
|
request = request,
|
||||||
|
images = compressedImages,
|
||||||
|
imageFileNames = imageFileNames
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for contractor management.
|
||||||
|
/// Observes DataManagerObservable for contractors list (automatically updated after mutations).
|
||||||
|
/// Calls APILayer for operations - DataManager updates propagate automatically via observation.
|
||||||
@MainActor
|
@MainActor
|
||||||
class ContractorViewModel: ObservableObject {
|
class ContractorViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -15,145 +18,168 @@ class ContractorViewModel: ObservableObject {
|
|||||||
@Published var successMessage: String?
|
@Published var successMessage: String?
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let sharedViewModel: ComposeApp.ContractorViewModel
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(sharedViewModel: ComposeApp.ContractorViewModel? = nil) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.ContractorViewModel()
|
init() {
|
||||||
|
// Observe contractors from DataManagerObservable
|
||||||
|
DataManagerObservable.shared.$contractors
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] contractors in
|
||||||
|
self?.contractors = contractors
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func loadContractors(
|
|
||||||
specialty: String? = nil,
|
/// Load contractors list - delegates to APILayer which handles cache timeout
|
||||||
isFavorite: Bool? = nil,
|
func loadContractors(forceRefresh: Bool = false) {
|
||||||
isActive: Bool? = nil,
|
|
||||||
search: String? = nil,
|
|
||||||
forceRefresh: Bool = false
|
|
||||||
) {
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadContractors(
|
Task {
|
||||||
specialty: specialty,
|
do {
|
||||||
isFavorite: isFavorite.asKotlin,
|
let result = try await APILayer.shared.getContractors(
|
||||||
isActive: isActive.asKotlin,
|
specialty: nil,
|
||||||
search: search,
|
isFavorite: nil,
|
||||||
forceRefresh: forceRefresh
|
isActive: nil,
|
||||||
)
|
search: nil,
|
||||||
|
forceRefresh: forceRefresh
|
||||||
|
)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
// API updates DataManager on success, which triggers our observation
|
||||||
sharedViewModel.contractorsState,
|
if result is ApiResultSuccess<NSArray> {
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.isLoading = false
|
||||||
onSuccess: { [weak self] (data: NSArray) in
|
} else if let error = result as? ApiResultError {
|
||||||
self?.contractors = data as? [ContractorSummary] ?? []
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.isLoading = false
|
self.isLoading = false
|
||||||
},
|
}
|
||||||
onError: { [weak self] error in
|
} catch {
|
||||||
self?.errorMessage = error
|
self.errorMessage = error.localizedDescription
|
||||||
self?.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadContractorDetail(id: Int32) {
|
func loadContractorDetail(id: Int32) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadContractorDetail(id: id)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.getContractor(id: id, forceRefresh: false)
|
||||||
|
|
||||||
StateFlowObserver.observeWithState(
|
if let success = result as? ApiResultSuccess<Contractor> {
|
||||||
sharedViewModel.contractorDetailState,
|
self.selectedContractor = success.data
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
self.isLoading = false
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
} else if let error = result as? ApiResultError {
|
||||||
onSuccess: { [weak self] (data: Contractor) in
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.selectedContractor = data
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
|
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||||
isCreating = true
|
isCreating = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.createContractor(request: request)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.createContractor(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<Contractor> {
|
||||||
sharedViewModel.createState,
|
self.successMessage = "Contractor added successfully"
|
||||||
onLoading: { [weak self] in self?.isCreating = true },
|
self.isCreating = false
|
||||||
onSuccess: { [weak self] (_: Contractor) in
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
self?.successMessage = "Contractor added successfully"
|
completion(true)
|
||||||
self?.isCreating = false
|
} else if let error = result as? ApiResultError {
|
||||||
completion(true)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
},
|
self.isCreating = false
|
||||||
onError: { [weak self] error in
|
completion(false)
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.isCreating = false
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isCreating = false
|
||||||
completion(false)
|
completion(false)
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
|
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
|
||||||
isUpdating = true
|
isUpdating = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.updateContractor(id: id, request: request)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.updateContractor(id: id, request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<Contractor> {
|
||||||
sharedViewModel.updateState,
|
self.successMessage = "Contractor updated successfully"
|
||||||
onLoading: { [weak self] in self?.isUpdating = true },
|
self.isUpdating = false
|
||||||
onSuccess: { [weak self] (_: Contractor) in
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
self?.successMessage = "Contractor updated successfully"
|
completion(true)
|
||||||
self?.isUpdating = false
|
} else if let error = result as? ApiResultError {
|
||||||
completion(true)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
},
|
self.isUpdating = false
|
||||||
onError: { [weak self] error in
|
completion(false)
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.isUpdating = false
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isUpdating = false
|
||||||
completion(false)
|
completion(false)
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
|
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||||
isDeleting = true
|
isDeleting = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.deleteContractor(id: id)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.deleteContractor(id: id)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
sharedViewModel.deleteState,
|
self.successMessage = "Contractor deleted successfully"
|
||||||
onLoading: { [weak self] in self?.isDeleting = true },
|
self.isDeleting = false
|
||||||
onSuccess: { [weak self] (_: KotlinUnit) in
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
self?.successMessage = "Contractor deleted successfully"
|
completion(true)
|
||||||
self?.isDeleting = false
|
} else if let error = result as? ApiResultError {
|
||||||
completion(true)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
},
|
self.isDeleting = false
|
||||||
onError: { [weak self] error in
|
completion(false)
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.isDeleting = false
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isDeleting = false
|
||||||
completion(false)
|
completion(false)
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) {
|
func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||||
sharedViewModel.toggleFavorite(id: id)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.toggleFavorite(id: id)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<Contractor> {
|
||||||
sharedViewModel.toggleFavoriteState,
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
onSuccess: { (_: Contractor) in
|
completion(true)
|
||||||
completion(true)
|
} else if let error = result as? ApiResultError {
|
||||||
},
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
onError: { [weak self] error in
|
completion(false)
|
||||||
self?.errorMessage = error
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
completion(false)
|
completion(false)
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetToggleFavoriteState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearMessages() {
|
func clearMessages() {
|
||||||
@@ -161,4 +187,3 @@ class ContractorViewModel: ObservableObject {
|
|||||||
successMessage = nil
|
successMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ struct ContractorsListView: View {
|
|||||||
|
|
||||||
private func loadContractors(forceRefresh: Bool = false) {
|
private func loadContractors(forceRefresh: Bool = false) {
|
||||||
// Load all contractors, filtering is done client-side
|
// Load all contractors, filtering is done client-side
|
||||||
viewModel.loadContractors()
|
viewModel.loadContractors(forceRefresh: forceRefresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContractorSpecialties() {
|
private func loadContractorSpecialties() {
|
||||||
|
|||||||
@@ -223,11 +223,17 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let errorMessage = errorMessage, items.isEmpty {
|
if let errorMessage = errorMessage, items.isEmpty {
|
||||||
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
// Wrap in ScrollView for pull-to-refresh support
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
ScrollView {
|
||||||
|
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
|
||||||
|
}
|
||||||
} else if items.isEmpty && !isLoading {
|
} else if items.isEmpty && !isLoading {
|
||||||
emptyContent()
|
// Wrap in ScrollView for pull-to-refresh support
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
ScrollView {
|
||||||
|
emptyContent()
|
||||||
|
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content(items)
|
content(items)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class DataManagerObservable: ObservableObject {
|
|||||||
|
|
||||||
@Published var residences: [ResidenceResponse] = []
|
@Published var residences: [ResidenceResponse] = []
|
||||||
@Published var myResidences: MyResidencesResponse?
|
@Published var myResidences: MyResidencesResponse?
|
||||||
|
@Published var totalSummary: TotalSummary?
|
||||||
@Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:]
|
@Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:]
|
||||||
|
|
||||||
// MARK: - Tasks
|
// MARK: - Tasks
|
||||||
@@ -49,7 +50,7 @@ class DataManagerObservable: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Contractors
|
// MARK: - Contractors
|
||||||
|
|
||||||
@Published var contractors: [Contractor] = []
|
@Published var contractors: [ContractorSummary] = []
|
||||||
|
|
||||||
// MARK: - Subscription
|
// MARK: - Subscription
|
||||||
|
|
||||||
@@ -138,6 +139,16 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
observationTasks.append(myResidencesTask)
|
observationTasks.append(myResidencesTask)
|
||||||
|
|
||||||
|
// TotalSummary
|
||||||
|
let totalSummaryTask = Task {
|
||||||
|
for await summary in DataManager.shared.totalSummary {
|
||||||
|
await MainActor.run {
|
||||||
|
self.totalSummary = summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observationTasks.append(totalSummaryTask)
|
||||||
|
|
||||||
// ResidenceSummaries
|
// ResidenceSummaries
|
||||||
let residenceSummariesTask = Task {
|
let residenceSummariesTask = Task {
|
||||||
for await summaries in DataManager.shared.residenceSummaries {
|
for await summaries in DataManager.shared.residenceSummaries {
|
||||||
@@ -338,26 +349,35 @@ class DataManagerObservable: ObservableObject {
|
|||||||
// MARK: - Map Conversion Helpers
|
// MARK: - Map Conversion Helpers
|
||||||
|
|
||||||
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
||||||
private func convertIntMap<V>(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] {
|
private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
|
||||||
|
guard let map = kotlinMap as? [KotlinInt: V] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
var result: [Int32: V] = [:]
|
var result: [Int32: V] = [:]
|
||||||
for (key, value) in kotlinMap {
|
for (key, value) in map {
|
||||||
result[key.int32Value] = value
|
result[key.int32Value] = value
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
||||||
private func convertIntArrayMap<V>(_ kotlinMap: [KotlinInt: [V]]) -> [Int32: [V]] {
|
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
|
||||||
|
guard let map = kotlinMap as? [KotlinInt: [V]] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
var result: [Int32: [V]] = [:]
|
var result: [Int32: [V]] = [:]
|
||||||
for (key, value) in kotlinMap {
|
for (key, value) in map {
|
||||||
result[key.int32Value] = value
|
result[key.int32Value] = value
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Kotlin Map<String, V> to Swift [String: V]
|
/// Convert Kotlin Map<String, V> to Swift [String: V]
|
||||||
private func convertStringMap<V>(_ kotlinMap: [String: V]) -> [String: V] {
|
private func convertStringMap<V>(_ kotlinMap: Any?) -> [String: V] {
|
||||||
return kotlinMap
|
guard let map = kotlinMap as? [String: V] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Convenience Lookup Methods
|
// MARK: - Convenience Lookup Methods
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ import UIKit
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for document management.
|
||||||
|
/// Observes DataManagerObservable for documents list.
|
||||||
|
/// Calls APILayer directly for all operations.
|
||||||
@MainActor
|
@MainActor
|
||||||
class DocumentViewModel: ObservableObject {
|
class DocumentViewModel: ObservableObject {
|
||||||
@Published var documents: [Document] = []
|
@Published var documents: [Document] = []
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
private let sharedViewModel: ComposeApp.DocumentViewModel
|
// MARK: - Private Properties
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(sharedViewModel: ComposeApp.DocumentViewModel? = nil) {
|
init() {
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.DocumentViewModel()
|
// Observe documents from DataManagerObservable
|
||||||
|
DataManagerObservable.shared.$documents
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] documents in
|
||||||
|
self?.documents = documents
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDocuments(
|
func loadDocuments(
|
||||||
@@ -29,30 +39,32 @@ class DocumentViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadDocuments(
|
Task {
|
||||||
residenceId: residenceId.asKotlin,
|
do {
|
||||||
documentType: documentType,
|
let result = try await APILayer.shared.getDocuments(
|
||||||
category: category,
|
residenceId: residenceId.asKotlin,
|
||||||
contractorId: contractorId.asKotlin,
|
documentType: documentType,
|
||||||
isActive: isActive.asKotlin,
|
category: category,
|
||||||
expiringSoon: expiringSoon.asKotlin,
|
contractorId: contractorId.asKotlin,
|
||||||
tags: tags,
|
isActive: isActive.asKotlin,
|
||||||
search: search,
|
expiringSoon: expiringSoon.asKotlin,
|
||||||
forceRefresh: forceRefresh
|
tags: tags,
|
||||||
)
|
search: search,
|
||||||
|
forceRefresh: forceRefresh
|
||||||
|
)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
// API updates DataManager on success, which triggers our observation
|
||||||
sharedViewModel.documentsState,
|
if result is ApiResultSuccess<NSArray> {
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.isLoading = false
|
||||||
onSuccess: { [weak self] (data: NSArray) in
|
} else if let error = result as? ApiResultError {
|
||||||
self?.documents = data as? [Document] ?? []
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.isLoading = false
|
self.isLoading = false
|
||||||
},
|
}
|
||||||
onError: { [weak self] error in
|
} catch {
|
||||||
self?.errorMessage = error
|
self.errorMessage = error.localizedDescription
|
||||||
self?.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDocument(
|
func createDocument(
|
||||||
@@ -82,53 +94,52 @@ class DocumentViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
// Convert UIImages to ImageData
|
Task {
|
||||||
var imageDataList: [Any] = []
|
do {
|
||||||
for (index, image) in images.enumerated() {
|
let result = try await APILayer.shared.createDocument(
|
||||||
if let jpegData = image.jpegData(compressionQuality: 0.8) {
|
title: title,
|
||||||
// This would need platform-specific ImageData implementation
|
documentType: documentType,
|
||||||
// For now, skip image conversion - would need to be handled differently
|
residenceId: residenceId,
|
||||||
|
description: description,
|
||||||
|
category: category,
|
||||||
|
tags: tags,
|
||||||
|
notes: notes,
|
||||||
|
contractorId: contractorId.asKotlin,
|
||||||
|
isActive: isActive,
|
||||||
|
itemName: itemName,
|
||||||
|
modelNumber: modelNumber,
|
||||||
|
serialNumber: serialNumber,
|
||||||
|
provider: provider,
|
||||||
|
providerContact: providerContact,
|
||||||
|
claimPhone: claimPhone,
|
||||||
|
claimEmail: claimEmail,
|
||||||
|
claimWebsite: claimWebsite,
|
||||||
|
purchaseDate: purchaseDate,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
fileBytes: nil,
|
||||||
|
fileName: nil,
|
||||||
|
mimeType: nil,
|
||||||
|
fileBytesList: nil,
|
||||||
|
fileNamesList: nil,
|
||||||
|
mimeTypesList: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<Document> {
|
||||||
|
self.isLoading = false
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
|
completion(true, nil)
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false, self.errorMessage)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false, self.errorMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedViewModel.createDocument(
|
|
||||||
title: title,
|
|
||||||
documentType: documentType,
|
|
||||||
residenceId: residenceId,
|
|
||||||
description: description,
|
|
||||||
category: category,
|
|
||||||
tags: tags,
|
|
||||||
notes: notes,
|
|
||||||
contractorId: contractorId.asKotlin,
|
|
||||||
isActive: isActive,
|
|
||||||
itemName: itemName,
|
|
||||||
modelNumber: modelNumber,
|
|
||||||
serialNumber: serialNumber,
|
|
||||||
provider: provider,
|
|
||||||
providerContact: providerContact,
|
|
||||||
claimPhone: claimPhone,
|
|
||||||
claimEmail: claimEmail,
|
|
||||||
claimWebsite: claimWebsite,
|
|
||||||
purchaseDate: purchaseDate,
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate,
|
|
||||||
images: [] // Image handling needs platform-specific implementation
|
|
||||||
)
|
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
|
||||||
sharedViewModel.createState,
|
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
|
||||||
onSuccess: { [weak self] (_: Document) in
|
|
||||||
self?.isLoading = false
|
|
||||||
completion(true, nil)
|
|
||||||
},
|
|
||||||
onError: { [weak self] error in
|
|
||||||
self?.errorMessage = error
|
|
||||||
self?.isLoading = false
|
|
||||||
completion(false, error)
|
|
||||||
},
|
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDocument(
|
func updateDocument(
|
||||||
@@ -157,65 +168,77 @@ class DocumentViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.updateDocument(
|
Task {
|
||||||
id: id,
|
do {
|
||||||
title: title,
|
let result = try await APILayer.shared.updateDocument(
|
||||||
documentType: "", // Required but not changing
|
id: id,
|
||||||
description: description,
|
title: title,
|
||||||
category: category,
|
documentType: "", // Required but not changing
|
||||||
tags: tags,
|
description: description,
|
||||||
notes: notes,
|
category: category,
|
||||||
contractorId: contractorId.asKotlin,
|
tags: tags,
|
||||||
isActive: isActive,
|
notes: notes,
|
||||||
itemName: itemName,
|
contractorId: contractorId.asKotlin,
|
||||||
modelNumber: modelNumber,
|
isActive: isActive,
|
||||||
serialNumber: serialNumber,
|
itemName: itemName,
|
||||||
provider: provider,
|
modelNumber: modelNumber,
|
||||||
providerContact: providerContact,
|
serialNumber: serialNumber,
|
||||||
claimPhone: claimPhone,
|
provider: provider,
|
||||||
claimEmail: claimEmail,
|
providerContact: providerContact,
|
||||||
claimWebsite: claimWebsite,
|
claimPhone: claimPhone,
|
||||||
purchaseDate: purchaseDate,
|
claimEmail: claimEmail,
|
||||||
startDate: startDate,
|
claimWebsite: claimWebsite,
|
||||||
endDate: endDate,
|
purchaseDate: purchaseDate,
|
||||||
images: [] // Image handling needs platform-specific implementation
|
startDate: startDate,
|
||||||
)
|
endDate: endDate
|
||||||
|
)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<Document> {
|
||||||
sharedViewModel.updateState,
|
self.isLoading = false
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
onSuccess: { [weak self] (_: Document) in
|
completion(true, nil)
|
||||||
self?.isLoading = false
|
} else if let error = result as? ApiResultError {
|
||||||
completion(true, nil)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
},
|
self.isLoading = false
|
||||||
onError: { [weak self] error in
|
completion(false, self.errorMessage)
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.isLoading = false
|
} catch {
|
||||||
completion(false, error)
|
self.errorMessage = error.localizedDescription
|
||||||
},
|
self.isLoading = false
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
|
completion(false, self.errorMessage)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteDocument(id: Int32) {
|
func deleteDocument(id: Int32, completion: @escaping (Bool) -> Void = { _ in }) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.deleteDocument(id: id)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||||
|
|
||||||
StateFlowObserver.observeWithState(
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
sharedViewModel.deleteState,
|
self.isLoading = false
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
completion(true)
|
||||||
onSuccess: { (_: KotlinUnit) in },
|
} else if let error = result as? ApiResultError {
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() }
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
)
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadDocument(url: String) -> Task<Data?, Error> {
|
func downloadDocument(url: String) -> Task<Data?, Error> {
|
||||||
return Task {
|
return Task {
|
||||||
do {
|
do {
|
||||||
let result = try await sharedViewModel.downloadDocument(url: url)
|
let result = try await APILayer.shared.downloadDocument(url: url)
|
||||||
|
|
||||||
if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data {
|
if let success = result as? ApiResultSuccess<KotlinByteArray>, let byteArray = success.data {
|
||||||
// Convert Kotlin ByteArray to Swift Data
|
// Convert Kotlin ByteArray to Swift Data
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
@Published var deleteState: DeleteState = DeleteStateIdle()
|
@Published var deleteState: DeleteState = DeleteStateIdle()
|
||||||
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
|
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
|
||||||
|
|
||||||
private let documentApi = DocumentApi(client: ApiClient_iosKt.createHttpClient())
|
|
||||||
|
|
||||||
func loadDocuments(
|
func loadDocuments(
|
||||||
residenceId: Int32? = nil,
|
residenceId: Int32? = nil,
|
||||||
documentType: String? = nil,
|
documentType: String? = nil,
|
||||||
@@ -68,29 +66,22 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
tags: String? = nil,
|
tags: String? = nil,
|
||||||
search: String? = nil
|
search: String? = nil
|
||||||
) {
|
) {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.documentsState = DocumentStateError(message: "Not authenticated")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.documentsState = DocumentStateLoading()
|
self.documentsState = DocumentStateLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await documentApi.getDocuments(
|
let result = try await APILayer.shared.getDocuments(
|
||||||
token: token,
|
residenceId: residenceId != nil ? KotlinInt(int: residenceId!) : nil,
|
||||||
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
|
|
||||||
documentType: documentType,
|
documentType: documentType,
|
||||||
category: category,
|
category: category,
|
||||||
contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil,
|
contractorId: contractorId != nil ? KotlinInt(int: contractorId!) : nil,
|
||||||
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
|
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
|
||||||
expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil,
|
expiringSoon: expiringSoon != nil ? KotlinInt(int: expiringSoon!) : nil,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
search: search
|
search: search,
|
||||||
|
forceRefresh: false
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -110,20 +101,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadDocumentDetail(id: Int32) {
|
func loadDocumentDetail(id: Int32) {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.documentDetailState = DocumentDetailStateError(message: "Not authenticated")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.documentDetailState = DocumentDetailStateLoading()
|
self.documentDetailState = DocumentDetailStateLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await documentApi.getDocument(token: token, id: id)
|
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
@@ -161,21 +145,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
startDate: String? = nil,
|
startDate: String? = nil,
|
||||||
endDate: String? = nil
|
endDate: String? = nil
|
||||||
) {
|
) {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateState = UpdateStateError(message: "Not authenticated")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.updateState = UpdateStateLoading()
|
self.updateState = UpdateStateLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await documentApi.updateDocument(
|
let result = try await APILayer.shared.updateDocument(
|
||||||
token: token,
|
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
documentType: documentType,
|
documentType: documentType,
|
||||||
@@ -184,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
notes: notes,
|
notes: notes,
|
||||||
contractorId: nil,
|
contractorId: nil,
|
||||||
isActive: KotlinBoolean(bool: isActive),
|
isActive: isActive,
|
||||||
itemName: itemName,
|
itemName: itemName,
|
||||||
modelNumber: modelNumber,
|
modelNumber: modelNumber,
|
||||||
serialNumber: serialNumber,
|
serialNumber: serialNumber,
|
||||||
@@ -195,10 +171,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
claimWebsite: claimWebsite,
|
claimWebsite: claimWebsite,
|
||||||
purchaseDate: purchaseDate,
|
purchaseDate: purchaseDate,
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
endDate: endDate,
|
endDate: endDate
|
||||||
fileBytes: nil,
|
|
||||||
fileName: nil,
|
|
||||||
mimeType: nil
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -219,20 +192,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteDocument(id: Int32) {
|
func deleteDocument(id: Int32) {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteState = DeleteStateError(message: "Not authenticated")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.deleteState = DeleteStateLoading()
|
self.deleteState = DeleteStateLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await documentApi.deleteDocument(token: token, id: id)
|
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if result is ApiResultSuccess<KotlinUnit> {
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
@@ -262,20 +228,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteDocumentImage(imageId: Int32) {
|
func deleteDocumentImage(imageId: Int32) {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteImageState = DeleteImageStateError(message: "Not authenticated")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.deleteImageState = DeleteImageStateLoading()
|
self.deleteImageState = DeleteImageStateLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await documentApi.deleteDocumentImage(token: token, imageId: imageId)
|
let result = try await APILayer.shared.deleteDocumentImage(imageId: imageId)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if result is ApiResultSuccess<KotlinUnit> {
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
|
|||||||
@@ -20,12 +20,32 @@ struct DocumentsWarrantiesView: View {
|
|||||||
|
|
||||||
let residenceId: Int32?
|
let residenceId: Int32?
|
||||||
|
|
||||||
|
// Client-side filtering for warranties tab
|
||||||
var warranties: [Document] {
|
var warranties: [Document] {
|
||||||
documentViewModel.documents.filter { $0.documentType == "warranty" }
|
documentViewModel.documents.filter { doc in
|
||||||
|
guard doc.documentType == "warranty" else { return false }
|
||||||
|
// Apply active filter if enabled
|
||||||
|
if showActiveOnly && doc.isActive != true {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Apply category filter if selected
|
||||||
|
if let category = selectedCategory, doc.category != category {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side filtering for documents tab
|
||||||
var documents: [Document] {
|
var documents: [Document] {
|
||||||
documentViewModel.documents.filter { $0.documentType != "warranty" }
|
documentViewModel.documents.filter { doc in
|
||||||
|
guard doc.documentType != "warranty" else { return false }
|
||||||
|
// Apply document type filter if selected
|
||||||
|
if let docType = selectedDocType, doc.documentType != docType {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if upgrade screen should be shown (disables add button)
|
// Check if upgrade screen should be shown (disables add button)
|
||||||
@@ -104,23 +124,21 @@ struct DocumentsWarrantiesView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: AppSpacing.sm) {
|
||||||
// Active Filter (for warranties)
|
// Active Filter (for warranties) - client-side, no API call
|
||||||
if selectedTab == .warranties {
|
if selectedTab == .warranties {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showActiveOnly.toggle()
|
showActiveOnly.toggle()
|
||||||
loadWarranties()
|
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter Menu
|
// Filter Menu - client-side filtering, no API calls
|
||||||
Menu {
|
Menu {
|
||||||
if selectedTab == .warranties {
|
if selectedTab == .warranties {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedCategory = nil
|
selectedCategory = nil
|
||||||
loadWarranties()
|
|
||||||
}) {
|
}) {
|
||||||
Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "")
|
Label(L10n.Documents.allCategories, systemImage: selectedCategory == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -130,7 +148,6 @@ struct DocumentsWarrantiesView: View {
|
|||||||
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
ForEach(DocumentCategory.allCases, id: \.self) { category in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedCategory = category.displayName
|
selectedCategory = category.displayName
|
||||||
loadWarranties()
|
|
||||||
}) {
|
}) {
|
||||||
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
|
Label(category.displayName, systemImage: selectedCategory == category.displayName ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -138,7 +155,6 @@ struct DocumentsWarrantiesView: View {
|
|||||||
} else {
|
} else {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedDocType = nil
|
selectedDocType = nil
|
||||||
loadDocuments()
|
|
||||||
}) {
|
}) {
|
||||||
Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "")
|
Label(L10n.Documents.allTypes, systemImage: selectedDocType == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -148,7 +164,6 @@ struct DocumentsWarrantiesView: View {
|
|||||||
ForEach(DocumentType.allCases, id: \.self) { type in
|
ForEach(DocumentType.allCases, id: \.self) { type in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedDocType = type.displayName
|
selectedDocType = type.displayName
|
||||||
loadDocuments()
|
|
||||||
}) {
|
}) {
|
||||||
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
|
Label(type.displayName, systemImage: selectedDocType == type.displayName ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -177,16 +192,10 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadWarranties()
|
// Load all documents once - filtering is client-side
|
||||||
loadDocuments()
|
loadAllDocuments()
|
||||||
}
|
|
||||||
.onChange(of: selectedTab) { _ in
|
|
||||||
if selectedTab == .warranties {
|
|
||||||
loadWarranties()
|
|
||||||
} else {
|
|
||||||
loadDocuments()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// No need for onChange on selectedTab - filtering is client-side
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
AddDocumentView(
|
AddDocumentView(
|
||||||
residenceId: residenceId,
|
residenceId: residenceId,
|
||||||
@@ -200,20 +209,20 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadAllDocuments(forceRefresh: Bool = false) {
|
||||||
|
// Load all documents without filters to use cache
|
||||||
|
// Filtering is done client-side in the computed properties
|
||||||
|
documentViewModel.loadDocuments(forceRefresh: forceRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadWarranties() {
|
private func loadWarranties() {
|
||||||
documentViewModel.loadDocuments(
|
// Just reload all - filtering happens client-side
|
||||||
residenceId: residenceId,
|
loadAllDocuments()
|
||||||
documentType: "warranty",
|
|
||||||
category: selectedCategory,
|
|
||||||
isActive: showActiveOnly ? true : nil
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDocuments() {
|
private func loadDocuments() {
|
||||||
documentViewModel.loadDocuments(
|
// Just reload all - filtering happens client-side
|
||||||
residenceId: residenceId,
|
loadAllDocuments()
|
||||||
documentType: selectedDocType
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// ViewModel for handling Apple Sign In flow
|
/// ViewModel for handling Apple Sign In flow.
|
||||||
/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin)
|
/// Calls APILayer directly for backend authentication.
|
||||||
@MainActor
|
@MainActor
|
||||||
class AppleSignInViewModel: ObservableObject {
|
class AppleSignInViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -13,21 +13,10 @@ class AppleSignInViewModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let appleSignInManager = AppleSignInManager()
|
private let appleSignInManager = AppleSignInManager()
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
|
||||||
|
|
||||||
// MARK: - Callbacks
|
// MARK: - Callbacks
|
||||||
var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified
|
var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
init(
|
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil,
|
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
|
||||||
) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Initiates the Apple Sign In flow
|
/// Initiates the Apple Sign In flow
|
||||||
@@ -58,70 +47,43 @@ class AppleSignInViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// Sends Apple credential to backend for verification/authentication
|
/// Sends Apple credential to backend for verification/authentication
|
||||||
private func sendCredentialToBackend(_ credential: AppleSignInCredential) {
|
private func sendCredentialToBackend(_ credential: AppleSignInCredential) {
|
||||||
sharedViewModel.appleSignIn(
|
|
||||||
idToken: credential.identityToken,
|
|
||||||
userId: credential.userIdentifier,
|
|
||||||
email: credential.email,
|
|
||||||
firstName: credential.firstName,
|
|
||||||
lastName: credential.lastName
|
|
||||||
)
|
|
||||||
|
|
||||||
// Observe the result
|
|
||||||
Task {
|
Task {
|
||||||
for await state in sharedViewModel.appleSignInState {
|
do {
|
||||||
if state is ApiResultLoading {
|
let request = AppleSignInRequest(
|
||||||
await MainActor.run {
|
idToken: credential.identityToken,
|
||||||
self.isLoading = true
|
userId: credential.userIdentifier,
|
||||||
}
|
email: credential.email,
|
||||||
} else if let success = state as? ApiResultSuccess<AppleSignInResponse> {
|
firstName: credential.firstName,
|
||||||
await MainActor.run {
|
lastName: credential.lastName
|
||||||
self.handleSuccess(success.data)
|
)
|
||||||
}
|
let result = try await APILayer.shared.appleSignIn(request: request)
|
||||||
break
|
|
||||||
} else if let error = state as? ApiResultError {
|
if let success = result as? ApiResultSuccess<AppleSignInResponse>, let response = success.data {
|
||||||
await MainActor.run {
|
self.handleSuccess(response)
|
||||||
self.handleBackendError(error)
|
} else if let error = result as? ApiResultError {
|
||||||
}
|
self.handleBackendError(error)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles successful authentication
|
/// Handles successful authentication
|
||||||
private func handleSuccess(_ response: AppleSignInResponse?) {
|
private func handleSuccess(_ response: AppleSignInResponse) {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
guard let response = response,
|
|
||||||
let token = response.token as String? else {
|
|
||||||
errorMessage = "Invalid response from server"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = response.user
|
let user = response.user
|
||||||
|
|
||||||
// Store the token
|
|
||||||
tokenStorage.saveToken(token: token)
|
|
||||||
|
|
||||||
// Track if this is a new user
|
// Track if this is a new user
|
||||||
isNewUser = response.isNewUser
|
isNewUser = response.isNewUser
|
||||||
|
|
||||||
// Initialize lookups
|
// APILayer.appleSignIn already:
|
||||||
Task {
|
// - Stores token in DataManager
|
||||||
_ = try? await APILayer.shared.initializeLookups()
|
// - Sets current user in DataManager
|
||||||
}
|
// - Initializes lookups
|
||||||
|
// - Prefetches all data
|
||||||
// Prefetch data
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
print("Starting data prefetch after Apple Sign In...")
|
|
||||||
let prefetchManager = DataPrefetchManager.Companion().getInstance()
|
|
||||||
_ = try await prefetchManager.prefetchAllData()
|
|
||||||
print("Data prefetch completed successfully")
|
|
||||||
} catch {
|
|
||||||
print("Data prefetch failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")
|
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")
|
||||||
|
|
||||||
@@ -147,7 +109,6 @@ class AppleSignInViewModel: ObservableObject {
|
|||||||
/// Handles backend API errors
|
/// Handles backend API errors
|
||||||
private func handleBackendError(_ error: ApiResultError) {
|
private func handleBackendError(_ error: ApiResultError) {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
sharedViewModel.resetAppleSignInState()
|
|
||||||
|
|
||||||
if let code = error.code?.intValue {
|
if let code = error.code?.intValue {
|
||||||
switch code {
|
switch code {
|
||||||
|
|||||||
@@ -2,32 +2,46 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for user login.
|
||||||
|
/// Observes DataManagerObservable for authentication state.
|
||||||
|
/// Kicks off API calls that update DataManager, letting views react to cache updates.
|
||||||
@MainActor
|
@MainActor
|
||||||
class LoginViewModel: ObservableObject {
|
class LoginViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties (from DataManager observation)
|
||||||
|
@Published var currentUser: User?
|
||||||
|
@Published var isAuthenticated: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Local State
|
||||||
@Published var username: String = ""
|
@Published var username: String = ""
|
||||||
@Published var password: String = ""
|
@Published var password: String = ""
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var isVerified: Bool = false
|
@Published var isVerified: Bool = false
|
||||||
@Published var currentUser: User?
|
|
||||||
|
|
||||||
// MARK: - Private Properties
|
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
|
||||||
|
|
||||||
// Callback for successful login
|
// Callback for successful login
|
||||||
var onLoginSuccess: ((Bool) -> Void)?
|
var onLoginSuccess: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Private Properties
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init() {
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil,
|
// Observe DataManagerObservable for authentication state
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
DataManagerObservable.shared.$currentUser
|
||||||
) {
|
.receive(on: DispatchQueue.main)
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
.sink { [weak self] user in
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
self?.currentUser = user
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
DataManagerObservable.shared.$isAuthenticated
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isAuth in
|
||||||
|
self?.isAuthenticated = isAuth
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func login() {
|
func login() {
|
||||||
guard !username.isEmpty else {
|
guard !username.isEmpty else {
|
||||||
@@ -43,175 +57,94 @@ class LoginViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.login(username: username, password: password)
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
for await state in sharedViewModel.loginState {
|
do {
|
||||||
if state is ApiResultLoading {
|
let result = try await APILayer.shared.login(
|
||||||
await MainActor.run {
|
request: LoginRequest(username: username, password: password)
|
||||||
self.isLoading = true
|
)
|
||||||
}
|
|
||||||
} else if let success = state as? ApiResultSuccess<AuthResponse> {
|
|
||||||
await MainActor.run {
|
|
||||||
if let token = success.data?.token,
|
|
||||||
let user = success.data?.user {
|
|
||||||
self.tokenStorage.saveToken(token: token)
|
|
||||||
|
|
||||||
// Store user data and verification status
|
if let success = result as? ApiResultSuccess<AuthResponse>,
|
||||||
self.currentUser = user
|
let response = success.data {
|
||||||
self.isVerified = user.verified
|
// APILayer.login already stores token in DataManager
|
||||||
self.isLoading = false
|
// currentUser will be updated via DataManagerObservable observation
|
||||||
|
self.isVerified = response.user.verified
|
||||||
|
self.isLoading = false
|
||||||
|
|
||||||
print("Login successful! Token: token")
|
print("Login successful!")
|
||||||
print("User: \(user.username), Verified: \(user.verified)")
|
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
|
||||||
print("isVerified set to: \(self.isVerified)")
|
|
||||||
|
|
||||||
// Initialize lookups via APILayer
|
// 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 {
|
|
||||||
Task {
|
Task {
|
||||||
_ = try? await APILayer.shared.initializeLookups()
|
_ = try? await APILayer.shared.initializeLookups()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
print("Auth check - User: \(user.username), Verified: \(user.verified)")
|
// Prefetch all data for caching
|
||||||
},
|
Task {
|
||||||
onError: { [weak self] _ in
|
do {
|
||||||
// Token invalid or expired, clear it
|
print("Starting data prefetch...")
|
||||||
self?.tokenStorage.clearToken()
|
let prefetchManager = DataPrefetchManager.Companion().getInstance()
|
||||||
self?.isVerified = false
|
_ = try await prefetchManager.prefetchAllData()
|
||||||
},
|
print("Data prefetch completed successfully")
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() }
|
} 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
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
Task {
|
viewModel.joinWithCode(code: shareCode) { success in
|
||||||
// Call the shared ViewModel which uses APILayer
|
isLoading = false
|
||||||
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
|
if success {
|
||||||
|
onJoined()
|
||||||
// Observe the result
|
} else {
|
||||||
for await state in viewModel.sharedViewModel.joinResidenceState {
|
errorMessage = viewModel.errorMessage
|
||||||
if state is ApiResultSuccess<JoinResidenceResponse> {
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
|
||||||
isLoading = false
|
|
||||||
onJoined()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
} else if let error = state as? ApiResultError {
|
|
||||||
await MainActor.run {
|
|
||||||
errorMessage = ErrorMessageParser.parse(error.message)
|
|
||||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ enum PasswordResetStep: CaseIterable {
|
|||||||
case success // Final: Success confirmation
|
case success // Final: Success confirmation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ViewModel for password reset flow.
|
||||||
|
/// Calls APILayer directly for all password reset operations.
|
||||||
@MainActor
|
@MainActor
|
||||||
class PasswordResetViewModel: ObservableObject {
|
class PasswordResetViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -22,16 +24,8 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
@Published var currentStep: PasswordResetStep = .requestCode
|
@Published var currentStep: PasswordResetStep = .requestCode
|
||||||
@Published var resetToken: String?
|
@Published var resetToken: String?
|
||||||
|
|
||||||
// MARK: - Private Properties
|
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init(resetToken: String? = nil) {
|
||||||
resetToken: String? = nil,
|
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil
|
|
||||||
) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
|
||||||
|
|
||||||
// If we have a reset token from deep link, skip to password reset step
|
// If we have a reset token from deep link, skip to password reset step
|
||||||
if let token = resetToken {
|
if let token = resetToken {
|
||||||
self.resetToken = token
|
self.resetToken = token
|
||||||
@@ -51,27 +45,29 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.forgotPassword(email: email)
|
Task {
|
||||||
|
do {
|
||||||
|
let request = ForgotPasswordRequest(email: email)
|
||||||
|
let result = try await APILayer.shared.forgotPassword(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<ForgotPasswordResponse> {
|
||||||
sharedViewModel.forgotPasswordState,
|
self.isLoading = false
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.successMessage = "Check your email for a 6-digit verification code"
|
||||||
onSuccess: { [weak self] (_: ForgotPasswordResponse) in
|
|
||||||
self?.isLoading = false
|
|
||||||
self?.successMessage = "Check your email for a 6-digit verification code"
|
|
||||||
|
|
||||||
// Automatically move to next step after short delay
|
// Automatically move to next step after short delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
self?.successMessage = nil
|
self.successMessage = nil
|
||||||
self?.currentStep = .verifyCode
|
self.currentStep = .verifyCode
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
onError: { [weak self] error in
|
self.isLoading = false
|
||||||
self?.isLoading = false
|
self.errorMessage = error.localizedDescription
|
||||||
self?.errorMessage = error
|
}
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetForgotPasswordState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 2: Verify reset code
|
/// Step 2: Verify reset code
|
||||||
@@ -84,30 +80,31 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.verifyResetCode(email: email, code: code)
|
Task {
|
||||||
|
do {
|
||||||
|
let request = VerifyResetCodeRequest(email: email, code: code)
|
||||||
|
let result = try await APILayer.shared.verifyResetCode(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if let success = result as? ApiResultSuccess<VerifyResetCodeResponse>, let response = success.data {
|
||||||
sharedViewModel.verifyResetCodeState,
|
let token = response.resetToken
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.resetToken = token
|
||||||
onSuccess: { [weak self] (response: VerifyResetCodeResponse) in
|
self.isLoading = false
|
||||||
guard let self = self else { return }
|
self.successMessage = "Code verified! Now set your new password"
|
||||||
let token = response.resetToken
|
|
||||||
self.resetToken = token
|
|
||||||
self.isLoading = false
|
|
||||||
self.successMessage = "Code verified! Now set your new password"
|
|
||||||
|
|
||||||
// Automatically move to next step after short delay
|
// Automatically move to next step after short delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
self.successMessage = nil
|
self.successMessage = nil
|
||||||
self.currentStep = .resetPassword
|
self.currentStep = .resetPassword
|
||||||
|
}
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.isLoading = false
|
||||||
|
self.handleVerifyError(ErrorMessageParser.parse(error.message))
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
onError: { [weak self] error in
|
self.isLoading = false
|
||||||
self?.isLoading = false
|
self.errorMessage = error.localizedDescription
|
||||||
self?.handleVerifyError(error)
|
}
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetVerifyResetCodeState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 3: Reset password
|
/// Step 3: Reset password
|
||||||
@@ -135,22 +132,27 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.resetPassword(resetToken: token, newPassword: newPassword, confirmPassword: confirmPassword)
|
Task {
|
||||||
|
do {
|
||||||
|
let request = ResetPasswordRequest(
|
||||||
|
resetToken: token,
|
||||||
|
newPassword: newPassword
|
||||||
|
)
|
||||||
|
let result = try await APILayer.shared.resetPassword(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if result is ApiResultSuccess<ResetPasswordResponse> {
|
||||||
sharedViewModel.resetPasswordState,
|
self.isLoading = false
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||||
onSuccess: { [weak self] (_: ResetPasswordResponse) in
|
self.currentStep = .success
|
||||||
self?.isLoading = false
|
} else if let error = result as? ApiResultError {
|
||||||
self?.successMessage = "Password reset successfully! You can now log in with your new password."
|
self.isLoading = false
|
||||||
self?.currentStep = .success
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
},
|
}
|
||||||
onError: { [weak self] error in
|
} catch {
|
||||||
self?.isLoading = false
|
self.isLoading = false
|
||||||
self?.errorMessage = error
|
self.errorMessage = error.localizedDescription
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetResetPasswordState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to next step
|
/// Navigate to next step
|
||||||
|
|||||||
@@ -228,48 +228,30 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var isSaving: Bool = false
|
@Published var isSaving: Bool = false
|
||||||
|
|
||||||
private let sharedViewModel = ComposeApp.NotificationPreferencesViewModel()
|
|
||||||
private var preferencesTask: Task<Void, Never>?
|
|
||||||
private var updateTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
func loadPreferences() {
|
func loadPreferences() {
|
||||||
preferencesTask?.cancel()
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadPreferences()
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.getNotificationPreferences()
|
||||||
|
|
||||||
preferencesTask = Task {
|
if let success = result as? ApiResultSuccess<NotificationPreference>, let prefs = success.data {
|
||||||
for await state in sharedViewModel.preferencesState {
|
self.taskDueSoon = prefs.taskDueSoon
|
||||||
if Task.isCancelled { break }
|
self.taskOverdue = prefs.taskOverdue
|
||||||
|
self.taskCompleted = prefs.taskCompleted
|
||||||
await MainActor.run {
|
self.taskAssigned = prefs.taskAssigned
|
||||||
switch state {
|
self.residenceShared = prefs.residenceShared
|
||||||
case let success as ApiResultSuccess<NotificationPreference>:
|
self.warrantyExpiring = prefs.warrantyExpiring
|
||||||
if let prefs = success.data {
|
self.isLoading = false
|
||||||
self.taskDueSoon = prefs.taskDueSoon
|
self.errorMessage = nil
|
||||||
self.taskOverdue = prefs.taskOverdue
|
} else if let error = result as? ApiResultError {
|
||||||
self.taskCompleted = prefs.taskCompleted
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.taskAssigned = prefs.taskAssigned
|
self.isLoading = false
|
||||||
self.residenceShared = prefs.residenceShared
|
|
||||||
self.warrantyExpiring = prefs.warrantyExpiring
|
|
||||||
}
|
|
||||||
self.isLoading = false
|
|
||||||
self.errorMessage = nil
|
|
||||||
case let error as ApiResultError:
|
|
||||||
self.errorMessage = error.message
|
|
||||||
self.isLoading = false
|
|
||||||
case is ApiResultLoading:
|
|
||||||
self.isLoading = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Break after success or error
|
|
||||||
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,50 +264,32 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
|
|||||||
residenceShared: Bool? = nil,
|
residenceShared: Bool? = nil,
|
||||||
warrantyExpiring: Bool? = nil
|
warrantyExpiring: Bool? = nil
|
||||||
) {
|
) {
|
||||||
updateTask?.cancel()
|
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
|
||||||
sharedViewModel.updatePreference(
|
Task {
|
||||||
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
|
do {
|
||||||
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
|
let request = UpdateNotificationPreferencesRequest(
|
||||||
taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) },
|
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
|
||||||
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
|
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
|
||||||
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
|
taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) },
|
||||||
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) }
|
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
|
||||||
)
|
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
|
||||||
|
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) }
|
||||||
|
)
|
||||||
|
let result = try await APILayer.shared.updateNotificationPreferences(request: request)
|
||||||
|
|
||||||
updateTask = Task {
|
if result is ApiResultSuccess<NotificationPreference> {
|
||||||
for await state in sharedViewModel.updateState {
|
self.isSaving = false
|
||||||
if Task.isCancelled { break }
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
await MainActor.run {
|
self.isSaving = false
|
||||||
switch state {
|
|
||||||
case is ApiResultSuccess<NotificationPreference>:
|
|
||||||
self.isSaving = false
|
|
||||||
self.sharedViewModel.resetUpdateState()
|
|
||||||
case let error as ApiResultError:
|
|
||||||
self.errorMessage = error.message
|
|
||||||
self.isSaving = false
|
|
||||||
self.sharedViewModel.resetUpdateState()
|
|
||||||
case is ApiResultLoading:
|
|
||||||
self.isSaving = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Break after success or error
|
|
||||||
if state is ApiResultSuccess<NotificationPreference> || state is ApiResultError {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isSaving = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
preferencesTask?.cancel()
|
|
||||||
updateTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for user profile management.
|
||||||
|
/// Observes DataManagerObservable for current user.
|
||||||
|
/// Calls APILayer directly for profile updates.
|
||||||
@MainActor
|
@MainActor
|
||||||
class ProfileViewModel: ObservableObject {
|
class ProfileViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -14,17 +17,26 @@ class ProfileViewModel: ObservableObject {
|
|||||||
@Published var successMessage: String?
|
@Published var successMessage: String?
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
private let tokenStorage: TokenStorageProtocol
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init(tokenStorage: TokenStorageProtocol? = nil) {
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil,
|
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
|
||||||
) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||||
|
|
||||||
|
// Observe current user from DataManagerObservable
|
||||||
|
DataManagerObservable.shared.$currentUser
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] user in
|
||||||
|
if let user = user {
|
||||||
|
self?.firstName = user.firstName ?? ""
|
||||||
|
self?.lastName = user.lastName ?? ""
|
||||||
|
self?.email = user.email
|
||||||
|
self?.isLoadingUser = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Load current user data
|
// Load current user data
|
||||||
loadCurrentUser()
|
loadCurrentUser()
|
||||||
}
|
}
|
||||||
@@ -37,27 +49,32 @@ class ProfileViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we already have user data
|
||||||
|
if DataManagerObservable.shared.currentUser != nil {
|
||||||
|
isLoadingUser = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isLoadingUser = true
|
isLoadingUser = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.getCurrentUser(forceRefresh: false)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.getCurrentUser(forceRefresh: false)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
// DataManager is updated by APILayer, UI updates via Combine observation
|
||||||
sharedViewModel.currentUserState,
|
if result is ApiResultSuccess<User> {
|
||||||
onLoading: { [weak self] in self?.isLoadingUser = true },
|
self.isLoadingUser = false
|
||||||
onSuccess: { [weak self] (user: User) in
|
self.errorMessage = nil
|
||||||
self?.firstName = user.firstName ?? ""
|
} else if let error = result as? ApiResultError {
|
||||||
self?.lastName = user.lastName ?? ""
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.email = user.email
|
self.isLoadingUser = false
|
||||||
self?.isLoadingUser = false
|
}
|
||||||
self?.errorMessage = nil
|
} catch {
|
||||||
},
|
self.errorMessage = error.localizedDescription
|
||||||
onError: { [weak self] error in
|
self.isLoadingUser = false
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.isLoadingUser = false
|
}
|
||||||
},
|
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateProfile() {
|
func updateProfile() {
|
||||||
@@ -66,7 +83,7 @@ class ProfileViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard tokenStorage.getToken() != nil else {
|
guard let token = tokenStorage.getToken() else {
|
||||||
errorMessage = "Not authenticated"
|
errorMessage = "Not authenticated"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,31 +92,31 @@ class ProfileViewModel: ObservableObject {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
successMessage = nil
|
successMessage = nil
|
||||||
|
|
||||||
sharedViewModel.updateProfile(
|
Task {
|
||||||
firstName: firstName.isEmpty ? nil : firstName,
|
do {
|
||||||
lastName: lastName.isEmpty ? nil : lastName,
|
let request = UpdateProfileRequest(
|
||||||
email: email
|
firstName: firstName.isEmpty ? nil : firstName,
|
||||||
)
|
lastName: lastName.isEmpty ? nil : lastName,
|
||||||
|
email: email
|
||||||
|
)
|
||||||
|
let result = try await APILayer.shared.updateProfile(token: token, request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
// DataManager is updated by APILayer, UI updates via Combine observation
|
||||||
sharedViewModel.updateProfileState,
|
if result is ApiResultSuccess<User> {
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.isLoading = false
|
||||||
onSuccess: { [weak self] (user: User) in
|
self.errorMessage = nil
|
||||||
self?.firstName = user.firstName ?? ""
|
self.successMessage = "Profile updated successfully"
|
||||||
self?.lastName = user.lastName ?? ""
|
} else if let error = result as? ApiResultError {
|
||||||
self?.email = user.email
|
self.isLoading = false
|
||||||
self?.isLoading = false
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.errorMessage = nil
|
self.successMessage = nil
|
||||||
self?.successMessage = "Profile updated successfully"
|
}
|
||||||
print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")")
|
} catch {
|
||||||
},
|
self.isLoading = false
|
||||||
onError: { [weak self] error in
|
self.errorMessage = error.localizedDescription
|
||||||
self?.isLoading = false
|
self.successMessage = nil
|
||||||
self?.errorMessage = error
|
}
|
||||||
self?.successMessage = nil
|
}
|
||||||
},
|
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetUpdateProfileState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearMessages() {
|
func clearMessages() {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for user registration.
|
||||||
|
/// Calls APILayer directly for registration.
|
||||||
@MainActor
|
@MainActor
|
||||||
class RegisterViewModel: ObservableObject {
|
class RegisterViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -14,15 +16,10 @@ class RegisterViewModel: ObservableObject {
|
|||||||
@Published var isRegistered: Bool = false
|
@Published var isRegistered: Bool = false
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
private let tokenStorage: TokenStorageProtocol
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init(tokenStorage: TokenStorageProtocol? = nil) {
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil,
|
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
|
||||||
) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,33 +49,32 @@ class RegisterViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.register(username: username, email: email, password: password)
|
Task {
|
||||||
|
do {
|
||||||
|
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
|
||||||
|
let result = try await APILayer.shared.register(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
|
||||||
sharedViewModel.registerState,
|
let token = response.token
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
self.tokenStorage.saveToken(token: token)
|
||||||
onSuccess: { [weak self] (response: AuthResponse) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let token = response.token
|
|
||||||
self.tokenStorage.saveToken(token: token)
|
|
||||||
|
|
||||||
// Update AuthenticationManager - user is authenticated but NOT verified
|
// Update AuthenticationManager - user is authenticated but NOT verified
|
||||||
AuthenticationManager.shared.login(verified: false)
|
AuthenticationManager.shared.login(verified: false)
|
||||||
|
|
||||||
// Initialize lookups via APILayer after successful registration
|
// Initialize lookups via APILayer after successful registration
|
||||||
Task {
|
|
||||||
_ = try? await APILayer.shared.initializeLookups()
|
_ = try? await APILayer.shared.initializeLookups()
|
||||||
}
|
|
||||||
|
|
||||||
self.isRegistered = true
|
self.isRegistered = true
|
||||||
|
self.isLoading = false
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
},
|
}
|
||||||
onError: { [weak self] error in
|
}
|
||||||
self?.errorMessage = error
|
|
||||||
self?.isLoading = false
|
|
||||||
},
|
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetRegisterState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
|
|||||||
@@ -80,27 +80,12 @@ struct JoinResidenceView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
viewModel.joinWithCode(code: shareCode) { success in
|
||||||
// Call the shared ViewModel which uses APILayer
|
if success {
|
||||||
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
|
onJoined()
|
||||||
|
dismiss()
|
||||||
// Observe the result
|
|
||||||
for await state in viewModel.sharedViewModel.joinResidenceState {
|
|
||||||
if state is ApiResultSuccess<JoinResidenceResponse> {
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
|
||||||
onJoined()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
} else if let error = state as? ApiResultError {
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.errorMessage = ErrorMessageParser.parse(error.message)
|
|
||||||
viewModel.sharedViewModel.resetJoinResidenceState()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Error is handled by ViewModel and displayed via viewModel.errorMessage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for residence management.
|
||||||
|
/// Observes DataManagerObservable for cached data.
|
||||||
|
/// Kicks off API calls that update DataManager, letting views react to cache updates.
|
||||||
@MainActor
|
@MainActor
|
||||||
class ResidenceViewModel: ObservableObject {
|
class ResidenceViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties (from DataManager observation)
|
||||||
@Published var residenceSummary: ResidenceSummaryResponse?
|
|
||||||
@Published var myResidences: MyResidencesResponse?
|
@Published var myResidences: MyResidencesResponse?
|
||||||
|
@Published var residences: [ResidenceResponse] = []
|
||||||
|
@Published var totalSummary: TotalSummary?
|
||||||
|
|
||||||
|
// MARK: - Local State
|
||||||
@Published var selectedResidence: ResidenceResponse?
|
@Published var selectedResidence: ResidenceResponse?
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@@ -14,57 +20,105 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
@Published var reportMessage: String?
|
@Published var reportMessage: String?
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
public let sharedViewModel: ComposeApp.ResidenceViewModel
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private let tokenStorage: TokenStorageProtocol
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init() {
|
||||||
sharedViewModel: ComposeApp.ResidenceViewModel? = nil,
|
// Observe DataManagerObservable for residence data
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
DataManagerObservable.shared.$myResidences
|
||||||
) {
|
.receive(on: DispatchQueue.main)
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel()
|
.sink { [weak self] myResidences in
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
self?.myResidences = myResidences
|
||||||
|
// Clear loading state when data arrives
|
||||||
|
if myResidences != nil {
|
||||||
|
self?.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
DataManagerObservable.shared.$residences
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] residences in
|
||||||
|
self?.residences = residences
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
DataManagerObservable.shared.$totalSummary
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] summary in
|
||||||
|
self?.totalSummary = summary
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
func loadResidenceSummary() {
|
|
||||||
isLoading = true
|
/// Load summary - kicks off API call that updates DataManager
|
||||||
|
func loadSummary(forceRefresh: Bool = false) {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadResidenceSummary()
|
// Check if we have cached data and don't need to refresh
|
||||||
|
if !forceRefresh && totalSummary != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
StateFlowObserver.observeWithState(
|
isLoading = true
|
||||||
sharedViewModel.residenceSummaryState,
|
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
// Kick off API call - DataManager will be updated, which updates DataManagerObservable
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
Task {
|
||||||
onSuccess: { [weak self] (data: ResidenceSummaryResponse) in
|
do {
|
||||||
self?.residenceSummary = data
|
let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh)
|
||||||
|
|
||||||
|
// Only handle errors - success updates DataManager automatically
|
||||||
|
if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
}
|
||||||
|
self.isLoading = false
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load my residences - checks cache first, then fetches if needed
|
||||||
func loadMyResidences(forceRefresh: Bool = false) {
|
func loadMyResidences(forceRefresh: Bool = false) {
|
||||||
isLoading = true
|
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.loadMyResidences(forceRefresh: forceRefresh)
|
// Check if we have cached data and don't need to refresh
|
||||||
|
if !forceRefresh && DataManagerObservable.shared.myResidences != nil {
|
||||||
|
// Data already available via observation, no API call needed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
StateFlowObserver.observeWithState(
|
isLoading = true
|
||||||
sharedViewModel.myResidencesState,
|
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
// which updates our @Published myResidences via the sink above
|
||||||
onSuccess: { [weak self] (data: MyResidencesResponse) in
|
Task {
|
||||||
self?.myResidences = data
|
do {
|
||||||
|
let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh)
|
||||||
|
|
||||||
|
// Only handle errors - success updates DataManager automatically
|
||||||
|
if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getResidence(id: Int32) {
|
func getResidence(id: Int32) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.getResidence(id: id) { result in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
|
let result = try await APILayer.shared.getResidence(id: id, forceRefresh: false)
|
||||||
|
|
||||||
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||||
self.selectedResidence = success.data
|
self.selectedResidence = success.data
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
@@ -72,6 +126,9 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,56 +137,77 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.createResidence(request: request)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.createResidence(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observeWithCompletion(
|
if result is ApiResultSuccess<ResidenceResponse> {
|
||||||
sharedViewModel.createResidenceState,
|
self.isLoading = false
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
// DataManager is updated by APILayer (including refreshMyResidences),
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
// which updates DataManagerObservable, which updates our @Published
|
||||||
completion: completion,
|
// myResidences via Combine subscription
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
|
completion(true)
|
||||||
)
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.updateResidence(residenceId: id, request: request)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.updateResidence(id: id, request: request)
|
||||||
|
|
||||||
StateFlowObserver.observeWithCompletion(
|
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||||
sharedViewModel.updateResidenceState,
|
self.selectedResidence = success.data
|
||||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
self.isLoading = false
|
||||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
// DataManager is updated by APILayer (including refreshMyResidences),
|
||||||
onSuccess: { [weak self] (data: ResidenceResponse) in
|
// which updates DataManagerObservable, which updates our @Published
|
||||||
self?.selectedResidence = data
|
// myResidences via Combine subscription
|
||||||
},
|
completion(true)
|
||||||
completion: completion,
|
} else if let error = result as? ApiResultError {
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
)
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateTasksReport(residenceId: Int32, email: String? = nil) {
|
func generateTasksReport(residenceId: Int32, email: String? = nil) {
|
||||||
isGeneratingReport = true
|
isGeneratingReport = true
|
||||||
reportMessage = nil
|
reportMessage = nil
|
||||||
|
|
||||||
sharedViewModel.generateTasksReport(residenceId: residenceId, email: email)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
|
||||||
sharedViewModel.generateReportState,
|
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
|
||||||
onLoading: { [weak self] in
|
self.isGeneratingReport = false
|
||||||
self?.isGeneratingReport = true
|
} else if let error = result as? ApiResultError {
|
||||||
},
|
self.reportMessage = ErrorMessageParser.parse(error.message)
|
||||||
onSuccess: { [weak self] (response: GenerateReportResponse) in
|
self.isGeneratingReport = false
|
||||||
self?.reportMessage = response.message ?? "Report generated, but no message returned."
|
}
|
||||||
self?.isGeneratingReport = false
|
} catch {
|
||||||
},
|
self.reportMessage = error.localizedDescription
|
||||||
onError: { [weak self] error in
|
self.isGeneratingReport = false
|
||||||
self?.reportMessage = error
|
}
|
||||||
self?.isGeneratingReport = false
|
}
|
||||||
},
|
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetGenerateReportState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
@@ -137,6 +215,34 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadResidenceContractors(residenceId: Int32) {
|
func loadResidenceContractors(residenceId: Int32) {
|
||||||
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
|
// This can now be handled directly via APILayer if needed
|
||||||
|
// or through DataManagerObservable.shared.contractors
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.joinWithCode(code: code)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<JoinResidenceResponse> {
|
||||||
|
self.isLoading = false
|
||||||
|
// APILayer updates DataManager with refreshMyResidences,
|
||||||
|
// which updates DataManagerObservable, which updates our
|
||||||
|
// @Published myResidences via Combine subscription
|
||||||
|
completion(true)
|
||||||
|
} else if let error = result as? ApiResultError {
|
||||||
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isLoading = false
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct ResidencesListView: View {
|
|||||||
errorMessage: viewModel.errorMessage,
|
errorMessage: viewModel.errorMessage,
|
||||||
content: { residences in
|
content: { residences in
|
||||||
ResidencesContent(
|
ResidencesContent(
|
||||||
response: response,
|
summary: viewModel.totalSummary ?? response.summary,
|
||||||
residences: residences
|
residences: residences
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -120,14 +120,14 @@ struct ResidencesListView: View {
|
|||||||
// MARK: - Residences Content View
|
// MARK: - Residences Content View
|
||||||
|
|
||||||
private struct ResidencesContent: View {
|
private struct ResidencesContent: View {
|
||||||
let response: MyResidencesResponse
|
let summary: TotalSummary
|
||||||
let residences: [ResidenceResponse]
|
let residences: [ResidenceResponse]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: AppSpacing.lg) {
|
VStack(spacing: AppSpacing.lg) {
|
||||||
// Summary Card
|
// Summary Card
|
||||||
SummaryCard(summary: response.summary)
|
SummaryCard(summary: summary)
|
||||||
.padding(.horizontal, AppSpacing.md)
|
.padding(.horizontal, AppSpacing.md)
|
||||||
.padding(.top, AppSpacing.sm)
|
.padding(.top, AppSpacing.sm)
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ class AuthenticationManager: ObservableObject {
|
|||||||
@Published var isAuthenticated: Bool = false
|
@Published var isAuthenticated: Bool = false
|
||||||
@Published var isVerified: Bool = false
|
@Published var isVerified: Bool = false
|
||||||
@Published var isCheckingAuth: Bool = true
|
@Published var isCheckingAuth: Bool = true
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.sharedViewModel = ComposeApp.AuthViewModel()
|
|
||||||
checkAuthenticationStatus()
|
checkAuthenticationStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +83,10 @@ class AuthenticationManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
// Call shared ViewModel logout which clears DataManager
|
// Call APILayer logout which clears DataManager
|
||||||
sharedViewModel.logout()
|
Task {
|
||||||
|
_ = try? await APILayer.shared.logout()
|
||||||
|
}
|
||||||
|
|
||||||
// Clear widget task data
|
// Clear widget task data
|
||||||
WidgetDataManager.shared.clearCache()
|
WidgetDataManager.shared.clearCache()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class StoreKitManager: ObservableObject {
|
|||||||
@Published var purchaseError: String?
|
@Published var purchaseError: String?
|
||||||
|
|
||||||
private var transactionListener: Task<Void, Error>?
|
private var transactionListener: Task<Void, Error>?
|
||||||
private let subscriptionApi = SubscriptionApi(client: ApiClient.shared.httpClient)
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Start listening for transactions
|
// Start listening for transactions
|
||||||
@@ -173,13 +172,8 @@ class StoreKitManager: ObservableObject {
|
|||||||
|
|
||||||
/// Fetch latest subscription status from backend and update cache
|
/// Fetch latest subscription status from backend and update cache
|
||||||
private func refreshSubscriptionFromBackend() async {
|
private func refreshSubscriptionFromBackend() async {
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
print("⚠️ StoreKit: No auth token, skipping backend status refresh")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token)
|
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
|
||||||
|
|
||||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||||
let subscription = statusSuccess.data {
|
let subscription = statusSuccess.data {
|
||||||
@@ -242,18 +236,11 @@ class StoreKitManager: ObservableObject {
|
|||||||
/// Verify transaction with backend API
|
/// Verify transaction with backend API
|
||||||
private func verifyTransactionWithBackend(_ transaction: Transaction) async {
|
private func verifyTransactionWithBackend(_ transaction: Transaction) async {
|
||||||
do {
|
do {
|
||||||
// Get auth token
|
|
||||||
guard let token = TokenStorage.shared.getToken() else {
|
|
||||||
print("⚠️ StoreKit: No auth token, skipping backend verification")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get transaction receipt data
|
// Get transaction receipt data
|
||||||
let receiptData = String(transaction.id)
|
let receiptData = String(transaction.id)
|
||||||
|
|
||||||
// Call backend verification endpoint
|
// Call backend verification endpoint via APILayer
|
||||||
let result = try await subscriptionApi.verifyIOSReceipt(
|
let result = try await APILayer.shared.verifyIOSReceipt(
|
||||||
token: token,
|
|
||||||
receiptData: receiptData,
|
receiptData: receiptData,
|
||||||
transactionId: String(transaction.id)
|
transactionId: String(transaction.id)
|
||||||
)
|
)
|
||||||
@@ -264,8 +251,8 @@ class StoreKitManager: ObservableObject {
|
|||||||
response.success {
|
response.success {
|
||||||
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
|
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
|
||||||
|
|
||||||
// Fetch updated subscription status from backend
|
// Fetch updated subscription status from backend via APILayer
|
||||||
let statusResult = try await subscriptionApi.getSubscriptionStatus(token: token)
|
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
|
||||||
|
|
||||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||||
let subscription = statusSuccess.data {
|
let subscription = statusSuccess.data {
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for task management.
|
||||||
|
/// Observes DataManagerObservable for cached data.
|
||||||
|
/// Calls APILayer directly for all operations.
|
||||||
@MainActor
|
@MainActor
|
||||||
class TaskViewModel: ObservableObject {
|
class TaskViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties (from DataManager observation)
|
||||||
|
@Published var tasksResponse: TaskColumnsResponse?
|
||||||
|
|
||||||
|
// MARK: - Local State
|
||||||
@Published var actionState: ActionState<TaskActionType> = .idle
|
@Published var actionState: ActionState<TaskActionType> = .idle
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@Published var completions: [TaskCompletionResponse] = []
|
@Published var completions: [TaskCompletionResponse] = []
|
||||||
@Published var isLoadingCompletions: Bool = false
|
@Published var isLoadingCompletions: Bool = false
|
||||||
@Published var completionsError: String?
|
@Published var completionsError: String?
|
||||||
|
|
||||||
// MARK: - Kanban Board State (shared across views)
|
|
||||||
@Published var tasksResponse: TaskColumnsResponse?
|
|
||||||
@Published var isLoadingTasks: Bool = false
|
@Published var isLoadingTasks: Bool = false
|
||||||
@Published var tasksError: String?
|
@Published var tasksError: String?
|
||||||
|
|
||||||
@@ -31,11 +34,36 @@ class TaskViewModel: ObservableObject {
|
|||||||
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
|
var taskUnarchived: Bool { actionState.isSuccess(.unarchive) }
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let sharedViewModel: ComposeApp.TaskViewModel
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(sharedViewModel: ComposeApp.TaskViewModel? = nil) {
|
init() {
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel()
|
// Observe DataManagerObservable for all tasks data
|
||||||
|
DataManagerObservable.shared.$allTasks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] allTasks in
|
||||||
|
// Only update if we're showing all tasks (no residence filter)
|
||||||
|
if self?.currentResidenceId == nil {
|
||||||
|
self?.tasksResponse = allTasks
|
||||||
|
if allTasks != nil {
|
||||||
|
self?.isLoadingTasks = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe tasks by residence
|
||||||
|
DataManagerObservable.shared.$tasksByResidence
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] tasksByResidence in
|
||||||
|
// Only update if we're filtering by residence
|
||||||
|
if let resId = self?.currentResidenceId,
|
||||||
|
let tasks = tasksByResidence[resId] {
|
||||||
|
self?.tasksResponse = tasks
|
||||||
|
self?.isLoadingTasks = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
@@ -43,42 +71,48 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.create)
|
actionState = .loading(.create)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.createNewTask(request: request)
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await APILayer.shared.createTask(request: request)
|
||||||
|
|
||||||
StateFlowObserver.observeWithCompletion(
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
sharedViewModel.taskAddNewCustomTaskState,
|
self.actionState = .success(.create)
|
||||||
loadingSetter: { [weak self] loading in
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
if loading { self?.actionState = .loading(.create) }
|
completion(true)
|
||||||
},
|
} else if let error = result as? ApiResultError {
|
||||||
errorSetter: { [weak self] error in
|
self.actionState = .error(.create, ErrorMessageParser.parse(error.message))
|
||||||
if let error = error {
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self?.actionState = .error(.create, error)
|
completion(false)
|
||||||
self?.errorMessage = error
|
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
onSuccess: { [weak self] (_: TaskResponse) in
|
self.actionState = .error(.create, error.localizedDescription)
|
||||||
self?.actionState = .success(.create)
|
self.errorMessage = error.localizedDescription
|
||||||
},
|
completion(false)
|
||||||
completion: completion,
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() }
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||||
actionState = .loading(.cancel)
|
actionState = .loading(.cancel)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.cancelTask(taskId: id) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.cancelTask(taskId: id)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.cancel)
|
self.actionState = .success(.cancel)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to cancel task"
|
self.actionState = .error(.cancel, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.cancel, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.cancel, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,17 +121,23 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.uncancel)
|
actionState = .loading(.uncancel)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.uncancelTask(taskId: id) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.uncancelTask(taskId: id)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.uncancel)
|
self.actionState = .success(.uncancel)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to uncancel task"
|
self.actionState = .error(.uncancel, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.uncancel, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.uncancel, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,17 +146,23 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.markInProgress)
|
actionState = .loading(.markInProgress)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.markInProgress(taskId: id) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.markInProgress(taskId: id)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.markInProgress)
|
self.actionState = .success(.markInProgress)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to mark task in progress"
|
self.actionState = .error(.markInProgress, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.markInProgress, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.markInProgress, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,17 +171,23 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.archive)
|
actionState = .loading(.archive)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.archiveTask(taskId: id) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.archiveTask(taskId: id)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.archive)
|
self.actionState = .success(.archive)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to archive task"
|
self.actionState = .error(.archive, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.archive, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.archive, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,17 +196,23 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.unarchive)
|
actionState = .loading(.unarchive)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.unarchiveTask(taskId: id) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.unarchiveTask(taskId: id)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.unarchive)
|
self.actionState = .success(.unarchive)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to unarchive task"
|
self.actionState = .error(.unarchive, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.unarchive, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.unarchive, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,17 +221,23 @@ class TaskViewModel: ObservableObject {
|
|||||||
actionState = .loading(.update)
|
actionState = .loading(.update)
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.updateTask(taskId: id, request: request) { success in
|
Task {
|
||||||
Task { @MainActor in
|
do {
|
||||||
if success.boolValue {
|
let result = try await APILayer.shared.updateTask(id: id, request: request)
|
||||||
|
|
||||||
|
if result is ApiResultSuccess<TaskResponse> {
|
||||||
self.actionState = .success(.update)
|
self.actionState = .success(.update)
|
||||||
|
// DataManager is updated by APILayer, view updates via observation
|
||||||
completion(true)
|
completion(true)
|
||||||
} else {
|
} else if let error = result as? ApiResultError {
|
||||||
let errorMsg = "Failed to update task"
|
self.actionState = .error(.update, ErrorMessageParser.parse(error.message))
|
||||||
self.actionState = .error(.update, errorMsg)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.errorMessage = errorMsg
|
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.actionState = .error(.update, error.localizedDescription)
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,27 +260,20 @@ class TaskViewModel: ObservableObject {
|
|||||||
isLoadingCompletions = true
|
isLoadingCompletions = true
|
||||||
completionsError = nil
|
completionsError = nil
|
||||||
|
|
||||||
sharedViewModel.loadTaskCompletions(taskId: taskId)
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
for await state in sharedViewModel.taskCompletionsState {
|
do {
|
||||||
if let success = state as? ApiResultSuccess<NSArray> {
|
let result = try await APILayer.shared.getTaskCompletions(taskId: taskId)
|
||||||
await MainActor.run {
|
|
||||||
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
|
if let success = result as? ApiResultSuccess<NSArray> {
|
||||||
self.isLoadingCompletions = false
|
self.completions = (success.data as? [TaskCompletionResponse]) ?? []
|
||||||
}
|
self.isLoadingCompletions = false
|
||||||
break
|
} else if let error = result as? ApiResultError {
|
||||||
} else if let error = state as? ApiResultError {
|
self.completionsError = ErrorMessageParser.parse(error.message)
|
||||||
await MainActor.run {
|
self.isLoadingCompletions = false
|
||||||
self.completionsError = error.message
|
|
||||||
self.isLoadingCompletions = false
|
|
||||||
}
|
|
||||||
break
|
|
||||||
} else if state is ApiResultLoading {
|
|
||||||
await MainActor.run {
|
|
||||||
self.isLoadingCompletions = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
self.completionsError = error.localizedDescription
|
||||||
|
self.isLoadingCompletions = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +282,6 @@ class TaskViewModel: ObservableObject {
|
|||||||
completions = []
|
completions = []
|
||||||
completionsError = nil
|
completionsError = nil
|
||||||
isLoadingCompletions = false
|
isLoadingCompletions = false
|
||||||
sharedViewModel.resetTaskCompletionsState()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Kanban Board Methods
|
// MARK: - Kanban Board Methods
|
||||||
@@ -248,6 +304,7 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load tasks - either all tasks or filtered by residence
|
/// Load tasks - either all tasks or filtered by residence
|
||||||
|
/// Checks cache first, then fetches if needed.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
||||||
/// - forceRefresh: Whether to bypass cache
|
/// - forceRefresh: Whether to bypass cache
|
||||||
@@ -255,9 +312,25 @@ class TaskViewModel: ObservableObject {
|
|||||||
guard DataManager.shared.isAuthenticated() else { return }
|
guard DataManager.shared.isAuthenticated() else { return }
|
||||||
|
|
||||||
currentResidenceId = residenceId
|
currentResidenceId = residenceId
|
||||||
isLoadingTasks = true
|
|
||||||
tasksError = nil
|
tasksError = nil
|
||||||
|
|
||||||
|
// Check if we have cached data and don't need to refresh
|
||||||
|
if !forceRefresh {
|
||||||
|
if let resId = residenceId {
|
||||||
|
if DataManagerObservable.shared.tasksByResidence[resId] != nil {
|
||||||
|
// Data already available via observation, no API call needed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if DataManagerObservable.shared.allTasks != nil {
|
||||||
|
// Data already available via observation, no API call needed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingTasks = true
|
||||||
|
|
||||||
|
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
|
||||||
|
// which updates our @Published tasksResponse via the sink above
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result: Any
|
let result: Any
|
||||||
@@ -270,17 +343,17 @@ class TaskViewModel: ObservableObject {
|
|||||||
result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh)
|
result = try await APILayer.shared.getTasks(forceRefresh: forceRefresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle all result states
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
|
if let success = result as? ApiResultSuccess<TaskColumnsResponse>,
|
||||||
let data = success.data {
|
let data = success.data {
|
||||||
self.tasksResponse = data
|
|
||||||
self.isLoadingTasks = false
|
|
||||||
self.tasksError = nil
|
|
||||||
|
|
||||||
// Update widget data if loading all tasks
|
// Update widget data if loading all tasks
|
||||||
if residenceId == nil {
|
if residenceId == nil {
|
||||||
WidgetDataManager.shared.saveTasks(from: data)
|
WidgetDataManager.shared.saveTasks(from: data)
|
||||||
}
|
}
|
||||||
|
// tasksResponse is updated via DataManagerObservable observation
|
||||||
|
// Ensure loading state is cleared on success
|
||||||
|
self.isLoadingTasks = false
|
||||||
} else if let error = result as? ApiResultError {
|
} else if let error = result as? ApiResultError {
|
||||||
self.tasksError = error.message
|
self.tasksError = error.message
|
||||||
self.isLoadingTasks = false
|
self.isLoadingTasks = false
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import Foundation
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// ViewModel for email verification.
|
||||||
|
/// Calls APILayer directly for verification.
|
||||||
@MainActor
|
@MainActor
|
||||||
class VerifyEmailViewModel: ObservableObject {
|
class VerifyEmailViewModel: ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@@ -11,15 +13,10 @@ class VerifyEmailViewModel: ObservableObject {
|
|||||||
@Published var isVerified: Bool = false
|
@Published var isVerified: Bool = false
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
private let sharedViewModel: ComposeApp.AuthViewModel
|
|
||||||
private let tokenStorage: TokenStorageProtocol
|
private let tokenStorage: TokenStorageProtocol
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(
|
init(tokenStorage: TokenStorageProtocol? = nil) {
|
||||||
sharedViewModel: ComposeApp.AuthViewModel? = nil,
|
|
||||||
tokenStorage: TokenStorageProtocol? = nil
|
|
||||||
) {
|
|
||||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
|
|
||||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +28,7 @@ class VerifyEmailViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard tokenStorage.getToken() != nil else {
|
guard let token = tokenStorage.getToken() else {
|
||||||
errorMessage = "Not authenticated"
|
errorMessage = "Not authenticated"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,29 +36,31 @@ class VerifyEmailViewModel: ObservableObject {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
sharedViewModel.verifyEmail(code: code)
|
Task {
|
||||||
|
do {
|
||||||
|
let request = VerifyEmailRequest(code: code)
|
||||||
|
let result = try await APILayer.shared.verifyEmail(token: token, request: request)
|
||||||
|
|
||||||
StateFlowObserver.observe(
|
if let success = result as? ApiResultSuccess<VerifyEmailResponse>, let response = success.data {
|
||||||
sharedViewModel.verifyEmailState,
|
print("VerifyEmailViewModel: onSuccess called, verified=\(response.verified)")
|
||||||
onLoading: { [weak self] in self?.isLoading = true },
|
if response.verified {
|
||||||
onSuccess: { [weak self] (response: VerifyEmailResponse) in
|
print("VerifyEmailViewModel: Setting isVerified = true")
|
||||||
print("🏠 VerifyEmailViewModel: onSuccess called, verified=\(response.verified)")
|
self.isVerified = true
|
||||||
if response.verified {
|
self.isLoading = false
|
||||||
print("🏠 VerifyEmailViewModel: Setting isVerified = true")
|
print("VerifyEmailViewModel: isVerified is now \(self.isVerified)")
|
||||||
self?.isVerified = true
|
} else {
|
||||||
self?.isLoading = false
|
self.errorMessage = "Verification failed"
|
||||||
print("🏠 VerifyEmailViewModel: isVerified is now \(self?.isVerified ?? false)")
|
self.isLoading = false
|
||||||
} else {
|
}
|
||||||
self?.errorMessage = "Verification failed"
|
} else if let error = result as? ApiResultError {
|
||||||
self?.isLoading = false
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
},
|
} catch {
|
||||||
onError: { [weak self] error in
|
self.errorMessage = error.localizedDescription
|
||||||
self?.errorMessage = error
|
self.isLoading = false
|
||||||
self?.isLoading = false
|
}
|
||||||
},
|
}
|
||||||
resetState: { [weak self] in self?.sharedViewModel.resetVerifyEmailState() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
|
|||||||
Reference in New Issue
Block a user