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

@@ -80,27 +80,12 @@ struct JoinResidenceView: View {
return
}
Task {
// Call the shared ViewModel which uses APILayer
await viewModel.sharedViewModel.joinWithCode(code: shareCode)
// Observe the result
for await state in viewModel.sharedViewModel.joinResidenceState {
if state is ApiResultSuccess<JoinResidenceResponse> {
await MainActor.run {
viewModel.sharedViewModel.resetJoinResidenceState()
onJoined()
dismiss()
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
viewModel.errorMessage = ErrorMessageParser.parse(error.message)
viewModel.sharedViewModel.resetJoinResidenceState()
}
break
}
viewModel.joinWithCode(code: shareCode) { success in
if success {
onJoined()
dismiss()
}
// Error is handled by ViewModel and displayed via viewModel.errorMessage
}
}
}

View File

@@ -2,11 +2,17 @@ import Foundation
import ComposeApp
import Combine
/// ViewModel for residence management.
/// Observes DataManagerObservable for cached data.
/// Kicks off API calls that update DataManager, letting views react to cache updates.
@MainActor
class ResidenceViewModel: ObservableObject {
// MARK: - Published Properties
@Published var residenceSummary: ResidenceSummaryResponse?
// MARK: - Published Properties (from DataManager observation)
@Published var myResidences: MyResidencesResponse?
@Published var residences: [ResidenceResponse] = []
@Published var totalSummary: TotalSummary?
// MARK: - Local State
@Published var selectedResidence: ResidenceResponse?
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@@ -14,57 +20,105 @@ class ResidenceViewModel: ObservableObject {
@Published var reportMessage: String?
// MARK: - Private Properties
public let sharedViewModel: ComposeApp.ResidenceViewModel
private let tokenStorage: TokenStorageProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.ResidenceViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
init() {
// Observe DataManagerObservable for residence data
DataManagerObservable.shared.$myResidences
.receive(on: DispatchQueue.main)
.sink { [weak self] myResidences in
self?.myResidences = myResidences
// Clear loading state when data arrives
if myResidences != nil {
self?.isLoading = false
}
}
.store(in: &cancellables)
DataManagerObservable.shared.$residences
.receive(on: DispatchQueue.main)
.sink { [weak self] residences in
self?.residences = residences
}
.store(in: &cancellables)
DataManagerObservable.shared.$totalSummary
.receive(on: DispatchQueue.main)
.sink { [weak self] summary in
self?.totalSummary = summary
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func loadResidenceSummary() {
isLoading = true
/// Load summary - kicks off API call that updates DataManager
func loadSummary(forceRefresh: Bool = false) {
errorMessage = nil
sharedViewModel.loadResidenceSummary()
// Check if we have cached data and don't need to refresh
if !forceRefresh && totalSummary != nil {
return
}
StateFlowObserver.observeWithState(
sharedViewModel.residenceSummaryState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: ResidenceSummaryResponse) in
self?.residenceSummary = data
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable
Task {
do {
let result = try await APILayer.shared.getSummary(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
)
}
}
/// Load my residences - checks cache first, then fetches if needed
func loadMyResidences(forceRefresh: Bool = false) {
isLoading = true
errorMessage = nil
sharedViewModel.loadMyResidences(forceRefresh: forceRefresh)
// Check if we have cached data and don't need to refresh
if !forceRefresh && DataManagerObservable.shared.myResidences != nil {
// Data already available via observation, no API call needed
return
}
StateFlowObserver.observeWithState(
sharedViewModel.myResidencesState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: MyResidencesResponse) in
self?.myResidences = data
isLoading = true
// Kick off API call - DataManager will be updated, which updates DataManagerObservable,
// which updates our @Published myResidences via the sink above
Task {
do {
let result = try await APILayer.shared.getMyResidences(forceRefresh: forceRefresh)
// Only handle errors - success updates DataManager automatically
if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
)
}
}
func getResidence(id: Int32) {
isLoading = true
errorMessage = nil
sharedViewModel.getResidence(id: id) { result in
Task { @MainActor in
Task {
do {
let result = try await APILayer.shared.getResidence(id: id, forceRefresh: false)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
@@ -72,6 +126,9 @@ class ResidenceViewModel: ObservableObject {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
@@ -80,56 +137,77 @@ class ResidenceViewModel: ObservableObject {
isLoading = true
errorMessage = nil
sharedViewModel.createResidence(request: request)
Task {
do {
let result = try await APILayer.shared.createResidence(request: request)
StateFlowObserver.observeWithCompletion(
sharedViewModel.createResidenceState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
completion: completion,
resetState: { [weak self] in self?.sharedViewModel.resetCreateState() }
)
if result is ApiResultSuccess<ResidenceResponse> {
self.isLoading = false
// DataManager is updated by APILayer (including refreshMyResidences),
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
sharedViewModel.updateResidence(residenceId: id, request: request)
Task {
do {
let result = try await APILayer.shared.updateResidence(id: id, request: request)
StateFlowObserver.observeWithCompletion(
sharedViewModel.updateResidenceState,
loadingSetter: { [weak self] in self?.isLoading = $0 },
errorSetter: { [weak self] in self?.errorMessage = $0 },
onSuccess: { [weak self] (data: ResidenceResponse) in
self?.selectedResidence = data
},
completion: completion,
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
)
if let success = result as? ApiResultSuccess<ResidenceResponse> {
self.selectedResidence = success.data
self.isLoading = false
// DataManager is updated by APILayer (including refreshMyResidences),
// which updates DataManagerObservable, which updates our @Published
// myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func generateTasksReport(residenceId: Int32, email: String? = nil) {
isGeneratingReport = true
reportMessage = nil
sharedViewModel.generateTasksReport(residenceId: residenceId, email: email)
Task {
do {
let result = try await APILayer.shared.generateTasksReport(residenceId: residenceId, email: email)
StateFlowObserver.observe(
sharedViewModel.generateReportState,
onLoading: { [weak self] in
self?.isGeneratingReport = true
},
onSuccess: { [weak self] (response: GenerateReportResponse) in
self?.reportMessage = response.message ?? "Report generated, but no message returned."
self?.isGeneratingReport = false
},
onError: { [weak self] error in
self?.reportMessage = error
self?.isGeneratingReport = false
},
resetState: { [weak self] in self?.sharedViewModel.resetGenerateReportState() }
)
if let success = result as? ApiResultSuccess<GenerateReportResponse> {
self.reportMessage = success.data?.message ?? "Report generated, but no message returned."
self.isGeneratingReport = false
} else if let error = result as? ApiResultError {
self.reportMessage = ErrorMessageParser.parse(error.message)
self.isGeneratingReport = false
}
} catch {
self.reportMessage = error.localizedDescription
self.isGeneratingReport = false
}
}
}
func clearError() {
@@ -137,6 +215,34 @@ class ResidenceViewModel: ObservableObject {
}
func loadResidenceContractors(residenceId: Int32) {
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
// This can now be handled directly via APILayer if needed
// or through DataManagerObservable.shared.contractors
}
func joinWithCode(code: String, completion: @escaping (Bool) -> Void) {
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.joinWithCode(code: code)
if result is ApiResultSuccess<JoinResidenceResponse> {
self.isLoading = false
// APILayer updates DataManager with refreshMyResidences,
// which updates DataManagerObservable, which updates our
// @Published myResidences via Combine subscription
completion(true)
} else if let error = result as? ApiResultError {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
completion(false)
}
} catch {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
}

View File

@@ -21,7 +21,7 @@ struct ResidencesListView: View {
errorMessage: viewModel.errorMessage,
content: { residences in
ResidencesContent(
response: response,
summary: viewModel.totalSummary ?? response.summary,
residences: residences
)
},
@@ -120,14 +120,14 @@ struct ResidencesListView: View {
// MARK: - Residences Content View
private struct ResidencesContent: View {
let response: MyResidencesResponse
let summary: TotalSummary
let residences: [ResidenceResponse]
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.lg) {
// Summary Card
SummaryCard(summary: response.summary)
SummaryCard(summary: summary)
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)