Refactor iOS codebase with SOLID/DRY patterns
Core Infrastructure: - Add StateFlowObserver for reusable Kotlin StateFlow observation - Add ValidationRules for centralized form validation - Add ActionState enum for tracking async operations - Add KotlinTypeExtensions with .asKotlin helpers - Add Dependencies factory for dependency injection - Add ViewState, FormField, and FormState for view layer - Add LoadingOverlay and AsyncContentView components - Add form state containers (Task, Residence, Contractor, Document) ViewModel Updates (9 files): - Refactor all ViewModels to use StateFlowObserver pattern - Add optional DI support via initializer parameters - Reduce boilerplate by ~330 lines across ViewModels View Updates (4 files): - Update ResidencesListView to use ListAsyncContentView - Update ContractorsListView to use ListAsyncContentView - Update WarrantiesTabContent to use ListAsyncContentView - Update DocumentsTabContent to use ListAsyncContentView Net reduction: -332 lines (1007 removed, 675 added) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,15 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Private Properties
|
||||
public let sharedViewModel: ComposeApp.ResidenceViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let tokenStorage: TokenStorageProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
self.sharedViewModel = ComposeApp.ResidenceViewModel()
|
||||
init(
|
||||
sharedViewModel: ComposeApp.ResidenceViewModel? = nil,
|
||||
tokenStorage: TokenStorageProtocol? = nil
|
||||
) {
|
||||
self.sharedViewModel = sharedViewModel ?? ComposeApp.ResidenceViewModel()
|
||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
@@ -29,28 +33,14 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
sharedViewModel.loadResidenceSummary()
|
||||
|
||||
// Observe the state
|
||||
Task {
|
||||
for await state in sharedViewModel.residenceSummaryState {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isLoading = true
|
||||
}
|
||||
} else if let success = state as? ApiResultSuccess<ResidenceSummaryResponse> {
|
||||
await MainActor.run {
|
||||
self.residenceSummary = success.data
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func loadMyResidences(forceRefresh: Bool = false) {
|
||||
@@ -59,28 +49,14 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
sharedViewModel.loadMyResidences(forceRefresh: forceRefresh)
|
||||
|
||||
// Observe the state
|
||||
Task {
|
||||
for await state in sharedViewModel.myResidencesState {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isLoading = true
|
||||
}
|
||||
} else if let success = state as? ApiResultSuccess<MyResidencesResponse> {
|
||||
await MainActor.run {
|
||||
self.myResidences = success.data
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func getResidence(id: Int32) {
|
||||
@@ -106,31 +82,13 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
sharedViewModel.createResidence(request: request)
|
||||
|
||||
// Observe the state
|
||||
Task {
|
||||
for await state in sharedViewModel.createResidenceState {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isLoading = true
|
||||
}
|
||||
} else if state is ApiResultSuccess<Residence> {
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetCreateState()
|
||||
completion(true)
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetCreateState()
|
||||
completion(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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() }
|
||||
)
|
||||
}
|
||||
|
||||
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
@@ -139,32 +97,16 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
sharedViewModel.updateResidence(residenceId: id, request: request)
|
||||
|
||||
// Observe the state
|
||||
Task {
|
||||
for await state in sharedViewModel.updateResidenceState {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isLoading = true
|
||||
}
|
||||
} else if let success = state as? ApiResultSuccess<Residence> {
|
||||
await MainActor.run {
|
||||
self.selectedResidence = success.data
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetUpdateState()
|
||||
completion(true)
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
}
|
||||
sharedViewModel.resetUpdateState()
|
||||
completion(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
StateFlowObserver.observeWithCompletion(
|
||||
sharedViewModel.updateResidenceState,
|
||||
loadingSetter: { [weak self] in self?.isLoading = $0 },
|
||||
errorSetter: { [weak self] in self?.errorMessage = $0 },
|
||||
onSuccess: { [weak self] (data: Residence) in
|
||||
self?.selectedResidence = data
|
||||
},
|
||||
completion: completion,
|
||||
resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() }
|
||||
)
|
||||
}
|
||||
|
||||
func generateTasksReport(residenceId: Int32, email: String? = nil) {
|
||||
@@ -173,34 +115,21 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
sharedViewModel.generateTasksReport(residenceId: residenceId, email: email)
|
||||
|
||||
// Observe the state
|
||||
Task {
|
||||
for await state in sharedViewModel.generateReportState {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
self.isGeneratingReport = true
|
||||
}
|
||||
} else if let success = state as? ApiResultSuccess<GenerateReportResponse> {
|
||||
await MainActor.run {
|
||||
if let response = success.data {
|
||||
self.reportMessage = response.message
|
||||
} else {
|
||||
self.reportMessage = "Report generated, but no message returned."
|
||||
}
|
||||
self.isGeneratingReport = false
|
||||
}
|
||||
sharedViewModel.resetGenerateReportState()
|
||||
break
|
||||
} else if let error = state as? ApiResultError {
|
||||
await MainActor.run {
|
||||
self.reportMessage = error.message
|
||||
self.isGeneratingReport = false
|
||||
}
|
||||
sharedViewModel.resetGenerateReportState()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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() }
|
||||
)
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
|
||||
@@ -9,63 +9,38 @@ struct ResidencesListView: View {
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary
|
||||
.ignoresSafeArea()
|
||||
|
||||
if viewModel.myResidences == nil && viewModel.isLoading {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Text("Loading properties...")
|
||||
.font(.body)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
} else if let response = viewModel.myResidences {
|
||||
if response.residences.isEmpty {
|
||||
EmptyResidencesView()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Summary Card
|
||||
SummaryCard(summary: response.summary)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
|
||||
// Properties Header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text("Your Properties")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("\(response.residences.count) \(response.residences.count == 1 ? "property" : "properties")")
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
|
||||
// Residences List
|
||||
ForEach(response.residences, id: \.id) { residence in
|
||||
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
||||
ResidenceCard(residence: residence)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.refreshable {
|
||||
if let response = viewModel.myResidences {
|
||||
ListAsyncContentView(
|
||||
items: response.residences,
|
||||
isLoading: viewModel.isLoading,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { residences in
|
||||
ResidencesContent(
|
||||
response: response,
|
||||
residences: residences
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
EmptyResidencesView()
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear.frame(height: 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else if viewModel.isLoading {
|
||||
DefaultLoadingView()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
DefaultErrorView(message: error, onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle("My Properties")
|
||||
@@ -123,10 +98,6 @@ struct ResidencesListView: View {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.loadMyResidences() }
|
||||
)
|
||||
.fullScreenCover(isPresented: $authManager.isAuthenticated.negated) {
|
||||
LoginView(onLoginSuccess: {
|
||||
authManager.isAuthenticated = true
|
||||
@@ -142,6 +113,51 @@ struct ResidencesListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Residences Content View
|
||||
|
||||
private struct ResidencesContent: View {
|
||||
let response: MyResidencesResponse
|
||||
let residences: [ResidenceWithTasks]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Summary Card
|
||||
SummaryCard(summary: response.summary)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
|
||||
// Properties Header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text("Your Properties")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text("\(residences.count) \(residences.count == 1 ? "property" : "properties")")
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
|
||||
// Residences List
|
||||
ForEach(residences, id: \.id) { residence in
|
||||
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
||||
ResidenceCard(residence: residence)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear.frame(height: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ResidencesListView()
|
||||
|
||||
Reference in New Issue
Block a user