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

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-03 09:50:57 -06:00
parent cf0cd1cda2
commit 63a54434ed
29 changed files with 1284 additions and 1230 deletions

View File

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