Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1214 lines
35 KiB
Markdown
1214 lines
35 KiB
Markdown
# iOS Codebase Refactoring Plan
|
|
|
|
## Overview
|
|
|
|
Refactor the HoneyDue 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**: `HoneyDueKMM/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
|
|
|
|
1. **BaseViewModel Skipped**: Per user preference, ViewModels continue to extend `ObservableObject` directly
|
|
2. **Protocol Simplification**: Full ViewModel protocols removed due to Kotlin-generated API parameter label mismatches; only `TokenStorageProtocol` retained
|
|
3. **Form State Containers**: Created as standalone structs that can be adopted incrementally by views
|
|
4. **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
|
|
|
|
- [x] ResidenceViewModel - StateFlowObserver + DI
|
|
- [x] TaskViewModel - StateFlowObserver + ActionState + DI
|
|
- [x] ContractorViewModel - StateFlowObserver + DI
|
|
- [x] DocumentViewModel - StateFlowObserver + DI
|
|
- [x] LoginViewModel - StateFlowObserver + DI
|
|
- [x] ProfileViewModel - StateFlowObserver + DI
|
|
- [x] RegisterViewModel - StateFlowObserver + ValidationRules + DI
|
|
- [x] VerifyEmailViewModel - StateFlowObserver + DI
|
|
- [x] PasswordResetViewModel - StateFlowObserver + ValidationRules + DI
|
|
|
|
### Views Updated with ListAsyncContentView
|
|
|
|
- [x] ResidencesListView - Now uses `ListAsyncContentView` with extracted `ResidencesContent`
|
|
- [x] ContractorsListView - Now uses `ListAsyncContentView` with extracted `ContractorsContent`
|
|
- [x] WarrantiesTabContent - Now uses `ListAsyncContentView` with extracted `WarrantiesListContent`
|
|
- [x] DocumentsTabContent - Now uses `ListAsyncContentView` with extracted `DocumentsListContent`
|
|
|
|
---
|
|
|
|
## 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`
|
|
|
|
```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.swift`
|
|
- `Task/TaskViewModel.swift`
|
|
- `Contractor/ContractorViewModel.swift`
|
|
- `Documents/DocumentViewModel.swift`
|
|
- `Login/LoginViewModel.swift`
|
|
- `Profile/ProfileViewModel.swift`
|
|
- `Register/RegisterView.swift`
|
|
- `VerifyEmail/VerifyEmailViewModel.swift`
|
|
- `PasswordReset/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`
|
|
|
|
```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 `BaseViewModel` instead of `ObservableObject`
|
|
|
|
---
|
|
|
|
### 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`
|
|
|
|
```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.swift`
|
|
- `Register/RegisterView.swift`
|
|
- `PasswordReset/PasswordResetViewModel.swift`
|
|
- `Profile/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`
|
|
|
|
```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.swift`
|
|
- `Contractor/ContractorViewModel.swift`
|
|
- `Task/TaskViewModel.swift`
|
|
- `Residence/ResidenceViewModel.swift`
|
|
|
|
**Before**:
|
|
```swift
|
|
residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil,
|
|
isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil,
|
|
```
|
|
|
|
**After**:
|
|
```swift
|
|
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`
|
|
|
|
```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**:
|
|
```swift
|
|
@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**:
|
|
```swift
|
|
@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`
|
|
|
|
```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`
|
|
|
|
```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**:
|
|
|
|
```swift
|
|
@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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```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`
|
|
|
|
```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**:
|
|
```swift
|
|
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**:
|
|
```swift
|
|
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)
|
|
|
|
1. **Create Core/ folder and new files**:
|
|
- `iosApp/Core/StateFlowObserver.swift`
|
|
- `iosApp/Core/BaseViewModel.swift`
|
|
- `iosApp/Core/ValidationRules.swift`
|
|
|
|
2. **Refactor ViewModels to use StateFlowObserver**:
|
|
- Start with `ResidenceViewModel` as the template
|
|
- Apply pattern to remaining 9 ViewModels
|
|
|
|
3. **Migrate ViewModels to inherit from BaseViewModel**
|
|
|
|
### Session 2: Type Conversions & Extensions
|
|
|
|
1. **Create new file**:
|
|
- `iosApp/Core/Extensions/KotlinTypeExtensions.swift`
|
|
|
|
2. **Update ViewModels to use new extensions**:
|
|
- `DocumentViewModel` (heaviest user)
|
|
- `ContractorViewModel`
|
|
- `TaskViewModel`
|
|
- `ResidenceViewModel`
|
|
|
|
### Session 3: State Management
|
|
|
|
1. **Create new file**:
|
|
- `iosApp/Core/ActionState.swift`
|
|
|
|
2. **Refactor TaskViewModel**:
|
|
- Replace 7 boolean flags with `ActionState<TaskActionType>`
|
|
- Update all views that observe these flags
|
|
|
|
3. **Apply pattern to other ViewModels** (ContractorViewModel, DocumentViewModel)
|
|
|
|
### Session 4: Dependency Injection
|
|
|
|
1. **Create new files**:
|
|
- `iosApp/Core/Protocols/ViewModelProtocols.swift`
|
|
- `iosApp/Core/Dependencies.swift`
|
|
|
|
2. **Update ViewModels to accept protocol-based dependencies**
|
|
|
|
3. **Verify testability** with sample mock
|
|
|
|
### Session 5: View Layer Refactoring
|
|
|
|
1. **Create new files**:
|
|
- `iosApp/Core/ViewState.swift`
|
|
- `iosApp/Core/LoadingOverlay.swift`
|
|
- `iosApp/Core/AsyncContentView.swift`
|
|
|
|
2. **Create form state structs** for each form view:
|
|
- `CompleteTaskFormState`
|
|
- `AddTaskFormState`
|
|
- `AddResidenceFormState`
|
|
- etc.
|
|
|
|
3. **Refactor form views** to use new state containers
|
|
|
|
4. **Update list views** to use `AsyncContentView` pattern
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
1. **Incremental Approach**: Each phase is independently deployable
|
|
2. **Template First**: Refactor `ResidenceViewModel` completely as template before others
|
|
3. **Testing**: Run app after each ViewModel refactor to catch regressions
|
|
4. **Git Strategy**: Commit after each major refactor step
|
|
5. **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
|
|
```
|