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