Add 1-hour cache timeout and fix pull-to-refresh across iOS
- Add configurable cache timeout (CACHE_TIMEOUT_MS) to DataManager - Fix cache to work with empty results (contractors, documents, residences) - Change Documents/Warranties view to use client-side filtering for cache efficiency - Add pull-to-refresh support for empty state views in ListAsyncContentView - Fix ContractorsListView to pass forceRefresh parameter correctly - Fix TaskViewModel loading spinner not stopping after refresh completes - Remove duplicate cache checks in iOS ViewModels, delegate to Kotlin APILayer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,47 @@ import kotlin.time.ExperimentalTime
|
||||
*/
|
||||
object DataManager {
|
||||
|
||||
// ==================== CACHE CONFIGURATION ====================
|
||||
|
||||
/**
|
||||
* Cache timeout in milliseconds.
|
||||
* Data older than this will be refreshed from the API.
|
||||
* Default: 1 hour (3600000ms)
|
||||
*/
|
||||
const val CACHE_TIMEOUT_MS: Long = 60 * 60 * 1000L // 1 hour
|
||||
|
||||
// Cache timestamps for each data type (epoch milliseconds)
|
||||
var residencesCacheTime: Long = 0L
|
||||
private set
|
||||
var myResidencesCacheTime: Long = 0L
|
||||
private set
|
||||
var tasksCacheTime: Long = 0L
|
||||
private set
|
||||
var tasksByResidenceCacheTime: MutableMap<Int, Long> = mutableMapOf()
|
||||
private set
|
||||
var contractorsCacheTime: Long = 0L
|
||||
private set
|
||||
var documentsCacheTime: Long = 0L
|
||||
private set
|
||||
var summaryCacheTime: Long = 0L
|
||||
private set
|
||||
|
||||
/**
|
||||
* Check if cache for a given timestamp is still valid (not expired)
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun isCacheValid(cacheTime: Long): Boolean {
|
||||
if (cacheTime == 0L) return false
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
return (now - cacheTime) < CACHE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in milliseconds
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun currentTimeMs(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
// Platform-specific persistence managers (initialized at app start)
|
||||
private var tokenManager: TokenManager? = null
|
||||
private var themeManager: ThemeStorageManager? = null
|
||||
@@ -58,6 +99,9 @@ object DataManager {
|
||||
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
||||
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
||||
|
||||
private val _totalSummary = MutableStateFlow<TotalSummary?>(null)
|
||||
val totalSummary: StateFlow<TotalSummary?> = _totalSummary.asStateFlow()
|
||||
|
||||
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
||||
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
||||
|
||||
@@ -78,9 +122,10 @@ object DataManager {
|
||||
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
||||
|
||||
// ==================== CONTRACTORS ====================
|
||||
// Stores ContractorSummary for list views (lighter weight than full Contractor)
|
||||
|
||||
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
|
||||
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
|
||||
private val _contractors = MutableStateFlow<List<ContractorSummary>>(emptyList())
|
||||
val contractors: StateFlow<List<ContractorSummary>> = _contractors.asStateFlow()
|
||||
|
||||
// ==================== SUBSCRIPTION ====================
|
||||
|
||||
@@ -215,16 +260,31 @@ object DataManager {
|
||||
|
||||
fun setResidences(residences: List<Residence>) {
|
||||
_residences.value = residences
|
||||
residencesCacheTime = currentTimeMs()
|
||||
updateLastSyncTime()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun setMyResidences(response: MyResidencesResponse) {
|
||||
_myResidences.value = response
|
||||
// Also update totalSummary from myResidences response
|
||||
_totalSummary.value = response.summary
|
||||
myResidencesCacheTime = currentTimeMs()
|
||||
summaryCacheTime = currentTimeMs()
|
||||
updateLastSyncTime()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun setTotalSummary(summary: TotalSummary) {
|
||||
_totalSummary.value = summary
|
||||
// Also update the summary in myResidences if it exists
|
||||
_myResidences.value?.let { current ->
|
||||
_myResidences.value = current.copy(summary = summary)
|
||||
}
|
||||
summaryCacheTime = currentTimeMs()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
||||
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
||||
persistToDisk()
|
||||
@@ -255,12 +315,14 @@ object DataManager {
|
||||
|
||||
fun setAllTasks(response: TaskColumnsResponse) {
|
||||
_allTasks.value = response
|
||||
tasksCacheTime = currentTimeMs()
|
||||
updateLastSyncTime()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
||||
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
||||
tasksByResidenceCacheTime[residenceId] = currentTimeMs()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
@@ -332,6 +394,7 @@ object DataManager {
|
||||
|
||||
fun setDocuments(documents: List<Document>) {
|
||||
_documents.value = documents
|
||||
documentsCacheTime = currentTimeMs()
|
||||
updateLastSyncTime()
|
||||
persistToDisk()
|
||||
}
|
||||
@@ -364,24 +427,40 @@ object DataManager {
|
||||
|
||||
// ==================== CONTRACTOR UPDATE METHODS ====================
|
||||
|
||||
fun setContractors(contractors: List<Contractor>) {
|
||||
fun setContractors(contractors: List<ContractorSummary>) {
|
||||
_contractors.value = contractors
|
||||
contractorsCacheTime = currentTimeMs()
|
||||
updateLastSyncTime()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun addContractor(contractor: Contractor) {
|
||||
fun addContractor(contractor: ContractorSummary) {
|
||||
_contractors.value = _contractors.value + contractor
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun updateContractor(contractor: Contractor) {
|
||||
/** Add a full Contractor (converts to summary for storage) */
|
||||
fun addContractor(contractor: Contractor) {
|
||||
_contractors.value = _contractors.value + contractor.toSummary()
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun updateContractor(contractor: ContractorSummary) {
|
||||
_contractors.value = _contractors.value.map {
|
||||
if (it.id == contractor.id) contractor else it
|
||||
}
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
/** Update from a full Contractor (converts to summary for storage) */
|
||||
fun updateContractor(contractor: Contractor) {
|
||||
val summary = contractor.toSummary()
|
||||
_contractors.value = _contractors.value.map {
|
||||
if (it.id == summary.id) summary else it
|
||||
}
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
fun removeContractor(contractorId: Int) {
|
||||
_contractors.value = _contractors.value.filter { it.id != contractorId }
|
||||
persistToDisk()
|
||||
@@ -475,6 +554,7 @@ object DataManager {
|
||||
// Clear user data
|
||||
_residences.value = emptyList()
|
||||
_myResidences.value = null
|
||||
_totalSummary.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
@@ -503,6 +583,15 @@ object DataManager {
|
||||
_contractorSpecialtiesMap.value = emptyMap()
|
||||
_lookupsInitialized.value = false
|
||||
|
||||
// Clear cache timestamps
|
||||
residencesCacheTime = 0L
|
||||
myResidencesCacheTime = 0L
|
||||
tasksCacheTime = 0L
|
||||
tasksByResidenceCacheTime.clear()
|
||||
contractorsCacheTime = 0L
|
||||
documentsCacheTime = 0L
|
||||
summaryCacheTime = 0L
|
||||
|
||||
// Clear metadata
|
||||
_lastSyncTime.value = 0L
|
||||
|
||||
@@ -517,6 +606,7 @@ object DataManager {
|
||||
_currentUser.value = null
|
||||
_residences.value = emptyList()
|
||||
_myResidences.value = null
|
||||
_totalSummary.value = null
|
||||
_residenceSummaries.value = emptyMap()
|
||||
_allTasks.value = null
|
||||
_tasksByResidence.value = emptyMap()
|
||||
@@ -527,6 +617,16 @@ object DataManager {
|
||||
_upgradeTriggers.value = emptyMap()
|
||||
_featureBenefits.value = emptyList()
|
||||
_promotions.value = emptyList()
|
||||
|
||||
// Clear cache timestamps
|
||||
residencesCacheTime = 0L
|
||||
myResidencesCacheTime = 0L
|
||||
tasksCacheTime = 0L
|
||||
tasksByResidenceCacheTime.clear()
|
||||
contractorsCacheTime = 0L
|
||||
documentsCacheTime = 0L
|
||||
summaryCacheTime = 0L
|
||||
|
||||
persistToDisk()
|
||||
}
|
||||
|
||||
@@ -539,169 +639,42 @@ object DataManager {
|
||||
|
||||
/**
|
||||
* Persist current state to disk.
|
||||
* Called automatically after each update.
|
||||
* Only persists user data - all other data is fetched fresh from API.
|
||||
* No offline mode support - network required for app functionality.
|
||||
*/
|
||||
private fun persistToDisk() {
|
||||
val manager = persistenceManager ?: return
|
||||
|
||||
try {
|
||||
// Persist each data type
|
||||
// Only persist user data - everything else is fetched fresh from API
|
||||
_currentUser.value?.let {
|
||||
manager.save(KEY_CURRENT_USER, json.encodeToString(it))
|
||||
}
|
||||
|
||||
if (_residences.value.isNotEmpty()) {
|
||||
manager.save(KEY_RESIDENCES, json.encodeToString(_residences.value))
|
||||
}
|
||||
|
||||
_myResidences.value?.let {
|
||||
manager.save(KEY_MY_RESIDENCES, json.encodeToString(it))
|
||||
}
|
||||
|
||||
_allTasks.value?.let {
|
||||
manager.save(KEY_ALL_TASKS, json.encodeToString(it))
|
||||
}
|
||||
|
||||
if (_documents.value.isNotEmpty()) {
|
||||
manager.save(KEY_DOCUMENTS, json.encodeToString(_documents.value))
|
||||
}
|
||||
|
||||
if (_contractors.value.isNotEmpty()) {
|
||||
manager.save(KEY_CONTRACTORS, json.encodeToString(_contractors.value))
|
||||
}
|
||||
|
||||
_subscription.value?.let {
|
||||
manager.save(KEY_SUBSCRIPTION, json.encodeToString(it))
|
||||
}
|
||||
|
||||
// Persist lookups
|
||||
if (_residenceTypes.value.isNotEmpty()) {
|
||||
manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value))
|
||||
}
|
||||
if (_taskFrequencies.value.isNotEmpty()) {
|
||||
manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value))
|
||||
}
|
||||
if (_taskPriorities.value.isNotEmpty()) {
|
||||
manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value))
|
||||
}
|
||||
if (_taskStatuses.value.isNotEmpty()) {
|
||||
manager.save(KEY_TASK_STATUSES, json.encodeToString(_taskStatuses.value))
|
||||
}
|
||||
if (_taskCategories.value.isNotEmpty()) {
|
||||
manager.save(KEY_TASK_CATEGORIES, json.encodeToString(_taskCategories.value))
|
||||
}
|
||||
if (_contractorSpecialties.value.isNotEmpty()) {
|
||||
manager.save(KEY_CONTRACTOR_SPECIALTIES, json.encodeToString(_contractorSpecialties.value))
|
||||
}
|
||||
|
||||
manager.save(KEY_LAST_SYNC_TIME, _lastSyncTime.value.toString())
|
||||
} catch (e: Exception) {
|
||||
// Log error but don't crash - persistence is best-effort
|
||||
println("DataManager: Error persisting to disk: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached state from disk.
|
||||
* Called during initialization.
|
||||
* Only loads user data - all other data is fetched fresh from API.
|
||||
* No offline mode support - network required for app functionality.
|
||||
*/
|
||||
private fun loadFromDisk() {
|
||||
val manager = persistenceManager ?: return
|
||||
|
||||
try {
|
||||
// Only load user data - everything else is fetched fresh from API
|
||||
manager.load(KEY_CURRENT_USER)?.let { data ->
|
||||
_currentUser.value = json.decodeFromString<User>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_RESIDENCES)?.let { data ->
|
||||
_residences.value = json.decodeFromString<List<Residence>>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_MY_RESIDENCES)?.let { data ->
|
||||
_myResidences.value = json.decodeFromString<MyResidencesResponse>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_ALL_TASKS)?.let { data ->
|
||||
_allTasks.value = json.decodeFromString<TaskColumnsResponse>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_DOCUMENTS)?.let { data ->
|
||||
_documents.value = json.decodeFromString<List<Document>>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_CONTRACTORS)?.let { data ->
|
||||
_contractors.value = json.decodeFromString<List<Contractor>>(data)
|
||||
}
|
||||
|
||||
manager.load(KEY_SUBSCRIPTION)?.let { data ->
|
||||
_subscription.value = json.decodeFromString<SubscriptionStatus>(data)
|
||||
}
|
||||
|
||||
// Load lookups
|
||||
manager.load(KEY_RESIDENCE_TYPES)?.let { data ->
|
||||
val types = json.decodeFromString<List<ResidenceType>>(data)
|
||||
_residenceTypes.value = types
|
||||
_residenceTypesMap.value = types.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_TASK_FREQUENCIES)?.let { data ->
|
||||
val items = json.decodeFromString<List<TaskFrequency>>(data)
|
||||
_taskFrequencies.value = items
|
||||
_taskFrequenciesMap.value = items.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_TASK_PRIORITIES)?.let { data ->
|
||||
val items = json.decodeFromString<List<TaskPriority>>(data)
|
||||
_taskPriorities.value = items
|
||||
_taskPrioritiesMap.value = items.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_TASK_STATUSES)?.let { data ->
|
||||
val items = json.decodeFromString<List<TaskStatus>>(data)
|
||||
_taskStatuses.value = items
|
||||
_taskStatusesMap.value = items.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_TASK_CATEGORIES)?.let { data ->
|
||||
val items = json.decodeFromString<List<TaskCategory>>(data)
|
||||
_taskCategories.value = items
|
||||
_taskCategoriesMap.value = items.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data ->
|
||||
val items = json.decodeFromString<List<ContractorSpecialty>>(data)
|
||||
_contractorSpecialties.value = items
|
||||
_contractorSpecialtiesMap.value = items.associateBy { it.id }
|
||||
}
|
||||
|
||||
manager.load(KEY_LAST_SYNC_TIME)?.let { data ->
|
||||
_lastSyncTime.value = data.toLongOrNull() ?: 0L
|
||||
}
|
||||
|
||||
// Mark lookups initialized if we have data
|
||||
if (_residenceTypes.value.isNotEmpty()) {
|
||||
_lookupsInitialized.value = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log error but don't crash - cache miss is OK
|
||||
println("DataManager: Error loading from disk: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PERSISTENCE KEYS ====================
|
||||
// Only user data is persisted - all other data fetched fresh from API
|
||||
|
||||
private const val KEY_CURRENT_USER = "dm_current_user"
|
||||
private const val KEY_RESIDENCES = "dm_residences"
|
||||
private const val KEY_MY_RESIDENCES = "dm_my_residences"
|
||||
private const val KEY_ALL_TASKS = "dm_all_tasks"
|
||||
private const val KEY_DOCUMENTS = "dm_documents"
|
||||
private const val KEY_CONTRACTORS = "dm_contractors"
|
||||
private const val KEY_SUBSCRIPTION = "dm_subscription"
|
||||
private const val KEY_RESIDENCE_TYPES = "dm_residence_types"
|
||||
private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies"
|
||||
private const val KEY_TASK_PRIORITIES = "dm_task_priorities"
|
||||
private const val KEY_TASK_STATUSES = "dm_task_statuses"
|
||||
private const val KEY_TASK_CATEGORIES = "dm_task_categories"
|
||||
private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties"
|
||||
private const val KEY_LAST_SYNC_TIME = "dm_last_sync_time"
|
||||
}
|
||||
|
||||
@@ -86,5 +86,15 @@ data class ContractorSummary(
|
||||
@SerialName("task_count") val taskCount: Int = 0
|
||||
)
|
||||
|
||||
// Note: API returns full Contractor objects for list endpoints
|
||||
// ContractorSummary kept for backward compatibility
|
||||
// Extension to convert full Contractor to ContractorSummary
|
||||
fun Contractor.toSummary() = ContractorSummary(
|
||||
id = id,
|
||||
residenceId = residenceId,
|
||||
name = name,
|
||||
company = company,
|
||||
phone = phone,
|
||||
specialties = specialties,
|
||||
rating = rating,
|
||||
isFavorite = isFavorite,
|
||||
taskCount = taskCount
|
||||
)
|
||||
|
||||
@@ -248,12 +248,10 @@ object APILayer {
|
||||
// ==================== Residence Operations ====================
|
||||
|
||||
suspend fun getResidences(forceRefresh: Boolean = false): ApiResult<List<ResidenceResponse>> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
val cached = DataManager.residences.value
|
||||
if (cached.isNotEmpty()) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
// Cache is valid even if empty (user has no residences)
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
|
||||
return ApiResult.Success(DataManager.residences.value)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
@@ -269,8 +267,8 @@ object APILayer {
|
||||
}
|
||||
|
||||
suspend fun getMyResidences(forceRefresh: Boolean = false): ApiResult<MyResidencesResponse> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.myResidencesCacheTime)) {
|
||||
val cached = DataManager.myResidences.value
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
@@ -290,8 +288,8 @@ object APILayer {
|
||||
}
|
||||
|
||||
suspend fun getResidence(id: Int, forceRefresh: Boolean = false): ApiResult<ResidenceResponse> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.residencesCacheTime)) {
|
||||
val cached = DataManager.residences.value.find { it.id == id }
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
@@ -310,9 +308,27 @@ object APILayer {
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getResidenceSummary(): ApiResult<ResidenceSummaryResponse> {
|
||||
/**
|
||||
* Get total summary (task counts across all residences).
|
||||
* This is a lightweight endpoint for refreshing summary counts.
|
||||
*/
|
||||
suspend fun getSummary(forceRefresh: Boolean = false): ApiResult<TotalSummary> {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.summaryCacheTime)) {
|
||||
val cached = DataManager.totalSummary.value
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return residenceApi.getResidenceSummary(token)
|
||||
val result = residenceApi.getSummary(token)
|
||||
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun createResidence(request: ResidenceCreateRequest): ApiResult<ResidenceResponse> {
|
||||
@@ -397,8 +413,8 @@ object APILayer {
|
||||
// ==================== Task Operations ====================
|
||||
|
||||
suspend fun getTasks(forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) {
|
||||
val cached = DataManager.allTasks.value
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
@@ -418,8 +434,8 @@ object APILayer {
|
||||
}
|
||||
|
||||
suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult<TaskColumnsResponse> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) {
|
||||
val cached = DataManager.tasksByResidence.value[residenceId]
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
@@ -548,6 +564,8 @@ object APILayer {
|
||||
result.data.updatedTask?.let { updatedTask ->
|
||||
DataManager.updateTask(updatedTask)
|
||||
}
|
||||
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
|
||||
refreshSummary()
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -566,6 +584,8 @@ object APILayer {
|
||||
result.data.updatedTask?.let { updatedTask ->
|
||||
DataManager.updateTask(updatedTask)
|
||||
}
|
||||
// Refresh summary counts (tasksDueNextWeek, etc.) - lightweight API call
|
||||
refreshSummary()
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -596,12 +616,10 @@ object APILayer {
|
||||
contractorId != null || isActive != null || expiringSoon != null ||
|
||||
tags != null || search != null
|
||||
|
||||
// Check DataManager first if no filters
|
||||
if (!forceRefresh && !hasFilters) {
|
||||
val cached = DataManager.documents.value
|
||||
if (cached.isNotEmpty()) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
// Check DataManager first if no filters - return cached if valid and not forcing refresh
|
||||
// Cache is valid even if empty (user has no documents)
|
||||
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
|
||||
return ApiResult.Success(DataManager.documents.value)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
@@ -620,8 +638,8 @@ object APILayer {
|
||||
}
|
||||
|
||||
suspend fun getDocument(id: Int, forceRefresh: Boolean = false): ApiResult<Document> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
// Check DataManager first - return cached if valid and not forcing refresh
|
||||
if (!forceRefresh && DataManager.isCacheValid(DataManager.documentsCacheTime)) {
|
||||
val cached = DataManager.documents.value.find { it.id == id }
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
@@ -764,25 +782,32 @@ object APILayer {
|
||||
search: String? = null,
|
||||
forceRefresh: Boolean = false
|
||||
): ApiResult<List<ContractorSummary>> {
|
||||
// Fetch from API (API returns summaries, not full contractors)
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
return contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
|
||||
}
|
||||
val hasFilters = specialty != null || isFavorite != null || isActive != null || search != null
|
||||
|
||||
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
|
||||
// Check DataManager first
|
||||
if (!forceRefresh) {
|
||||
val cached = DataManager.contractors.value.find { it.id == id }
|
||||
if (cached != null) {
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
// Check cache first (only if no filters applied) - return cached if valid and not forcing refresh
|
||||
// Cache is valid even if empty (user has no contractors)
|
||||
if (!forceRefresh && !hasFilters && DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
|
||||
return ApiResult.Success(DataManager.contractors.value)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = contractorApi.getContractors(token, specialty, isFavorite, isActive, search)
|
||||
|
||||
// Update DataManager on success (only for unfiltered results)
|
||||
if (result is ApiResult.Success && !hasFilters) {
|
||||
DataManager.setContractors(result.data)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getContractor(id: Int, forceRefresh: Boolean = false): ApiResult<Contractor> {
|
||||
// Fetch from API (summaries don't have full detail, always fetch)
|
||||
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
|
||||
val result = contractorApi.getContractor(token, id)
|
||||
|
||||
// Update DataManager on success
|
||||
// Update the summary in DataManager on success
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.updateContractor(result.data)
|
||||
}
|
||||
@@ -1030,6 +1055,13 @@ object APILayer {
|
||||
getMyResidences(forceRefresh = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh just the summary counts (lightweight)
|
||||
*/
|
||||
private suspend fun refreshSummary() {
|
||||
getSummary(forceRefresh = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch all data after login
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ package com.example.casera.network
|
||||
*/
|
||||
object ApiConfig {
|
||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||
val CURRENT_ENV = Environment.DEV
|
||||
val CURRENT_ENV = Environment.LOCAL
|
||||
|
||||
enum class Environment {
|
||||
LOCAL,
|
||||
|
||||
@@ -93,7 +93,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getResidenceSummary(token: String): ApiResult<ResidenceSummaryResponse> {
|
||||
suspend fun getSummary(token: String): ApiResult<TotalSummary> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/residences/summary/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -102,7 +102,7 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to fetch residence summary", response.status.value)
|
||||
ApiResult.Error("Failed to fetch summary", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.casera.models.*
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.network.AuthApi
|
||||
import com.example.casera.network.APILayer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -19,7 +19,6 @@ enum class PasswordResetStep {
|
||||
class PasswordResetViewModel(
|
||||
private val deepLinkToken: String? = null
|
||||
) : ViewModel() {
|
||||
private val authApi = AuthApi()
|
||||
|
||||
private val _forgotPasswordState = MutableStateFlow<ApiResult<ForgotPasswordResponse>>(ApiResult.Idle)
|
||||
val forgotPasswordState: StateFlow<ApiResult<ForgotPasswordResponse>> = _forgotPasswordState
|
||||
@@ -48,7 +47,7 @@ class PasswordResetViewModel(
|
||||
fun requestPasswordReset(email: String) {
|
||||
viewModelScope.launch {
|
||||
_forgotPasswordState.value = ApiResult.Loading
|
||||
val result = authApi.forgotPassword(ForgotPasswordRequest(email))
|
||||
val result = APILayer.forgotPassword(ForgotPasswordRequest(email))
|
||||
_forgotPasswordState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_email.value = email
|
||||
@@ -66,7 +65,7 @@ class PasswordResetViewModel(
|
||||
fun verifyResetCode(email: String, code: String) {
|
||||
viewModelScope.launch {
|
||||
_verifyCodeState.value = ApiResult.Loading
|
||||
val result = authApi.verifyResetCode(VerifyResetCodeRequest(email, code))
|
||||
val result = APILayer.verifyResetCode(VerifyResetCodeRequest(email, code))
|
||||
_verifyCodeState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
_resetToken.value = result.data.resetToken
|
||||
@@ -91,7 +90,7 @@ class PasswordResetViewModel(
|
||||
viewModelScope.launch {
|
||||
_resetPasswordState.value = ApiResult.Loading
|
||||
// Note: confirmPassword is for UI validation only, not sent to API
|
||||
val result = authApi.resetPassword(
|
||||
val result = APILayer.resetPassword(
|
||||
ResetPasswordRequest(
|
||||
resetToken = token,
|
||||
newPassword = newPassword
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.casera.models.Residence
|
||||
import com.example.casera.models.ResidenceCreateRequest
|
||||
import com.example.casera.models.ResidenceSummaryResponse
|
||||
import com.example.casera.models.TotalSummary
|
||||
import com.example.casera.models.MyResidencesResponse
|
||||
import com.example.casera.models.TaskColumnsResponse
|
||||
import com.example.casera.models.ContractorSummary
|
||||
@@ -19,8 +19,8 @@ class ResidenceViewModel : ViewModel() {
|
||||
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Idle)
|
||||
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
|
||||
|
||||
private val _residenceSummaryState = MutableStateFlow<ApiResult<ResidenceSummaryResponse>>(ApiResult.Idle)
|
||||
val residenceSummaryState: StateFlow<ApiResult<ResidenceSummaryResponse>> = _residenceSummaryState
|
||||
private val _summaryState = MutableStateFlow<ApiResult<TotalSummary>>(ApiResult.Idle)
|
||||
val summaryState: StateFlow<ApiResult<TotalSummary>> = _summaryState
|
||||
|
||||
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Idle)
|
||||
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
|
||||
@@ -63,10 +63,10 @@ class ResidenceViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadResidenceSummary() {
|
||||
fun loadSummary(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_residenceSummaryState.value = ApiResult.Loading
|
||||
_residenceSummaryState.value = APILayer.getResidenceSummary()
|
||||
_summaryState.value = ApiResult.Loading
|
||||
_summaryState.value = APILayer.getSummary(forceRefresh = forceRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,31 +2,25 @@ package com.example.casera.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.TaskCompletion
|
||||
import com.example.casera.models.TaskCompletionCreateRequest
|
||||
import com.example.casera.models.TaskCompletionResponse
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.network.TaskCompletionApi
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.util.ImageCompressor
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TaskCompletionViewModel : ViewModel() {
|
||||
private val taskCompletionApi = TaskCompletionApi()
|
||||
|
||||
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletion>>(ApiResult.Idle)
|
||||
val createCompletionState: StateFlow<ApiResult<TaskCompletion>> = _createCompletionState
|
||||
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletionResponse>>(ApiResult.Idle)
|
||||
val createCompletionState: StateFlow<ApiResult<TaskCompletionResponse>> = _createCompletionState
|
||||
|
||||
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
||||
viewModelScope.launch {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
val token = DataManager.authToken.value
|
||||
if (token != null) {
|
||||
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
|
||||
} else {
|
||||
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
// Use APILayer which handles DataManager updates and summary refresh
|
||||
_createCompletionState.value = APILayer.createTaskCompletion(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,31 +36,27 @@ class TaskCompletionViewModel : ViewModel() {
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
val token = DataManager.authToken.value
|
||||
if (token != null) {
|
||||
// Compress images and prepare for upload
|
||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||
val imageFileNames = images.mapIndexed { index, image ->
|
||||
// Always use .jpg extension since we compress to JPEG
|
||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
// Remove any existing extension and add .jpg
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
}
|
||||
}
|
||||
|
||||
_createCompletionState.value = taskCompletionApi.createCompletionWithImages(
|
||||
token = token,
|
||||
request = request,
|
||||
images = compressedImages,
|
||||
imageFileNames = imageFileNames
|
||||
)
|
||||
} else {
|
||||
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
||||
// Compress images and prepare for upload
|
||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||
val imageFileNames = images.mapIndexed { index, image ->
|
||||
// Always use .jpg extension since we compress to JPEG
|
||||
val baseName = image.fileName.ifBlank { "completion_$index" }
|
||||
if (baseName.endsWith(".jpg", ignoreCase = true) ||
|
||||
baseName.endsWith(".jpeg", ignoreCase = true)) {
|
||||
baseName
|
||||
} else {
|
||||
// Remove any existing extension and add .jpg
|
||||
baseName.substringBeforeLast('.', baseName) + ".jpg"
|
||||
}
|
||||
}
|
||||
|
||||
// Use APILayer which handles DataManager updates and summary refresh
|
||||
_createCompletionState.value = APILayer.createTaskCompletionWithImages(
|
||||
request = request,
|
||||
images = compressedImages,
|
||||
imageFileNames = imageFileNames
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user