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>
35 KiB
iOS Codebase Refactoring Plan
Overview
Refactor the MyCrib iOS codebase to improve adherence to SOLID principles and DRY patterns. This plan targets ~1,500 lines of code reduction while significantly improving testability and maintainability.
Scope: MyCribKMM/iosApp/iosApp/
Estimated Effort: 4-5 focused sessions
Risk Level: Medium (ViewModels are central to app functionality)
User Preferences
- DI Approach: Factory pattern (
Dependencies.current.make*()) - View Refactoring: Yes, include Views alongside ViewModels
- File Organization: New
Core/folder for foundational abstractions
Completion Status
| Session | Status | Notes |
|---|---|---|
| Session 1: Core Abstractions | ✅ Complete | StateFlowObserver, ValidationRules created; BaseViewModel skipped per user preference |
| Session 2: Type Conversions | ✅ Complete | KotlinTypeExtensions.swift created with .asKotlin helpers |
| Session 3: State Management | ✅ Complete | ActionState.swift created for TaskViewModel |
| Session 4: Dependency Injection | ✅ Complete | Dependencies.swift + TokenStorageProtocol (simplified due to Kotlin API constraints) |
| Session 5: View Layer | ✅ Complete | ViewState, LoadingOverlay, AsyncContentView, Form State containers created |
Implementation Notes
- BaseViewModel Skipped: Per user preference, ViewModels continue to extend
ObservableObjectdirectly - Protocol Simplification: Full ViewModel protocols removed due to Kotlin-generated API parameter label mismatches; only
TokenStorageProtocolretained - Form State Containers: Created as standalone structs that can be adopted incrementally by views
- All ViewModels Updated: 9 ViewModels now use StateFlowObserver pattern and support optional DI
Files Created
iosApp/Core/
├── StateFlowObserver.swift ✅
├── ValidationRules.swift ✅
├── ActionState.swift ✅
├── ViewState.swift ✅
├── LoadingOverlay.swift ✅
├── AsyncContentView.swift ✅
├── Dependencies.swift ✅
├── Protocols/
│ └── ViewModelProtocols.swift ✅ (TokenStorageProtocol only)
├── FormStates/
│ ├── TaskFormStates.swift ✅
│ ├── ResidenceFormState.swift ✅
│ ├── ContractorFormState.swift ✅
│ └── DocumentFormState.swift ✅
└── Extensions/
└── KotlinTypeExtensions.swift ✅
ViewModels Updated
- ResidenceViewModel - StateFlowObserver + DI
- TaskViewModel - StateFlowObserver + ActionState + DI
- ContractorViewModel - StateFlowObserver + DI
- DocumentViewModel - StateFlowObserver + DI
- LoginViewModel - StateFlowObserver + DI
- ProfileViewModel - StateFlowObserver + DI
- RegisterViewModel - StateFlowObserver + ValidationRules + DI
- VerifyEmailViewModel - StateFlowObserver + DI
- PasswordResetViewModel - StateFlowObserver + ValidationRules + DI
Views Updated with ListAsyncContentView
- ResidencesListView - Now uses
ListAsyncContentViewwith extractedResidencesContent - ContractorsListView - Now uses
ListAsyncContentViewwith extractedContractorsContent - WarrantiesTabContent - Now uses
ListAsyncContentViewwith extractedWarrantiesListContent - DocumentsTabContent - Now uses
ListAsyncContentViewwith extractedDocumentsListContent
Phase 1: Core Abstractions
1.1 Create StateFlow Observation Utility
Problem: 26 instances of identical StateFlow observation boilerplate across all ViewModels (~800 lines)
Solution: Create a reusable utility that handles Kotlin StateFlow observation with proper MainActor handling.
New File: iosApp/Core/StateFlowObserver.swift
import Foundation
import ComposeApp
@MainActor
class StateFlowObserver {
/// Observe a Kotlin StateFlow and handle loading/success/error states
static func observe<T>(
_ stateFlow: Kotlinx_coroutines_coreStateFlow,
onLoading: (() -> Void)? = nil,
onSuccess: @escaping (T) -> Void,
onError: ((String) -> Void)? = nil,
resetState: (() -> Void)? = nil
) {
Task {
for await state in stateFlow {
if state is ApiResultLoading {
await MainActor.run {
onLoading?()
}
} else if let success = state as? ApiResultSuccess<T> {
await MainActor.run {
onSuccess(success.data!)
}
resetState?()
break
} else if let error = state as? ApiResultError {
await MainActor.run {
let message = ErrorMessageParser.parse(error.message ?? "Unknown error")
onError?(message)
}
resetState?()
break
} else if state is ApiResultIdle {
// Idle state, continue observing
continue
}
}
}
}
/// Observe with automatic isLoading/errorMessage binding
static func observeWithState<T>(
_ stateFlow: Kotlinx_coroutines_coreStateFlow,
isLoading: Binding<Bool>,
errorMessage: Binding<String?>,
onSuccess: @escaping (T) -> Void,
resetState: (() -> Void)? = nil
) {
observe(
stateFlow,
onLoading: { isLoading.wrappedValue = true },
onSuccess: { data in
isLoading.wrappedValue = false
onSuccess(data)
},
onError: { error in
isLoading.wrappedValue = false
errorMessage.wrappedValue = error
},
resetState: resetState
)
}
}
Files to Update (remove duplicated observation code):
Residence/ResidenceViewModel.swiftTask/TaskViewModel.swiftContractor/ContractorViewModel.swiftDocuments/DocumentViewModel.swiftLogin/LoginViewModel.swiftProfile/ProfileViewModel.swiftRegister/RegisterView.swiftVerifyEmail/VerifyEmailViewModel.swiftPasswordReset/PasswordResetViewModel.swift
1.2 Create Base ViewModel Class
Problem: Every ViewModel repeats isLoading, errorMessage, and basic state handling patterns.
Solution: Abstract common ViewModel functionality into a base class.
New File: iosApp/Core/BaseViewModel.swift
import Foundation
import SwiftUI
import ComposeApp
@MainActor
class BaseViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var errorMessage: String?
/// Clear error message
func clearError() {
errorMessage = nil
}
/// Set loading state
func setLoading(_ loading: Bool) {
isLoading = loading
}
/// Handle error with optional parsing
func handleError(_ message: String?) {
errorMessage = ErrorMessageParser.parse(message ?? "An unexpected error occurred")
isLoading = false
}
/// Observe StateFlow with automatic state binding
func observe<T>(
_ stateFlow: Kotlinx_coroutines_coreStateFlow,
onSuccess: @escaping (T) -> Void,
resetState: (() -> Void)? = nil
) {
StateFlowObserver.observe(
stateFlow,
onLoading: { [weak self] in self?.isLoading = true },
onSuccess: { [weak self] (data: T) in
self?.isLoading = false
onSuccess(data)
},
onError: { [weak self] error in
self?.handleError(error)
},
resetState: resetState
)
}
}
ViewModels to Refactor (inherit from BaseViewModel):
- All 10 ViewModels will extend
BaseViewModelinstead ofObservableObject
1.3 Create Validation Rules Module
Problem: Email, password, and field validation logic duplicated across 4+ ViewModels.
Solution: Centralize all validation rules in a single module.
New File: iosApp/Core/ValidationRules.swift
import Foundation
enum ValidationError: LocalizedError {
case required(field: String)
case invalidEmail
case passwordTooShort(minLength: Int)
case passwordMismatch
case invalidCode(expectedLength: Int)
case invalidUsername
case custom(message: String)
var errorDescription: String? {
switch self {
case .required(let field):
return "\(field) is required"
case .invalidEmail:
return "Please enter a valid email address"
case .passwordTooShort(let minLength):
return "Password must be at least \(minLength) characters"
case .passwordMismatch:
return "Passwords do not match"
case .invalidCode(let length):
return "Code must be \(length) digits"
case .invalidUsername:
return "Username can only contain letters, numbers, and underscores"
case .custom(let message):
return message
}
}
}
struct ValidationRules {
// MARK: - Email Validation
static func validateEmail(_ email: String) -> ValidationError? {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return .required(field: "Email")
}
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
if !predicate.evaluate(with: trimmed) {
return .invalidEmail
}
return nil
}
// MARK: - Password Validation
static func validatePassword(_ password: String, minLength: Int = 8) -> ValidationError? {
if password.isEmpty {
return .required(field: "Password")
}
if password.count < minLength {
return .passwordTooShort(minLength: minLength)
}
return nil
}
static func validatePasswordMatch(_ password: String, _ confirmPassword: String) -> ValidationError? {
if password != confirmPassword {
return .passwordMismatch
}
return nil
}
// MARK: - Code Validation
static func validateCode(_ code: String, expectedLength: Int = 6) -> ValidationError? {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return .required(field: "Code")
}
if trimmed.count != expectedLength || !trimmed.allSatisfy({ $0.isNumber }) {
return .invalidCode(expectedLength: expectedLength)
}
return nil
}
// MARK: - Username Validation
static func validateUsername(_ username: String) -> ValidationError? {
let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return .required(field: "Username")
}
let usernameRegex = "^[A-Za-z0-9_]+$"
let predicate = NSPredicate(format: "SELF MATCHES %@", usernameRegex)
if !predicate.evaluate(with: trimmed) {
return .invalidUsername
}
return nil
}
// MARK: - Required Field
static func validateRequired(_ value: String, fieldName: String) -> ValidationError? {
if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .required(field: fieldName)
}
return nil
}
}
Files to Update (remove inline validation):
Login/LoginViewModel.swiftRegister/RegisterView.swiftPasswordReset/PasswordResetViewModel.swiftProfile/ProfileViewModel.swift
Phase 2: Type Conversion & Extensions
2.1 Create Kotlin Type Conversion Extensions
Problem: Verbose type conversions scattered across ViewModels (~150 lines of boilerplate).
Solution: Create clean extensions for Swift-to-Kotlin type conversions.
New File: iosApp/Extensions/KotlinTypeExtensions.swift
import Foundation
import ComposeApp
// MARK: - Optional Bool to KotlinBoolean
extension Optional where Wrapped == Bool {
var asKotlin: KotlinBoolean? {
self.map { KotlinBoolean(bool: $0) }
}
}
extension Bool {
var asKotlin: KotlinBoolean {
KotlinBoolean(bool: self)
}
}
// MARK: - Optional Int to KotlinInt
extension Optional where Wrapped == Int {
var asKotlin: KotlinInt? {
self.map { KotlinInt(integerLiteral: $0) }
}
}
extension Optional where Wrapped == Int32 {
var asKotlin: KotlinInt? {
self.map { KotlinInt(integerLiteral: Int($0)) }
}
}
extension Int {
var asKotlin: KotlinInt {
KotlinInt(integerLiteral: self)
}
}
extension Int32 {
var asKotlin: KotlinInt {
KotlinInt(integerLiteral: Int(self))
}
}
// MARK: - Optional Double to KotlinDouble
extension Optional where Wrapped == Double {
var asKotlin: KotlinDouble? {
self.map { KotlinDouble(double: $0) }
}
}
extension Double {
var asKotlin: KotlinDouble {
KotlinDouble(double: self)
}
}
// MARK: - String to Optional Double (for form inputs)
extension String {
var asOptionalDouble: Double? {
Double(self)
}
var asKotlinDouble: KotlinDouble? {
Double(self).map { KotlinDouble(double: $0) }
}
}
// MARK: - Optional Long to KotlinLong
extension Optional where Wrapped == Int64 {
var asKotlin: KotlinLong? {
self.map { KotlinLong(longLong: $0) }
}
}
extension Int64 {
var asKotlin: KotlinLong {
KotlinLong(longLong: self)
}
}
Files to Update (simplify type conversions):
Documents/DocumentViewModel.swiftContractor/ContractorViewModel.swiftTask/TaskViewModel.swiftResidence/ResidenceViewModel.swift
Before:
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
After:
residenceId: residenceId.asKotlin,
isActive: isActive.asKotlin,
Phase 3: State Management Improvements
3.1 Create Action State Pattern
Problem: TaskViewModel has 7 separate boolean flags for action states, which is error-prone.
Solution: Replace with a single enum-based state.
New File: iosApp/Core/ActionState.swift
import Foundation
/// Generic action state for tracking async operations
enum ActionState<T>: Equatable where T: Equatable {
case idle
case inProgress
case success(T)
case failure(String)
var isInProgress: Bool {
if case .inProgress = self { return true }
return false
}
var isSuccess: Bool {
if case .success = self { return true }
return false
}
var successValue: T? {
if case .success(let value) = self { return value }
return nil
}
var errorMessage: String? {
if case .failure(let message) = self { return message }
return nil
}
static func == (lhs: ActionState<T>, rhs: ActionState<T>) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle): return true
case (.inProgress, .inProgress): return true
case (.success(let l), .success(let r)): return l == r
case (.failure(let l), .failure(let r)): return l == r
default: return false
}
}
}
/// Specialized for void actions (create, delete, etc.)
typealias VoidActionState = ActionState<Void>
extension ActionState where T == Void {
static var success: ActionState<Void> { .success(()) }
}
/// Task-specific action types
enum TaskActionType: Equatable {
case created
case updated
case cancelled
case uncancelled
case markedInProgress
case archived
case unarchived
case completed
}
File to Update: Task/TaskViewModel.swift
Before:
@Published var taskCreated: Bool = false
@Published var taskUpdated: Bool = false
@Published var taskCancelled: Bool = false
@Published var taskUncancelled: Bool = false
@Published var taskMarkedInProgress: Bool = false
@Published var taskArchived: Bool = false
@Published var taskUnarchived: Bool = false
After:
@Published var lastAction: ActionState<TaskActionType> = .idle
// Usage in views:
.onChange(of: viewModel.lastAction) { newValue in
if case .success(let action) = newValue {
switch action {
case .created: // handle created
case .cancelled: // handle cancelled
// etc.
}
viewModel.lastAction = .idle // reset
}
}
Phase 4: Dependency Injection Foundation
4.1 Create Protocol Abstractions for Kotlin ViewModels
Problem: Hard-wired Kotlin ViewModel dependencies make unit testing impossible.
Solution: Create protocols that abstract the Kotlin layer, allowing mock implementations.
New File: iosApp/Protocols/ViewModelProtocols.swift
import Foundation
import ComposeApp
// MARK: - Residence ViewModel Protocol
protocol ResidenceViewModelProtocol {
var myResidencesState: Kotlinx_coroutines_coreStateFlow { get }
var createState: Kotlinx_coroutines_coreStateFlow { get }
var updateState: Kotlinx_coroutines_coreStateFlow { get }
var deleteState: Kotlinx_coroutines_coreStateFlow { get }
func loadMyResidences(forceRefresh: Bool)
func createResidence(request: ResidenceCreateRequest)
func updateResidence(id: Int32, request: ResidenceUpdateRequest)
func deleteResidence(id: Int32)
func resetCreateState()
func resetUpdateState()
func resetDeleteState()
}
// Make Kotlin ViewModel conform
extension ComposeApp.ResidenceViewModel: ResidenceViewModelProtocol {}
// MARK: - Task ViewModel Protocol
protocol TaskViewModelProtocol {
var kanbanState: Kotlinx_coroutines_coreStateFlow { get }
var createState: Kotlinx_coroutines_coreStateFlow { get }
var updateState: Kotlinx_coroutines_coreStateFlow { get }
func loadKanbanBoard(residenceId: Int32, forceRefresh: Bool)
func createTask(request: TaskCreateRequest)
func updateTask(id: Int32, request: TaskUpdateRequest)
func cancelTask(id: Int32)
func markTaskInProgress(id: Int32)
// ... other methods
}
extension ComposeApp.TaskViewModel: TaskViewModelProtocol {}
// MARK: - Auth ViewModel Protocol
protocol AuthViewModelProtocol {
var loginState: Kotlinx_coroutines_coreStateFlow { get }
var logoutState: Kotlinx_coroutines_coreStateFlow { get }
func login(username: String, password: String)
func logout()
func resetLoginState()
}
extension ComposeApp.AuthViewModel: AuthViewModelProtocol {}
4.2 Create Simple Dependency Container
New File: iosApp/Core/Dependencies.swift
import Foundation
import ComposeApp
/// Simple dependency container for ViewModels
@MainActor
class Dependencies {
static let shared = Dependencies()
// MARK: - Kotlin ViewModel Factories
func makeResidenceViewModel() -> ResidenceViewModelProtocol {
ComposeApp.ResidenceViewModel()
}
func makeTaskViewModel() -> TaskViewModelProtocol {
ComposeApp.TaskViewModel()
}
func makeAuthViewModel() -> AuthViewModelProtocol {
ComposeApp.AuthViewModel()
}
func makeContractorViewModel() -> ComposeApp.ContractorViewModel {
ComposeApp.ContractorViewModel()
}
func makeDocumentViewModel() -> ComposeApp.DocumentViewModel {
ComposeApp.DocumentViewModel()
}
func makeProfileViewModel() -> ComposeApp.ProfileViewModel {
ComposeApp.ProfileViewModel()
}
// MARK: - For Testing
#if DEBUG
static var testInstance: Dependencies?
static var current: Dependencies {
testInstance ?? shared
}
#else
static var current: Dependencies { shared }
#endif
}
// MARK: - Environment Key
private struct DependenciesKey: EnvironmentKey {
static let defaultValue = Dependencies.shared
}
extension EnvironmentValues {
var dependencies: Dependencies {
get { self[DependenciesKey.self] }
set { self[DependenciesKey.self] = newValue }
}
}
Updated ViewModel Pattern:
@MainActor
class ResidenceViewModel: BaseViewModel {
@Published var myResidences: MyResidencesResponse?
private let sharedViewModel: ResidenceViewModelProtocol
init(sharedViewModel: ResidenceViewModelProtocol? = nil) {
self.sharedViewModel = sharedViewModel ?? Dependencies.current.makeResidenceViewModel()
super.init()
}
func loadMyResidences(forceRefresh: Bool = false) {
sharedViewModel.loadMyResidences(forceRefresh: forceRefresh)
observe(sharedViewModel.myResidencesState) { [weak self] (response: MyResidencesResponse) in
self?.myResidences = response
} resetState: { [weak self] in
// Reset if needed
}
}
}
Phase 5: Multi-Step Flow Pattern (Optional Enhancement)
5.1 Create Multi-Step Flow Protocol
Problem: PasswordResetViewModel (315 lines) manages complex multi-step flow inline.
Solution: Extract reusable multi-step flow pattern.
New File: iosApp/Core/MultiStepFlow.swift
import Foundation
import SwiftUI
protocol MultiStepFlow: ObservableObject {
associatedtype Step: Hashable & CaseIterable
var currentStep: Step { get set }
var canGoBack: Bool { get }
var canGoForward: Bool { get }
func goToNextStep()
func goToPreviousStep()
func reset()
}
extension MultiStepFlow where Step: CaseIterable, Step.AllCases: BidirectionalCollection {
var canGoBack: Bool {
currentStep != Step.allCases.first
}
var canGoForward: Bool {
currentStep != Step.allCases.last
}
func goToNextStep() {
let allCases = Array(Step.allCases)
guard let currentIndex = allCases.firstIndex(of: currentStep),
currentIndex < allCases.count - 1 else { return }
currentStep = allCases[currentIndex + 1]
}
func goToPreviousStep() {
let allCases = Array(Step.allCases)
guard let currentIndex = allCases.firstIndex(of: currentStep),
currentIndex > 0 else { return }
currentStep = allCases[currentIndex - 1]
}
func reset() {
currentStep = Step.allCases.first!
}
}
Phase 6: View Layer Refactoring
6.1 Create Reusable View Components
Problem: Views have heavy state management and duplicated patterns for loading, error handling, and form state.
Solution: Create composable view builders and state containers.
New File: iosApp/Core/ViewState.swift
import Foundation
import SwiftUI
/// Represents the state of async data loading
enum ViewState<T> {
case idle
case loading
case loaded(T)
case error(String)
var isLoading: Bool {
if case .loading = self { return true }
return false
}
var data: T? {
if case .loaded(let data) = self { return data }
return nil
}
var errorMessage: String? {
if case .error(let message) = self { return message }
return nil
}
}
/// Container for form field state
struct FormField<T> {
var value: T
var error: String?
var isDirty: Bool = false
mutating func validate(_ validator: (T) -> ValidationError?) {
if let validationError = validator(value) {
error = validationError.localizedDescription
} else {
error = nil
}
}
mutating func touch() {
isDirty = true
}
}
extension FormField where T == String {
init() {
self.value = ""
self.error = nil
self.isDirty = false
}
}
6.2 Create Loading State View Modifier
New File: iosApp/Core/LoadingOverlay.swift
import SwiftUI
struct LoadingOverlay: ViewModifier {
let isLoading: Bool
let message: String?
func body(content: Content) -> some View {
ZStack {
content
.disabled(isLoading)
if isLoading {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
if let message = message {
Text(message)
.foregroundColor(.white)
.font(.subheadline)
}
}
.padding(24)
.background(Color.black.opacity(0.7))
.cornerRadius(12)
}
}
}
}
extension View {
func loadingOverlay(isLoading: Bool, message: String? = nil) -> some View {
modifier(LoadingOverlay(isLoading: isLoading, message: message))
}
}
6.3 Create Async Content View
New File: iosApp/Core/AsyncContentView.swift
import SwiftUI
/// Reusable view for handling async content states
struct AsyncContentView<T, Content: View, Loading: View, Error: View>: View {
let state: ViewState<T>
let content: (T) -> Content
let loading: () -> Loading
let error: (String, @escaping () -> Void) -> Error
let onRetry: () -> Void
init(
state: ViewState<T>,
@ViewBuilder content: @escaping (T) -> Content,
@ViewBuilder loading: @escaping () -> Loading = { ProgressView() },
@ViewBuilder error: @escaping (String, @escaping () -> Void) -> Error,
onRetry: @escaping () -> Void
) {
self.state = state
self.content = content
self.loading = loading
self.error = error
self.onRetry = onRetry
}
var body: some View {
switch state {
case .idle:
EmptyView()
case .loading:
loading()
case .loaded(let data):
content(data)
case .error(let message):
error(message, onRetry)
}
}
}
// Convenience initializer with default error view
extension AsyncContentView where Error == DefaultErrorView {
init(
state: ViewState<T>,
@ViewBuilder content: @escaping (T) -> Content,
@ViewBuilder loading: @escaping () -> Loading = { ProgressView() },
onRetry: @escaping () -> Void
) {
self.state = state
self.content = content
self.loading = loading
self.error = { message, retry in DefaultErrorView(message: message, onRetry: retry) }
self.onRetry = onRetry
}
}
struct DefaultErrorView: View {
let message: String
let onRetry: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
Button("Retry", action: onRetry)
.buttonStyle(.bordered)
}
.padding()
}
}
6.4 Refactor Form Views Pattern
Problem: Form views like CompleteTaskView have 12+ @State properties.
Solution: Use form state containers.
Example Refactoring (CompleteTaskView):
Before:
struct CompleteTaskView: View {
@State private var completedByName: String = ""
@State private var actualCost: String = ""
@State private var notes: String = ""
@State private var rating: Int = 3
@State private var selectedItems: [PhotosPickerItem] = []
@State private var selectedImages: [UIImage] = []
@State private var completionDate: Date = Date()
@State private var showingImagePicker: Bool = false
@State private var isSubmitting: Bool = false
@State private var errorMessage: String?
// ... more state
}
After:
struct CompleteTaskFormState {
var completedByName = FormField<String>()
var actualCost = FormField<String>()
var notes = FormField<String>()
var rating: Int = 3
var completionDate: Date = Date()
var selectedImages: [UIImage] = []
var isValid: Bool {
completedByName.error == nil && actualCost.error == nil
}
mutating func validateAll() {
completedByName.validate { ValidationRules.validateRequired($0, fieldName: "Completed By") }
}
}
struct CompleteTaskView: View {
@State private var formState = CompleteTaskFormState()
@StateObject private var viewModel: TaskViewModel
// Reduced from 12+ to 2 state properties
}
6.5 Views to Refactor
| View File | Current State Props | Target | Changes |
|---|---|---|---|
Task/CompleteTaskView.swift |
12+ | 2-3 | Extract form state |
Task/AddTaskView.swift |
10+ | 2-3 | Extract form state |
Task/EditTaskView.swift |
10+ | 2-3 | Extract form state |
Residence/AddResidenceView.swift |
8+ | 2-3 | Extract form state |
Residence/EditResidenceView.swift |
8+ | 2-3 | Extract form state |
Contractor/AddContractorView.swift |
6+ | 2-3 | Extract form state |
Documents/AddDocumentView.swift |
8+ | 2-3 | Extract form state |
Login/LoginView.swift |
5 | 2 | Use AsyncContentView |
Residence/ResidencesListView.swift |
5 | 2 | Use AsyncContentView |
Implementation Order
Session 1: Core Abstractions (Highest Impact)
-
Create Core/ folder and new files:
iosApp/Core/StateFlowObserver.swiftiosApp/Core/BaseViewModel.swiftiosApp/Core/ValidationRules.swift
-
Refactor ViewModels to use StateFlowObserver:
- Start with
ResidenceViewModelas the template - Apply pattern to remaining 9 ViewModels
- Start with
-
Migrate ViewModels to inherit from BaseViewModel
Session 2: Type Conversions & Extensions
-
Create new file:
iosApp/Core/Extensions/KotlinTypeExtensions.swift
-
Update ViewModels to use new extensions:
DocumentViewModel(heaviest user)ContractorViewModelTaskViewModelResidenceViewModel
Session 3: State Management
-
Create new file:
iosApp/Core/ActionState.swift
-
Refactor TaskViewModel:
- Replace 7 boolean flags with
ActionState<TaskActionType> - Update all views that observe these flags
- Replace 7 boolean flags with
-
Apply pattern to other ViewModels (ContractorViewModel, DocumentViewModel)
Session 4: Dependency Injection
-
Create new files:
iosApp/Core/Protocols/ViewModelProtocols.swiftiosApp/Core/Dependencies.swift
-
Update ViewModels to accept protocol-based dependencies
-
Verify testability with sample mock
Session 5: View Layer Refactoring
-
Create new files:
iosApp/Core/ViewState.swiftiosApp/Core/LoadingOverlay.swiftiosApp/Core/AsyncContentView.swift
-
Create form state structs for each form view:
CompleteTaskFormStateAddTaskFormStateAddResidenceFormState- etc.
-
Refactor form views to use new state containers
-
Update list views to use
AsyncContentViewpattern
Files Summary
New Files to Create (11 files in Core/)
| File | Purpose | Priority |
|---|---|---|
Core/StateFlowObserver.swift |
Reusable StateFlow observation | P0 |
Core/BaseViewModel.swift |
Common ViewModel base class | P0 |
Core/ValidationRules.swift |
Centralized validation | P0 |
Core/Extensions/KotlinTypeExtensions.swift |
Type conversion helpers | P1 |
Core/ActionState.swift |
Action state enum | P1 |
Core/Protocols/ViewModelProtocols.swift |
Kotlin ViewModel abstractions | P2 |
Core/Dependencies.swift |
Simple DI container (factory pattern) | P2 |
Core/MultiStepFlow.swift |
Multi-step flow protocol | P3 |
Core/ViewState.swift |
View state enum + FormField | P2 |
Core/LoadingOverlay.swift |
Loading overlay modifier | P2 |
Core/AsyncContentView.swift |
Async content wrapper view | P2 |
Files to Modify (10 ViewModels)
| File | Changes | Impact |
|---|---|---|
Residence/ResidenceViewModel.swift |
Inherit BaseViewModel, use StateFlowObserver | High |
Task/TaskViewModel.swift |
Replace boolean flags, use ActionState | High |
Contractor/ContractorViewModel.swift |
Use StateFlowObserver, type extensions | Medium |
Documents/DocumentViewModel.swift |
Heavy type conversion cleanup | High |
Login/LoginViewModel.swift |
Extract validation, use base class | Medium |
Profile/ProfileViewModel.swift |
Minor cleanup | Low |
Register/RegisterView.swift |
Extract validation | Low |
VerifyEmail/VerifyEmailViewModel.swift |
Use StateFlowObserver | Low |
PasswordReset/PasswordResetViewModel.swift |
Validation, possibly MultiStepFlow | Medium |
Views to Modify (9 form/list views)
| File | Changes | Impact |
|---|---|---|
Task/CompleteTaskView.swift |
Extract form state | High |
Task/AddTaskView.swift |
Extract form state | High |
Task/EditTaskView.swift |
Extract form state | Medium |
Residence/AddResidenceView.swift |
Extract form state | Medium |
Residence/EditResidenceView.swift |
Extract form state | Medium |
Residence/ResidencesListView.swift |
Use AsyncContentView | Medium |
Contractor/AddContractorView.swift |
Extract form state | Low |
Documents/AddDocumentView.swift |
Extract form state | Low |
Login/LoginView.swift |
Use AsyncContentView | Low |
Expected Outcomes
| Metric | Before | After | Improvement |
|---|---|---|---|
| Total Lines | ~13,837 | ~11,800 | -15% |
| Duplicated Patterns | 26+ | 0 | -100% |
| ViewModel Boilerplate | ~200 lines each | ~80 lines each | -60% |
| View State Properties | 8-12 per form | 2-3 per form | -75% |
| Type Conversion Lines | ~150 | ~30 | -80% |
| Unit Test Coverage | 0% | Possible | Enabled |
| Validation Sources | 4+ files | 1 file | Centralized |
Risk Mitigation
- Incremental Approach: Each phase is independently deployable
- Template First: Refactor
ResidenceViewModelcompletely as template before others - Testing: Run app after each ViewModel refactor to catch regressions
- Git Strategy: Commit after each major refactor step
- View Testing: Test each form after state extraction to ensure bindings work
Final Core/ Folder Structure
iosApp/Core/
├── StateFlowObserver.swift # Kotlin StateFlow observation utility
├── BaseViewModel.swift # Base class for all ViewModels
├── ValidationRules.swift # Centralized validation logic
├── ActionState.swift # Generic action state enum
├── ViewState.swift # View state + FormField
├── LoadingOverlay.swift # Loading overlay modifier
├── AsyncContentView.swift # Async content wrapper
├── MultiStepFlow.swift # Multi-step flow protocol
├── Dependencies.swift # Simple factory-based DI
├── Extensions/
│ └── KotlinTypeExtensions.swift
└── Protocols/
└── ViewModelProtocols.swift