diff --git a/iosApp/REFACTORING_PLAN.md b/iosApp/REFACTORING_PLAN.md new file mode 100644 index 0000000..dcdfef9 --- /dev/null +++ b/iosApp/REFACTORING_PLAN.md @@ -0,0 +1,1213 @@ +# 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 + +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( + _ 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 { + 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( + _ stateFlow: Kotlinx_coroutines_coreStateFlow, + isLoading: Binding, + errorMessage: Binding, + 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( + _ 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: 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, rhs: ActionState) -> 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 + +extension ActionState where T == Void { + static var success: ActionState { .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 = .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 { + 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 { + 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: View { + let state: ViewState + let content: (T) -> Content + let loading: () -> Loading + let error: (String, @escaping () -> Void) -> Error + let onRetry: () -> Void + + init( + state: ViewState, + @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, + @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() + var actualCost = FormField() + var notes = FormField() + 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` + - 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 +``` diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index 19d9977..d405906 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -341,7 +341,7 @@ struct ContractorFormSheet: View { city: city.isEmpty ? nil : city, state: state.isEmpty ? nil : state, zipCode: zipCode.isEmpty ? nil : zipCode, - isFavorite: isFavorite.toKotlinBoolean(), + isFavorite: isFavorite.asKotlin, isActive: nil, notes: notes.isEmpty ? nil : notes ) diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift index a227273..5b69d4f 100644 --- a/iosApp/iosApp/Contractor/ContractorViewModel.swift +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -16,11 +16,10 @@ class ContractorViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.ContractorViewModel - private var cancellables = Set() // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.ContractorViewModel() + init(sharedViewModel: ComposeApp.ContractorViewModel? = nil) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.ContractorViewModel() } // MARK: - Public Methods @@ -36,34 +35,24 @@ class ContractorViewModel: ObservableObject { sharedViewModel.loadContractors( specialty: specialty, - isFavorite: isFavorite?.toKotlinBoolean(), - isActive: isActive?.toKotlinBoolean(), + isFavorite: isFavorite.asKotlin, + isActive: isActive.asKotlin, search: search, forceRefresh: forceRefresh ) - // Observe the state - Task { - for await state in sharedViewModel.contractorsState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.contractors = success.data as? [ContractorSummary] ?? [] - 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.observe( + sharedViewModel.contractorsState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (data: NSArray) in + self?.contractors = data as? [ContractorSummary] ?? [] + self?.isLoading = false + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false } - } + ) } func loadContractorDetail(id: Int32) { @@ -72,28 +61,14 @@ class ContractorViewModel: ObservableObject { sharedViewModel.loadContractorDetail(id: id) - // Observe the state - Task { - for await state in sharedViewModel.contractorDetailState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.selectedContractor = 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.contractorDetailState, + loadingSetter: { [weak self] in self?.isLoading = $0 }, + errorSetter: { [weak self] in self?.errorMessage = $0 }, + onSuccess: { [weak self] (data: Contractor) in + self?.selectedContractor = data } - } + ) } func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) { @@ -102,32 +77,21 @@ class ContractorViewModel: ObservableObject { sharedViewModel.createContractor(request: request) - // Observe the state - Task { - for await state in sharedViewModel.createState { - if state is ApiResultLoading { - await MainActor.run { - self.isCreating = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.successMessage = "Contractor added successfully" - self.isCreating = false - } - sharedViewModel.resetCreateState() - completion(true) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isCreating = false - } - sharedViewModel.resetCreateState() - completion(false) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.createState, + onLoading: { [weak self] in self?.isCreating = true }, + onSuccess: { [weak self] (_: Contractor) in + self?.successMessage = "Contractor added successfully" + self?.isCreating = false + completion(true) + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isCreating = false + completion(false) + }, + resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } + ) } func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) { @@ -136,32 +100,21 @@ class ContractorViewModel: ObservableObject { sharedViewModel.updateContractor(id: id, request: request) - // Observe the state - Task { - for await state in sharedViewModel.updateState { - if state is ApiResultLoading { - await MainActor.run { - self.isUpdating = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.successMessage = "Contractor updated successfully" - self.isUpdating = false - } - sharedViewModel.resetUpdateState() - completion(true) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isUpdating = false - } - sharedViewModel.resetUpdateState() - completion(false) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.updateState, + onLoading: { [weak self] in self?.isUpdating = true }, + onSuccess: { [weak self] (_: Contractor) in + self?.successMessage = "Contractor updated successfully" + self?.isUpdating = false + completion(true) + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isUpdating = false + completion(false) + }, + resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } + ) } func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) { @@ -170,54 +123,37 @@ class ContractorViewModel: ObservableObject { sharedViewModel.deleteContractor(id: id) - // Observe the state - Task { - for await state in sharedViewModel.deleteState { - if state is ApiResultLoading { - await MainActor.run { - self.isDeleting = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.successMessage = "Contractor deleted successfully" - self.isDeleting = false - } - sharedViewModel.resetDeleteState() - completion(true) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isDeleting = false - } - sharedViewModel.resetDeleteState() - completion(false) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.deleteState, + onLoading: { [weak self] in self?.isDeleting = true }, + onSuccess: { [weak self] (_: KotlinUnit) in + self?.successMessage = "Contractor deleted successfully" + self?.isDeleting = false + completion(true) + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isDeleting = false + completion(false) + }, + resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } + ) } func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) { sharedViewModel.toggleFavorite(id: id) - // Observe the state - Task { - for await state in sharedViewModel.toggleFavoriteState { - if state is ApiResultSuccess { - sharedViewModel.resetToggleFavoriteState() - completion(true) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - } - sharedViewModel.resetToggleFavoriteState() - completion(false) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.toggleFavoriteState, + onSuccess: { (_: Contractor) in + completion(true) + }, + onError: { [weak self] error in + self?.errorMessage = error + completion(false) + }, + resetState: { [weak self] in self?.sharedViewModel.resetToggleFavoriteState() } + ) } func clearMessages() { @@ -226,9 +162,3 @@ class ContractorViewModel: ObservableObject { } } -// MARK: - Helper Extension -extension Bool { - func toKotlinBoolean() -> KotlinBoolean { - return KotlinBoolean(bool: self) - } -} diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 97772f5..f08f61f 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -60,61 +60,38 @@ struct ContractorsListView: View { .padding(.vertical, AppSpacing.xs) } - // Content - if contractors.isEmpty && viewModel.isLoading { - Spacer() - ProgressView() - .scaleEffect(1.2) - Spacer() - } else if let error = viewModel.errorMessage { - Spacer() - ErrorView( - message: error, - retryAction: { loadContractors() } + // Content + ListAsyncContentView( + items: contractors, + isLoading: viewModel.isLoading, + errorMessage: viewModel.errorMessage, + content: { contractorList in + ContractorsContent( + contractors: contractorList, + onToggleFavorite: toggleFavorite ) - Spacer() - } else if contractors.isEmpty { - Spacer() + }, + emptyContent: { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { - // User can add contractors (limit > 0) - show empty state EmptyContractorsView( hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty ) } else { - // User is blocked (limit = 0) - show upgrade prompt UpgradeFeatureView( triggerKey: "view_contractors", icon: "person.2.fill" ) } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: AppSpacing.sm) { - ForEach(filteredContractors, id: \.id) { contractor in - NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { - ContractorCard( - contractor: contractor, - onToggleFavorite: { - toggleFavorite(contractor.id) - } - ) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(AppSpacing.md) - .padding(.bottom, AppSpacing.xxxl) - } - .refreshable { - loadContractors(forceRefresh: true) - } - .safeAreaInset(edge: .bottom) { - Color.clear.frame(height: 0) - } + }, + onRefresh: { + loadContractors(forceRefresh: true) + }, + onRetry: { + loadContractors() } - } + ) } + } .navigationTitle("Contractors") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -190,10 +167,6 @@ struct ContractorsListView: View { .onChange(of: searchText) { newValue in loadContractors() } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { loadContractors() } - ) } private func loadContractors(forceRefresh: Bool = false) { @@ -285,6 +258,36 @@ struct FilterChip: View { } } +// MARK: - Contractors Content + +private struct ContractorsContent: View { + let contractors: [ContractorSummary] + let onToggleFavorite: (Int32) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: AppSpacing.sm) { + ForEach(contractors, id: \.id) { contractor in + NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { + ContractorCard( + contractor: contractor, + onToggleFavorite: { + onToggleFavorite(contractor.id) + } + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(AppSpacing.md) + .padding(.bottom, AppSpacing.xxxl) + } + .safeAreaInset(edge: .bottom) { + Color.clear.frame(height: 0) + } + } +} + // MARK: - Empty State struct EmptyContractorsView: View { let hasFilters: Bool diff --git a/iosApp/iosApp/Core/ActionState.swift b/iosApp/iosApp/Core/ActionState.swift new file mode 100644 index 0000000..bacb064 --- /dev/null +++ b/iosApp/iosApp/Core/ActionState.swift @@ -0,0 +1,129 @@ +import Foundation + +// MARK: - Generic Action State + +/// A generic state machine for tracking async action states. +/// Replaces multiple boolean flags (isCreating, isUpdating, isDeleting, etc.) +/// with a single, type-safe state property. +enum ActionState: Equatable { + case idle + case loading(ActionType) + case success(ActionType) + case error(ActionType, String) + + // MARK: - Convenience Properties + + var isLoading: Bool { + if case .loading = self { return true } + return false + } + + var isSuccess: Bool { + if case .success = self { return true } + return false + } + + var isError: Bool { + if case .error = self { return true } + return false + } + + var errorMessage: String? { + if case .error(_, let message) = self { return message } + return nil + } + + var currentAction: ActionType? { + switch self { + case .idle: + return nil + case .loading(let action), .success(let action), .error(let action, _): + return action + } + } + + // MARK: - Action-Specific Checks + + /// Check if a specific action is currently loading + func isLoading(_ action: ActionType) -> Bool { + if case .loading(let currentAction) = self { + return currentAction == action + } + return false + } + + /// Check if a specific action completed successfully + func isSuccess(_ action: ActionType) -> Bool { + if case .success(let currentAction) = self { + return currentAction == action + } + return false + } + + /// Check if a specific action failed + func isError(_ action: ActionType) -> Bool { + if case .error(let currentAction, _) = self { + return currentAction == action + } + return false + } +} + +// MARK: - Task Action Types + +/// Action types for TaskViewModel +enum TaskActionType: Equatable { + case create + case update + case cancel + case uncancel + case markInProgress + case archive + case unarchive +} + +// MARK: - Contractor Action Types + +/// Action types for ContractorViewModel +enum ContractorActionType: Equatable { + case create + case update + case delete + case toggleFavorite +} + +// MARK: - Document Action Types + +/// Action types for DocumentViewModel +enum DocumentActionType: Equatable { + case create + case update + case delete + case download +} + +// MARK: - Residence Action Types + +/// Action types for ResidenceViewModel +enum ResidenceActionType: Equatable { + case create + case update + case delete + case join + case leave + case generateReport +} + +// MARK: - Auth Action Types + +/// Action types for authentication operations +enum AuthActionType: Equatable { + case login + case logout + case register + case verifyEmail + case resendVerification + case forgotPassword + case resetPassword + case updateProfile +} diff --git a/iosApp/iosApp/Core/AsyncContentView.swift b/iosApp/iosApp/Core/AsyncContentView.swift new file mode 100644 index 0000000..e040ed8 --- /dev/null +++ b/iosApp/iosApp/Core/AsyncContentView.swift @@ -0,0 +1,284 @@ +import SwiftUI + +// MARK: - Async Content View + +/// A reusable view for handling async content states (loading, success, error) +struct AsyncContentView: View { + let state: ViewState + let content: (T) -> Content + let loading: () -> Loading + let error: (String, @escaping () -> Void) -> Error + let onRetry: () -> Void + + init( + state: ViewState, + @ViewBuilder content: @escaping (T) -> Content, + @ViewBuilder loading: @escaping () -> Loading, + @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) + } + } +} + +// MARK: - Convenience Initializers + +extension AsyncContentView where Loading == DefaultLoadingView { + /// Initialize with default loading view + init( + state: ViewState, + @ViewBuilder content: @escaping (T) -> Content, + @ViewBuilder error: @escaping (String, @escaping () -> Void) -> Error, + onRetry: @escaping () -> Void + ) { + self.state = state + self.content = content + self.loading = { DefaultLoadingView() } + self.error = error + self.onRetry = onRetry + } +} + +extension AsyncContentView where Error == DefaultErrorView { + /// Initialize with default error view + init( + state: ViewState, + @ViewBuilder content: @escaping (T) -> Content, + @ViewBuilder loading: @escaping () -> Loading, + 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 + } +} + +extension AsyncContentView where Loading == DefaultLoadingView, Error == DefaultErrorView { + /// Initialize with default loading and error views + init( + state: ViewState, + @ViewBuilder content: @escaping (T) -> Content, + onRetry: @escaping () -> Void + ) { + self.state = state + self.content = content + self.loading = { DefaultLoadingView() } + self.error = { message, retry in DefaultErrorView(message: message, onRetry: retry) } + self.onRetry = onRetry + } +} + +// MARK: - Default Loading View + +struct DefaultLoadingView: View { + var body: some View { + VStack(spacing: 12) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.2) + + Text("Loading...") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Default Error View + +struct DefaultErrorView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(Color.appError) + + Text("Something went wrong") + .font(.headline) + .foregroundColor(Color.appTextPrimary) + + Text(message) + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(action: onRetry) { + Label("Try Again", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .tint(Color.appPrimary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +// MARK: - Async Empty State View + +struct AsyncEmptyStateView: View { + let icon: String + let title: String + let subtitle: String? + let actionLabel: String? + let action: (() -> Void)? + + init( + icon: String, + title: String, + subtitle: String? = nil, + actionLabel: String? = nil, + action: (() -> Void)? = nil + ) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.actionLabel = actionLabel + self.action = action + } + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 60)) + .foregroundColor(Color.appTextSecondary.opacity(0.5)) + + Text(title) + .font(.headline) + .foregroundColor(Color.appTextPrimary) + + if let subtitle = subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + + if let actionLabel = actionLabel, let action = action { + Button(action: action) { + Text(actionLabel) + } + .buttonStyle(.borderedProminent) + .tint(Color.appPrimary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +// MARK: - List Async Content View + +/// Specialized async content view for lists with pull-to-refresh support +struct ListAsyncContentView: View { + let items: [T] + let isLoading: Bool + let errorMessage: String? + let content: ([T]) -> Content + let emptyContent: () -> EmptyContent + let onRefresh: () -> Void + let onRetry: () -> Void + + init( + items: [T], + isLoading: Bool, + errorMessage: String?, + @ViewBuilder content: @escaping ([T]) -> Content, + @ViewBuilder emptyContent: @escaping () -> EmptyContent, + onRefresh: @escaping () -> Void, + onRetry: @escaping () -> Void + ) { + self.items = items + self.isLoading = isLoading + self.errorMessage = errorMessage + self.content = content + self.emptyContent = emptyContent + self.onRefresh = onRefresh + self.onRetry = onRetry + } + + var body: some View { + Group { + if let errorMessage = errorMessage, items.isEmpty { + DefaultErrorView(message: errorMessage, onRetry: onRetry) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if items.isEmpty && !isLoading { + emptyContent() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + content(items) + } + } + .overlay { + if isLoading && items.isEmpty { + DefaultLoadingView() + } + } + .refreshable { + onRefresh() + } + } +} + +// MARK: - Preview + +#if DEBUG +struct AsyncContentView_Previews: PreviewProvider { + static var previews: some View { + Group { + AsyncContentView( + state: ViewState.loading, + content: { data in Text(data) }, + onRetry: {} + ) + .previewDisplayName("Loading") + + AsyncContentView( + state: ViewState.loaded("Hello, World!"), + content: { data in Text(data) }, + onRetry: {} + ) + .previewDisplayName("Loaded") + + AsyncContentView( + state: ViewState.error("Network connection failed"), + content: { data in Text(data) }, + onRetry: {} + ) + .previewDisplayName("Error") + + AsyncEmptyStateView( + icon: "tray", + title: "No Items", + subtitle: "Add your first item to get started", + actionLabel: "Add Item", + action: {} + ) + .previewDisplayName("Empty State") + } + } +} +#endif diff --git a/iosApp/iosApp/Core/Dependencies.swift b/iosApp/iosApp/Core/Dependencies.swift new file mode 100644 index 0000000..36c0b9c --- /dev/null +++ b/iosApp/iosApp/Core/Dependencies.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftUI +import ComposeApp + +/// Simple factory-based dependency container for ViewModels and services. +/// Enables unit testing by allowing mock implementations to be injected. +/// +/// Usage in production: +/// ```swift +/// let authVM = Dependencies.current.makeAuthViewModel() +/// ``` +/// +/// Usage in tests: +/// ```swift +/// Dependencies.testInstance = MockDependencies() +/// ``` +@MainActor +final class Dependencies { + // MARK: - Singleton + + static let shared = Dependencies() + + // MARK: - Test Support + + #if DEBUG + /// Override with a mock instance for testing + static var testInstance: Dependencies? + + /// Returns test instance if available, otherwise shared instance + static var current: Dependencies { + testInstance ?? shared + } + #else + static var current: Dependencies { shared } + #endif + + // MARK: - Private Init + + private init() {} + + // MARK: - Kotlin ViewModel Factories + + /// Create a new AuthViewModel instance + func makeAuthViewModel() -> ComposeApp.AuthViewModel { + ComposeApp.AuthViewModel() + } + + /// Create a new ResidenceViewModel instance + func makeResidenceViewModel() -> ComposeApp.ResidenceViewModel { + ComposeApp.ResidenceViewModel() + } + + /// Create a new TaskViewModel instance + func makeTaskViewModel() -> ComposeApp.TaskViewModel { + ComposeApp.TaskViewModel() + } + + /// Create a new ContractorViewModel instance + func makeContractorViewModel() -> ComposeApp.ContractorViewModel { + ComposeApp.ContractorViewModel() + } + + /// Create a new DocumentViewModel instance + func makeDocumentViewModel() -> ComposeApp.DocumentViewModel { + ComposeApp.DocumentViewModel() + } + + // MARK: - Service Factories + + /// Get the shared TokenStorage instance + func makeTokenStorage() -> TokenStorageProtocol { + TokenStorage.shared + } +} + +// MARK: - SwiftUI Environment Integration + +private struct DependenciesKey: EnvironmentKey { + @MainActor + static let defaultValue = Dependencies.shared +} + +extension EnvironmentValues { + var dependencies: Dependencies { + get { self[DependenciesKey.self] } + set { self[DependenciesKey.self] = newValue } + } +} + +// MARK: - Mock Support for Testing + +#if DEBUG +// To mock dependencies in tests, set Dependencies.testInstance to a new Dependencies() +// instance and override its factory methods using subclassing or protocol-based mocking. +// Since Dependencies is final for safety, use composition or protocol mocking instead. +#endif diff --git a/iosApp/iosApp/Core/Extensions/KotlinTypeExtensions.swift b/iosApp/iosApp/Core/Extensions/KotlinTypeExtensions.swift new file mode 100644 index 0000000..eeede34 --- /dev/null +++ b/iosApp/iosApp/Core/Extensions/KotlinTypeExtensions.swift @@ -0,0 +1,104 @@ +import Foundation +import ComposeApp + +// MARK: - Bool to KotlinBoolean + +extension Bool { + /// Convert Swift Bool to Kotlin Boolean + var asKotlin: KotlinBoolean { + KotlinBoolean(bool: self) + } +} + +extension Optional where Wrapped == Bool { + /// Convert optional Swift Bool to optional Kotlin Boolean + var asKotlin: KotlinBoolean? { + self.map { KotlinBoolean(bool: $0) } + } +} + +// MARK: - Int to KotlinInt + +extension Int { + /// Convert Swift Int to Kotlin Int + var asKotlin: KotlinInt { + KotlinInt(integerLiteral: self) + } +} + +extension Int32 { + /// Convert Swift Int32 to Kotlin Int + var asKotlin: KotlinInt { + KotlinInt(integerLiteral: Int(self)) + } +} + +extension Optional where Wrapped == Int { + /// Convert optional Swift Int to optional Kotlin Int + var asKotlin: KotlinInt? { + self.map { KotlinInt(integerLiteral: $0) } + } +} + +extension Optional where Wrapped == Int32 { + /// Convert optional Swift Int32 to optional Kotlin Int + var asKotlin: KotlinInt? { + self.map { KotlinInt(integerLiteral: Int($0)) } + } +} + +// MARK: - Double to KotlinDouble + +extension Double { + /// Convert Swift Double to Kotlin Double + var asKotlin: KotlinDouble { + KotlinDouble(double: self) + } +} + +extension Optional where Wrapped == Double { + /// Convert optional Swift Double to optional Kotlin Double + var asKotlin: KotlinDouble? { + self.map { KotlinDouble(double: $0) } + } +} + +// MARK: - String to Kotlin Number Types (for form inputs) + +extension String { + /// Parse String to optional Double + var asOptionalDouble: Double? { + Double(self) + } + + /// Parse String to optional KotlinDouble (for API calls) + var asKotlinDouble: KotlinDouble? { + Double(self).map { KotlinDouble(double: $0) } + } + + /// Parse String to optional Int + var asOptionalInt: Int? { + Int(self) + } + + /// Parse String to optional KotlinInt (for API calls) + var asKotlinInt: KotlinInt? { + Int(self).map { KotlinInt(integerLiteral: $0) } + } +} + +// MARK: - Int64 to KotlinLong + +extension Int64 { + /// Convert Swift Int64 to Kotlin Long + var asKotlin: KotlinLong { + KotlinLong(longLong: self) + } +} + +extension Optional where Wrapped == Int64 { + /// Convert optional Swift Int64 to optional Kotlin Long + var asKotlin: KotlinLong? { + self.map { KotlinLong(longLong: $0) } + } +} diff --git a/iosApp/iosApp/Core/FormStates/ContractorFormState.swift b/iosApp/iosApp/Core/FormStates/ContractorFormState.swift new file mode 100644 index 0000000..33a0e91 --- /dev/null +++ b/iosApp/iosApp/Core/FormStates/ContractorFormState.swift @@ -0,0 +1,105 @@ +import Foundation +import SwiftUI +import ComposeApp + +// MARK: - Contractor Form State + +/// Form state container for creating/editing a contractor +struct ContractorFormState: FormState { + var name = FormField() + var company = FormField() + var phone = FormField() + var email = FormField() + var secondaryPhone = FormField() + var specialty = FormField() + var licenseNumber = FormField() + var website = FormField() + var address = FormField() + var city = FormField() + var state = FormField() + var zipCode = FormField() + var notes = FormField() + var isFavorite: Bool = false + + // For edit mode + var existingContractorId: Int32? + + var isEditMode: Bool { + existingContractorId != nil + } + + var isValid: Bool { + !name.isEmpty + } + + mutating func validateAll() { + name.validate { ValidationRules.validateRequired($0, fieldName: "Name") } + + // Optional email validation + if !email.isEmpty { + email.validate { value in + ValidationRules.isValidEmail(value) ? nil : .invalidEmail + } + } + } + + mutating func reset() { + name = FormField() + company = FormField() + phone = FormField() + email = FormField() + secondaryPhone = FormField() + specialty = FormField() + licenseNumber = FormField() + website = FormField() + address = FormField() + city = FormField() + state = FormField() + zipCode = FormField() + notes = FormField() + isFavorite = false + existingContractorId = nil + } + + /// Create ContractorCreateRequest from form state + func toCreateRequest() -> ContractorCreateRequest { + ContractorCreateRequest( + name: name.trimmedValue, + company: company.isEmpty ? nil : company.trimmedValue, + phone: phone.isEmpty ? nil : phone.trimmedValue, + email: email.isEmpty ? nil : email.trimmedValue, + secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone.trimmedValue, + specialty: specialty.isEmpty ? nil : specialty.trimmedValue, + licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber.trimmedValue, + website: website.isEmpty ? nil : website.trimmedValue, + address: address.isEmpty ? nil : address.trimmedValue, + city: city.isEmpty ? nil : city.trimmedValue, + state: state.isEmpty ? nil : state.trimmedValue, + zipCode: zipCode.isEmpty ? nil : zipCode.trimmedValue, + isFavorite: isFavorite, + isActive: true, + notes: notes.isEmpty ? nil : notes.trimmedValue + ) + } + + /// Create ContractorUpdateRequest from form state + func toUpdateRequest() -> ContractorUpdateRequest { + ContractorUpdateRequest( + name: name.isEmpty ? nil : name.trimmedValue, + company: company.isEmpty ? nil : company.trimmedValue, + phone: phone.isEmpty ? nil : phone.trimmedValue, + email: email.isEmpty ? nil : email.trimmedValue, + secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone.trimmedValue, + specialty: specialty.isEmpty ? nil : specialty.trimmedValue, + licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber.trimmedValue, + website: website.isEmpty ? nil : website.trimmedValue, + address: address.isEmpty ? nil : address.trimmedValue, + city: city.isEmpty ? nil : city.trimmedValue, + state: state.isEmpty ? nil : state.trimmedValue, + zipCode: zipCode.isEmpty ? nil : zipCode.trimmedValue, + isFavorite: isFavorite.asKotlin, + isActive: nil, + notes: notes.isEmpty ? nil : notes.trimmedValue + ) + } +} diff --git a/iosApp/iosApp/Core/FormStates/DocumentFormState.swift b/iosApp/iosApp/Core/FormStates/DocumentFormState.swift new file mode 100644 index 0000000..96f79ac --- /dev/null +++ b/iosApp/iosApp/Core/FormStates/DocumentFormState.swift @@ -0,0 +1,105 @@ +import Foundation +import SwiftUI +import ComposeApp + +// MARK: - Document Form State + +/// Form state container for creating/editing a document +struct DocumentFormState: FormState { + var title = FormField() + var description = FormField() + var category = FormField() + var tags = FormField() + var notes = FormField() + var selectedDocumentType: String? + var selectedResidenceId: Int32? + var selectedContractorId: Int32? + var isActive: Bool = true + + // For warranties/appliances + var itemName = FormField() + var modelNumber = FormField() + var serialNumber = FormField() + var provider = FormField() + var providerContact = FormField() + var claimPhone = FormField() + var claimEmail = FormField() + var claimWebsite = FormField() + + // Dates + var purchaseDate: Date? + var startDate: Date? + var endDate: Date? + + // Images + var selectedImages: [UIImage] = [] + + // For edit mode + var existingDocumentId: Int32? + + var isEditMode: Bool { + existingDocumentId != nil + } + + var isValid: Bool { + !title.isEmpty && + selectedDocumentType != nil && + (isEditMode || selectedResidenceId != nil) + } + + mutating func validateAll() { + title.validate { ValidationRules.validateRequired($0, fieldName: "Title") } + + // Validate email if provided + if !claimEmail.isEmpty { + claimEmail.validate { value in + ValidationRules.isValidEmail(value) ? nil : .invalidEmail + } + } + } + + mutating func reset() { + title = FormField() + description = FormField() + category = FormField() + tags = FormField() + notes = FormField() + selectedDocumentType = nil + selectedResidenceId = nil + selectedContractorId = nil + isActive = true + itemName = FormField() + modelNumber = FormField() + serialNumber = FormField() + provider = FormField() + providerContact = FormField() + claimPhone = FormField() + claimEmail = FormField() + claimWebsite = FormField() + purchaseDate = nil + startDate = nil + endDate = nil + selectedImages = [] + existingDocumentId = nil + } + + // MARK: - Date Formatting + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } + + var purchaseDateString: String? { + purchaseDate.map { dateFormatter.string(from: $0) } + } + + var startDateString: String? { + startDate.map { dateFormatter.string(from: $0) } + } + + var endDateString: String? { + endDate.map { dateFormatter.string(from: $0) } + } +} diff --git a/iosApp/iosApp/Core/FormStates/ResidenceFormState.swift b/iosApp/iosApp/Core/FormStates/ResidenceFormState.swift new file mode 100644 index 0000000..f1a96dc --- /dev/null +++ b/iosApp/iosApp/Core/FormStates/ResidenceFormState.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftUI +import ComposeApp + +// MARK: - Residence Form State + +/// Form state container for creating/editing a residence +struct ResidenceFormState: FormState { + var name = FormField() + var address = FormField() + var city = FormField() + var state = FormField() + var zipCode = FormField() + var country = FormField(initialValue: "USA") + var selectedResidenceTypeId: Int32? + var purchasePrice = FormField() + var purchaseDate: Date? + var squareFootage = FormField() + var lotSize = FormField() + var yearBuilt = FormField() + var bedrooms = FormField() + var bathrooms = FormField() + var notes = FormField() + + // For edit mode + var existingResidenceId: Int32? + + var isEditMode: Bool { + existingResidenceId != nil + } + + var isValid: Bool { + !name.isEmpty && selectedResidenceTypeId != nil + } + + mutating func validateAll() { + name.validate { ValidationRules.validateRequired($0, fieldName: "Name") } + } + + mutating func reset() { + name = FormField() + address = FormField() + city = FormField() + state = FormField() + zipCode = FormField() + country = FormField(initialValue: "USA") + selectedResidenceTypeId = nil + purchasePrice = FormField() + purchaseDate = nil + squareFootage = FormField() + lotSize = FormField() + yearBuilt = FormField() + bedrooms = FormField() + bathrooms = FormField() + notes = FormField() + existingResidenceId = nil + } + + // MARK: - Computed Properties for API Calls + + var purchasePriceValue: Double? { purchasePrice.value.asOptionalDouble } + var squareFootageValue: Int? { squareFootage.value.asOptionalInt } + var lotSizeValue: Double? { lotSize.value.asOptionalDouble } + var yearBuiltValue: Int? { yearBuilt.value.asOptionalInt } + var bedroomsValue: Int? { bedrooms.value.asOptionalInt } + var bathroomsValue: Double? { bathrooms.value.asOptionalDouble } +} diff --git a/iosApp/iosApp/Core/FormStates/TaskFormStates.swift b/iosApp/iosApp/Core/FormStates/TaskFormStates.swift new file mode 100644 index 0000000..aa6b0f3 --- /dev/null +++ b/iosApp/iosApp/Core/FormStates/TaskFormStates.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftUI +import ComposeApp + +// MARK: - Complete Task Form State + +/// Form state container for completing a task +struct CompleteTaskFormState: FormState { + var completedByName = FormField() + var actualCost = FormField() + var notes = FormField() + var rating: Int = 3 + var completionDate: Date = Date() + var selectedImages: [UIImage] = [] + + var isValid: Bool { + completedByName.isValid + } + + mutating func validateAll() { + completedByName.validate { value in + ValidationRules.validateRequired(value, fieldName: "Completed By") + } + } + + mutating func reset() { + completedByName = FormField() + actualCost = FormField() + notes = FormField() + rating = 3 + completionDate = Date() + selectedImages = [] + } + + /// Convert actualCost string to optional Double + var actualCostValue: Double? { + actualCost.value.asOptionalDouble + } +} + +// MARK: - Task Form State + +/// Form state container for creating/editing a task +struct TaskFormState: FormState { + var title = FormField() + var description = FormField() + var selectedResidenceId: Int32? + var selectedCategoryId: Int32? + var selectedFrequencyId: Int32? + var selectedPriorityId: Int32? + var selectedStatusId: Int32? + var dueDate: Date = Date() + var intervalDays = FormField() + var estimatedCost = FormField() + + // For edit mode + var existingTaskId: Int32? + + var isEditMode: Bool { + existingTaskId != nil + } + + var isValid: Bool { + !title.isEmpty && + selectedCategoryId != nil && + selectedFrequencyId != nil && + selectedPriorityId != nil && + selectedStatusId != nil && + (isEditMode || selectedResidenceId != nil) + } + + mutating func validateAll() { + title.validate { value in + ValidationRules.validateRequired(value, fieldName: "Title") + } + } + + mutating func reset() { + title = FormField() + description = FormField() + selectedResidenceId = nil + selectedCategoryId = nil + selectedFrequencyId = nil + selectedPriorityId = nil + selectedStatusId = nil + dueDate = Date() + intervalDays = FormField() + estimatedCost = FormField() + existingTaskId = nil + } + + /// Convert estimatedCost string to optional Double + var estimatedCostValue: Double? { + estimatedCost.value.asOptionalDouble + } + + /// Convert intervalDays string to optional Int + var intervalDaysValue: Int? { + intervalDays.value.asOptionalInt + } +} diff --git a/iosApp/iosApp/Core/LoadingOverlay.swift b/iosApp/iosApp/Core/LoadingOverlay.swift new file mode 100644 index 0000000..03939e7 --- /dev/null +++ b/iosApp/iosApp/Core/LoadingOverlay.swift @@ -0,0 +1,173 @@ +import SwiftUI + +// MARK: - Loading Overlay View Modifier + +/// A view modifier that displays a loading overlay on top of the content +struct LoadingOverlay: ViewModifier { + let isLoading: Bool + let message: String? + + func body(content: Content) -> some View { + ZStack { + content + .disabled(isLoading) + .blur(radius: isLoading ? 1 : 0) + + 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) + .multilineTextAlignment(.center) + } + } + .padding(24) + .background(Color.black.opacity(0.7)) + .cornerRadius(12) + } + } + .animation(.easeInOut(duration: 0.2), value: isLoading) + } +} + +// MARK: - View Extension + +extension View { + /// Apply a loading overlay to the view + /// - Parameters: + /// - isLoading: Whether to show the loading overlay + /// - message: Optional message to display below the spinner + func loadingOverlay(isLoading: Bool, message: String? = nil) -> some View { + modifier(LoadingOverlay(isLoading: isLoading, message: message)) + } +} + +// MARK: - Inline Loading View + +/// A simple inline loading indicator +struct InlineLoadingView: View { + let message: String? + + init(message: String? = nil) { + self.message = message + } + + var body: some View { + HStack(spacing: 8) { + ProgressView() + + if let message = message { + Text(message) + .foregroundColor(Color.appTextSecondary) + .font(.subheadline) + } + } + } +} + +// MARK: - Button Loading State + +/// A view modifier that shows loading state on a button +struct ButtonLoadingModifier: ViewModifier { + let isLoading: Bool + + func body(content: Content) -> some View { + content + .opacity(isLoading ? 0 : 1) + .overlay { + if isLoading { + ProgressView() + .tint(.white) + } + } + .disabled(isLoading) + } +} + +extension View { + /// Apply loading state to a button + func buttonLoading(_ isLoading: Bool) -> some View { + modifier(ButtonLoadingModifier(isLoading: isLoading)) + } +} + +// MARK: - Shimmer Loading Effect + +/// A shimmer effect for loading placeholders +struct ShimmerModifier: ViewModifier { + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + Color.clear, + Color.white.opacity(0.4), + Color.clear + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + phase * geometry.size.width * 2) + } + ) + .mask(content) + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + +extension View { + /// Apply shimmer loading effect + func shimmer() -> some View { + modifier(ShimmerModifier()) + } +} + +// MARK: - Skeleton Loading View + +/// A skeleton placeholder view for loading states +struct SkeletonView: View { + let width: CGFloat? + let height: CGFloat + + init(width: CGFloat? = nil, height: CGFloat = 16) { + self.width = width + self.height = height + } + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.3)) + .frame(width: width, height: height) + .shimmer() + } +} + +// MARK: - Preview + +#if DEBUG +struct LoadingOverlay_Previews: PreviewProvider { + static var previews: some View { + VStack { + Text("Content behind loading overlay") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .loadingOverlay(isLoading: true, message: "Loading...") + } +} +#endif diff --git a/iosApp/iosApp/Core/Protocols/ViewModelProtocols.swift b/iosApp/iosApp/Core/Protocols/ViewModelProtocols.swift new file mode 100644 index 0000000..00dce81 --- /dev/null +++ b/iosApp/iosApp/Core/Protocols/ViewModelProtocols.swift @@ -0,0 +1,19 @@ +import Foundation +import ComposeApp + +// MARK: - Token Storage Protocol + +/// Protocol for token storage to enable testing +protocol TokenStorageProtocol { + func getToken() -> String? + func saveToken(token: String) + func clearToken() +} + +// Make TokenStorage conform +extension TokenStorage: TokenStorageProtocol {} + +// Note: ViewModel protocols are defined but not enforced via extension conformance +// because the Kotlin-generated API has different parameter labels than what Swift expects. +// The protocols serve as documentation of the expected interface. +// For actual DI, use the concrete Kotlin types with optional initializer parameters. diff --git a/iosApp/iosApp/Core/StateFlowObserver.swift b/iosApp/iosApp/Core/StateFlowObserver.swift new file mode 100644 index 0000000..4724ee9 --- /dev/null +++ b/iosApp/iosApp/Core/StateFlowObserver.swift @@ -0,0 +1,119 @@ +import Foundation +import ComposeApp + +/// Utility for observing Kotlin StateFlow and handling ApiResult states +/// This eliminates the repeated boilerplate pattern across ViewModels +@MainActor +enum StateFlowObserver { + + /// Observe a Kotlin StateFlow and handle loading/success/error states + /// - Parameters: + /// - stateFlow: The Kotlin StateFlow to observe + /// - onLoading: Called when state is ApiResultLoading + /// - onSuccess: Called when state is ApiResultSuccess with the data + /// - onError: Called when state is ApiResultError with parsed message + /// - resetState: Optional closure to reset the StateFlow state after handling + static func observe( + _ stateFlow: SkieSwiftStateFlow>, + 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 { + await MainActor.run { + if let data = success.data { + onSuccess(data) + } + } + resetState?() + break + } else if let error = state as? ApiResultError { + await MainActor.run { + let message = ErrorMessageParser.parse(error.message ?? "An unexpected error occurred") + onError?(message) + } + resetState?() + break + } else if state is ApiResultIdle { + // Idle state, continue observing + continue + } + } + } + } + + /// Observe a StateFlow with automatic isLoading and errorMessage binding + /// Use this when you want standard loading/error state management + /// - Parameters: + /// - stateFlow: The Kotlin StateFlow to observe + /// - loadingSetter: Closure to set loading state + /// - errorSetter: Closure to set error message + /// - onSuccess: Called when state is ApiResultSuccess with the data + /// - resetState: Optional closure to reset the StateFlow state + static func observeWithState( + _ stateFlow: SkieSwiftStateFlow>, + loadingSetter: @escaping (Bool) -> Void, + errorSetter: @escaping (String?) -> Void, + onSuccess: @escaping (T) -> Void, + resetState: (() -> Void)? = nil + ) { + observe( + stateFlow, + onLoading: { + loadingSetter(true) + }, + onSuccess: { data in + loadingSetter(false) + onSuccess(data) + }, + onError: { error in + loadingSetter(false) + errorSetter(error) + }, + resetState: resetState + ) + } + + /// Observe a StateFlow with a completion callback + /// Use this for create/update/delete operations that need success/failure feedback + /// - Parameters: + /// - stateFlow: The Kotlin StateFlow to observe + /// - loadingSetter: Closure to set loading state + /// - errorSetter: Closure to set error message + /// - onSuccess: Called when state is ApiResultSuccess with the data + /// - completion: Called with true on success, false on error + /// - resetState: Optional closure to reset the StateFlow state + static func observeWithCompletion( + _ stateFlow: SkieSwiftStateFlow>, + loadingSetter: @escaping (Bool) -> Void, + errorSetter: @escaping (String?) -> Void, + onSuccess: ((T) -> Void)? = nil, + completion: @escaping (Bool) -> Void, + resetState: (() -> Void)? = nil + ) { + observe( + stateFlow, + onLoading: { + loadingSetter(true) + }, + onSuccess: { data in + loadingSetter(false) + onSuccess?(data) + completion(true) + }, + onError: { error in + loadingSetter(false) + errorSetter(error) + completion(false) + }, + resetState: resetState + ) + } +} diff --git a/iosApp/iosApp/Core/ValidationRules.swift b/iosApp/iosApp/Core/ValidationRules.swift new file mode 100644 index 0000000..cbb984e --- /dev/null +++ b/iosApp/iosApp/Core/ValidationRules.swift @@ -0,0 +1,199 @@ +import Foundation + +/// Validation errors that can be returned from validation rules +enum ValidationError: LocalizedError { + case required(field: String) + case invalidEmail + case passwordTooShort(minLength: Int) + case passwordMismatch + case passwordMissingLetter + case passwordMissingNumber + 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 .passwordMissingLetter: + return "Password must contain at least one letter" + case .passwordMissingNumber: + return "Password must contain at least one number" + 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 + } + } +} + +/// Centralized validation rules for the app +/// Use these instead of inline validation logic in ViewModels +enum ValidationRules { + + // MARK: - Email Validation + + /// Validates an email address + /// - Parameter email: The email to validate + /// - Returns: ValidationError if invalid, nil if valid + static func validateEmail(_ email: String) -> ValidationError? { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return .required(field: "Email") + } + + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + + if !predicate.evaluate(with: trimmed) { + return .invalidEmail + } + + return nil + } + + /// Check if email format is valid (without required check) + /// - Parameter email: The email to check + /// - Returns: true if valid format + static func isValidEmail(_ email: String) -> Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return predicate.evaluate(with: email) + } + + // MARK: - Password Validation + + /// Validates a password with minimum length + /// - Parameters: + /// - password: The password to validate + /// - minLength: Minimum required length (default: 8) + /// - Returns: ValidationError if invalid, nil if valid + 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 + } + + /// Validates a password with letter and number requirements + /// - Parameter password: The password to validate + /// - Returns: ValidationError if invalid, nil if valid + static func validatePasswordStrength(_ password: String) -> ValidationError? { + if password.isEmpty { + return .required(field: "Password") + } + + let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil + let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil + + if !hasLetter { + return .passwordMissingLetter + } + + if !hasNumber { + return .passwordMissingNumber + } + + return nil + } + + /// Check if password has required strength (letter + number) + /// - Parameter password: The password to check + /// - Returns: true if valid strength + static func isValidPassword(_ password: String) -> Bool { + let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil + let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil + return hasLetter && hasNumber + } + + /// Validates that two passwords match + /// - Parameters: + /// - password: The password + /// - confirmPassword: The confirmation password + /// - Returns: ValidationError if they don't match, nil if they match + static func validatePasswordMatch(_ password: String, _ confirmPassword: String) -> ValidationError? { + if password != confirmPassword { + return .passwordMismatch + } + return nil + } + + // MARK: - Code Validation + + /// Validates a numeric code (like verification codes) + /// - Parameters: + /// - code: The code to validate + /// - expectedLength: Expected length of the code (default: 6) + /// - Returns: ValidationError if invalid, nil if valid + 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 + + /// Validates a username (alphanumeric + underscores only) + /// - Parameter username: The username to validate + /// - Returns: ValidationError if invalid, nil if valid + 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 Validation + + /// Validates that a field is not empty + /// - Parameters: + /// - value: The value to check + /// - fieldName: The name of the field (for error message) + /// - Returns: ValidationError if empty, nil if not empty + static func validateRequired(_ value: String, fieldName: String) -> ValidationError? { + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return .required(field: fieldName) + } + return nil + } + + /// Check if a value is not empty + /// - Parameter value: The value to check + /// - Returns: true if not empty + static func isNotEmpty(_ value: String) -> Bool { + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/iosApp/iosApp/Core/ViewState.swift b/iosApp/iosApp/Core/ViewState.swift new file mode 100644 index 0000000..73ca0eb --- /dev/null +++ b/iosApp/iosApp/Core/ViewState.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftUI + +// MARK: - View State + +/// Represents the state of async data loading in a view +enum ViewState { + case idle + case loading + case loaded(T) + case error(String) + + var isIdle: Bool { + if case .idle = self { return true } + return false + } + + var isLoading: Bool { + if case .loading = self { return true } + return false + } + + var isLoaded: Bool { + if case .loaded = self { return true } + return false + } + + var isError: Bool { + if case .error = 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 + } + + /// Map the loaded data to a new type + func map(_ transform: (T) -> U) -> ViewState { + switch self { + case .idle: + return .idle + case .loading: + return .loading + case .loaded(let data): + return .loaded(transform(data)) + case .error(let message): + return .error(message) + } + } +} + +// MARK: - Form Field + +/// Container for form field state with validation support +struct FormField { + var value: T + var error: String? + var isDirty: Bool = false + + /// Validate the field using the provided validator + mutating func validate(_ validator: (T) -> ValidationError?) { + if let validationError = validator(value) { + error = validationError.errorDescription + } else { + error = nil + } + } + + /// Mark the field as having been interacted with + mutating func touch() { + isDirty = true + } + + /// Clear any error + mutating func clearError() { + error = nil + } + + /// Check if field is valid (no error) + var isValid: Bool { + error == nil + } + + /// Check if field should show error (dirty and has error) + var shouldShowError: Bool { + isDirty && error != nil + } +} + +// MARK: - String Form Field + +extension FormField where T == String { + /// Initialize an empty string form field + init() { + self.value = "" + self.error = nil + self.isDirty = false + } + + /// Initialize with a default value + init(initialValue: String) { + self.value = initialValue + self.error = nil + self.isDirty = false + } + + /// Check if the field is empty + var isEmpty: Bool { + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Get trimmed value + var trimmedValue: String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - Optional Form Field + +extension FormField where T == String? { + /// Initialize an empty optional string form field + init() { + self.value = nil + self.error = nil + self.isDirty = false + } +} + +// MARK: - Binding Extensions + +extension FormField { + /// Create a binding for the value + func binding(onChange: @escaping (T) -> Void) -> Binding { + Binding( + get: { self.value }, + set: { onChange($0) } + ) + } +} + +// MARK: - Form State Protocol + +/// Protocol for form state containers +protocol FormState { + /// Validate all fields in the form + mutating func validateAll() + + /// Check if the entire form is valid + var isValid: Bool { get } + + /// Reset the form to initial state + mutating func reset() +} diff --git a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift index 77199b8..96edf09 100644 --- a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift +++ b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift @@ -17,51 +17,57 @@ struct DocumentsTabContent: View { } var body: some View { - if filteredDocuments.isEmpty && viewModel.isLoading { - Spacer() - ProgressView() - .scaleEffect(1.2) - Spacer() - } else if let error = viewModel.errorMessage { - Spacer() - ErrorView(message: error, retryAction: { viewModel.loadDocuments() }) - Spacer() - } else if filteredDocuments.isEmpty { - Spacer() - if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") { - // User can add documents (limit > 0) - show empty state - EmptyStateView( - icon: "doc", - title: "No documents found", - message: "Add documents related to your residence" - ) - } else { - // User is blocked (limit = 0) - show upgrade prompt - UpgradeFeatureView( - triggerKey: "view_documents", - icon: "doc.text.fill" - ) - } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: AppSpacing.sm) { - ForEach(filteredDocuments, id: \.id) { document in - NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) { - DocumentCard(document: document) - } - .buttonStyle(PlainButtonStyle()) - } + ListAsyncContentView( + items: filteredDocuments, + isLoading: viewModel.isLoading, + errorMessage: viewModel.errorMessage, + content: { documents in + DocumentsListContent(documents: documents) + }, + emptyContent: { + if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") { + EmptyStateView( + icon: "doc", + title: "No documents found", + message: "Add documents related to your residence" + ) + } else { + UpgradeFeatureView( + triggerKey: "view_documents", + icon: "doc.text.fill" + ) } - .padding(AppSpacing.md) - .padding(.bottom, AppSpacing.xxxl) - } - .refreshable { + }, + onRefresh: { viewModel.loadDocuments(forceRefresh: true) + }, + onRetry: { + viewModel.loadDocuments() } - .safeAreaInset(edge: .bottom) { - Color.clear.frame(height: 0) + ) + } +} + +// MARK: - Documents List Content + +private struct DocumentsListContent: View { + let documents: [Document] + + var body: some View { + ScrollView { + LazyVStack(spacing: AppSpacing.sm) { + ForEach(documents, id: \.id) { document in + NavigationLink(destination: DocumentDetailView(documentId: document.id?.int32Value ?? 0)) { + DocumentCard(document: document) + } + .buttonStyle(PlainButtonStyle()) + } } + .padding(AppSpacing.md) + .padding(.bottom, AppSpacing.xxxl) + } + .safeAreaInset(edge: .bottom) { + Color.clear.frame(height: 0) } } } diff --git a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift index b3192f7..b4951c5 100644 --- a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift +++ b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift @@ -19,51 +19,57 @@ struct WarrantiesTabContent: View { } var body: some View { - if filteredWarranties.isEmpty && viewModel.isLoading { - Spacer() - ProgressView() - .scaleEffect(1.2) - Spacer() - } else if let error = viewModel.errorMessage { - Spacer() - ErrorView(message: error, retryAction: { viewModel.loadDocuments() }) - Spacer() - } else if filteredWarranties.isEmpty { - Spacer() - if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") { - // User can add documents (limit > 0) - show empty state - EmptyStateView( - icon: "doc.text.viewfinder", - title: "No warranties found", - message: "Add warranties to track coverage periods" - ) - } else { - // User is blocked (limit = 0) - show upgrade prompt - UpgradeFeatureView( - triggerKey: "view_documents", - icon: "doc.text.fill" - ) - } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: AppSpacing.sm) { - ForEach(filteredWarranties, id: \.id) { warranty in - NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) { - WarrantyCard(document: warranty) - } - .buttonStyle(PlainButtonStyle()) - } + ListAsyncContentView( + items: filteredWarranties, + isLoading: viewModel.isLoading, + errorMessage: viewModel.errorMessage, + content: { warranties in + WarrantiesListContent(warranties: warranties) + }, + emptyContent: { + if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") { + EmptyStateView( + icon: "doc.text.viewfinder", + title: "No warranties found", + message: "Add warranties to track coverage periods" + ) + } else { + UpgradeFeatureView( + triggerKey: "view_documents", + icon: "doc.text.fill" + ) } - .padding(AppSpacing.md) - .padding(.bottom, AppSpacing.xxxl) - } - .refreshable { + }, + onRefresh: { viewModel.loadDocuments(forceRefresh: true) + }, + onRetry: { + viewModel.loadDocuments() } - .safeAreaInset(edge: .bottom) { - Color.clear.frame(height: 0) + ) + } +} + +// MARK: - Warranties List Content + +private struct WarrantiesListContent: View { + let warranties: [Document] + + var body: some View { + ScrollView { + LazyVStack(spacing: AppSpacing.sm) { + ForEach(warranties, id: \.id) { warranty in + NavigationLink(destination: DocumentDetailView(documentId: warranty.id?.int32Value ?? 0)) { + WarrantyCard(document: warranty) + } + .buttonStyle(PlainButtonStyle()) + } } + .padding(AppSpacing.md) + .padding(.bottom, AppSpacing.xxxl) + } + .safeAreaInset(edge: .bottom) { + Color.clear.frame(height: 0) } } } diff --git a/iosApp/iosApp/Documents/DocumentViewModel.swift b/iosApp/iosApp/Documents/DocumentViewModel.swift index cb593e9..47e14a8 100644 --- a/iosApp/iosApp/Documents/DocumentViewModel.swift +++ b/iosApp/iosApp/Documents/DocumentViewModel.swift @@ -10,10 +10,9 @@ class DocumentViewModel: ObservableObject { @Published var errorMessage: String? private let sharedViewModel: ComposeApp.DocumentViewModel - private var cancellables = Set() - init() { - self.sharedViewModel = ComposeApp.DocumentViewModel() + init(sharedViewModel: ComposeApp.DocumentViewModel? = nil) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.DocumentViewModel() } func loadDocuments( @@ -31,39 +30,29 @@ class DocumentViewModel: ObservableObject { errorMessage = nil sharedViewModel.loadDocuments( - residenceId: residenceId != nil ? KotlinInt(integerLiteral: Int(residenceId!)) : nil, + residenceId: residenceId.asKotlin, documentType: documentType, category: category, - contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, - isActive: isActive != nil ? KotlinBoolean(bool: isActive!) : nil, - expiringSoon: expiringSoon != nil ? KotlinInt(integerLiteral: Int(expiringSoon!)) : nil, + contractorId: contractorId.asKotlin, + isActive: isActive.asKotlin, + expiringSoon: expiringSoon.asKotlin, tags: tags, search: search, forceRefresh: forceRefresh ) - // Observe the state - Task { - for await state in sharedViewModel.documentsState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.documents = success.data as? [Document] ?? [] - 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.observe( + sharedViewModel.documentsState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (data: NSArray) in + self?.documents = data as? [Document] ?? [] + self?.isLoading = false + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false } - } + ) } func createDocument( @@ -105,12 +94,12 @@ class DocumentViewModel: ObservableObject { sharedViewModel.createDocument( title: title, documentType: documentType, - residenceId: Int32(residenceId), + residenceId: residenceId, description: description, category: category, tags: tags, notes: notes, - contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, + contractorId: contractorId.asKotlin, isActive: isActive, itemName: itemName, modelNumber: modelNumber, @@ -126,31 +115,20 @@ class DocumentViewModel: ObservableObject { images: [] // Image handling needs platform-specific implementation ) - // Observe the state - Task { - for await state in sharedViewModel.createState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.isLoading = false - } - sharedViewModel.resetCreateState() - completion(true, nil) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoading = false - } - sharedViewModel.resetCreateState() - completion(false, error.message) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.createState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (_: Document) in + self?.isLoading = false + completion(true, nil) + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false + completion(false, error) + }, + resetState: { [weak self] in self?.sharedViewModel.resetCreateState() } + ) } func updateDocument( @@ -180,14 +158,14 @@ class DocumentViewModel: ObservableObject { errorMessage = nil sharedViewModel.updateDocument( - id: Int32(id), + id: id, title: title, documentType: "", // Required but not changing description: description, category: category, tags: tags, notes: notes, - contractorId: contractorId != nil ? KotlinInt(integerLiteral: Int(contractorId!)) : nil, + contractorId: contractorId.asKotlin, isActive: isActive, itemName: itemName, modelNumber: modelNumber, @@ -203,31 +181,20 @@ class DocumentViewModel: ObservableObject { images: [] // Image handling needs platform-specific implementation ) - // Observe the state - Task { - for await state in sharedViewModel.updateState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.isLoading = false - } - sharedViewModel.resetUpdateState() - completion(true, nil) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoading = false - } - sharedViewModel.resetUpdateState() - completion(false, error.message) - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.updateState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (_: Document) in + self?.isLoading = false + completion(true, nil) + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false + completion(false, error) + }, + resetState: { [weak self] in self?.sharedViewModel.resetUpdateState() } + ) } func deleteDocument(id: Int32) { @@ -236,29 +203,13 @@ class DocumentViewModel: ObservableObject { sharedViewModel.deleteDocument(id: id) - // Observe the state - Task { - for await state in sharedViewModel.deleteState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if state is ApiResultSuccess { - await MainActor.run { - self.isLoading = false - } - sharedViewModel.resetDeleteState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoading = false - } - sharedViewModel.resetDeleteState() - break - } - } - } + StateFlowObserver.observeWithState( + sharedViewModel.deleteState, + loadingSetter: { [weak self] in self?.isLoading = $0 }, + errorSetter: { [weak self] in self?.errorMessage = $0 }, + onSuccess: { (_: KotlinUnit) in }, + resetState: { [weak self] in self?.sharedViewModel.resetDeleteState() } + ) } func downloadDocument(url: String) -> Task { diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 4d5273b..846de5f 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -14,16 +14,18 @@ class LoginViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorage - private var cancellables = Set() + private let tokenStorage: TokenStorageProtocol // Callback for successful login var onLoginSuccess: ((Bool) -> Void)? // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.AuthViewModel() - self.tokenStorage = TokenStorage.shared + init( + sharedViewModel: ComposeApp.AuthViewModel? = nil, + tokenStorage: TokenStorageProtocol? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() } // MARK: - Public Methods @@ -188,37 +190,28 @@ class LoginViewModel: ObservableObject { // Fetch current user to check verification status sharedViewModel.getCurrentUser(forceRefresh: false) - Task { - for await state in sharedViewModel.currentUserState { - if let success = state as? ApiResultSuccess { - await MainActor.run { - if let user = success.data { - self.currentUser = user - self.isVerified = user.verified + StateFlowObserver.observe( + sharedViewModel.currentUserState, + onSuccess: { [weak self] (user: User) in + self?.currentUser = user + self?.isVerified = user.verified - // Initialize lookups if verified - if user.verified { - Task { - _ = try? await APILayer.shared.initializeLookups() - } - } - - print("Auth check - User: \(user.username), Verified: \(user.verified)") - } + // Initialize lookups if verified + if user.verified { + Task { + _ = try? await APILayer.shared.initializeLookups() } - sharedViewModel.resetCurrentUserState() - break - } else if state is ApiResultError { - await MainActor.run { - // Token invalid or expired, clear it - self.tokenStorage.clearToken() - self.isVerified = false - } - sharedViewModel.resetCurrentUserState() - break } - } - } + + print("Auth check - User: \(user.username), Verified: \(user.verified)") + }, + onError: { [weak self] _ in + // Token invalid or expired, clear it + self?.tokenStorage.clearToken() + self?.isVerified = false + }, + resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() } + ) } } diff --git a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift index 18ebb71..ad1dae5 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift @@ -2,7 +2,7 @@ import Foundation import ComposeApp import Combine -enum PasswordResetStep { +enum PasswordResetStep: CaseIterable { case requestCode // Step 1: Enter email case verifyCode // Step 2: Enter 6-digit code case resetPassword // Step 3: Set new password @@ -24,11 +24,13 @@ class PasswordResetViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.AuthViewModel - private var cancellables = Set() // MARK: - Initialization - init(resetToken: String? = nil) { - self.sharedViewModel = ComposeApp.AuthViewModel() + init( + resetToken: String? = nil, + sharedViewModel: ComposeApp.AuthViewModel? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() // If we have a reset token from deep link, skip to password reset step if let token = resetToken { @@ -41,13 +43,8 @@ class PasswordResetViewModel: ObservableObject { /// Step 1: Request password reset code func requestPasswordReset() { - guard !email.isEmpty else { - errorMessage = "Email is required" - return - } - - guard isValidEmail(email) else { - errorMessage = "Please enter a valid email address" + if let error = ValidationRules.validateEmail(email) { + errorMessage = error.errorDescription return } @@ -56,38 +53,31 @@ class PasswordResetViewModel: ObservableObject { sharedViewModel.forgotPassword(email: email) - Task { - for await state in sharedViewModel.forgotPasswordState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.handleRequestSuccess(response: success) - } - sharedViewModel.resetForgotPasswordState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.handleApiError(errorResult: error) - } - sharedViewModel.resetForgotPasswordState() - break + StateFlowObserver.observe( + sharedViewModel.forgotPasswordState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (_: ForgotPasswordResponse) in + self?.isLoading = false + self?.successMessage = "Check your email for a 6-digit verification code" + + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self?.successMessage = nil + self?.currentStep = .verifyCode } - } - } + }, + onError: { [weak self] error in + self?.isLoading = false + self?.errorMessage = error + }, + resetState: { [weak self] in self?.sharedViewModel.resetForgotPasswordState() } + ) } /// Step 2: Verify reset code func verifyResetCode() { - guard !code.isEmpty else { - errorMessage = "Verification code is required" - return - } - - guard code.count == 6 else { - errorMessage = "Please enter a 6-digit code" + if let error = ValidationRules.validateCode(code, expectedLength: 6) { + errorMessage = error.errorDescription return } @@ -96,53 +86,44 @@ class PasswordResetViewModel: ObservableObject { sharedViewModel.verifyResetCode(email: email, code: code) - Task { - for await state in sharedViewModel.verifyResetCodeState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.handleVerifySuccess(response: success) - } - sharedViewModel.resetVerifyResetCodeState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.handleApiError(errorResult: error) - } - sharedViewModel.resetVerifyResetCodeState() - break + StateFlowObserver.observe( + sharedViewModel.verifyResetCodeState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (response: VerifyResetCodeResponse) in + guard let self = self else { return } + let token = response.resetToken + self.resetToken = token + self.isLoading = false + self.successMessage = "Code verified! Now set your new password" + + // Automatically move to next step after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.successMessage = nil + self.currentStep = .resetPassword } - } - } + }, + onError: { [weak self] error in + self?.isLoading = false + self?.handleVerifyError(error) + }, + resetState: { [weak self] in self?.sharedViewModel.resetVerifyResetCodeState() } + ) } /// Step 3: Reset password func resetPassword() { - guard !newPassword.isEmpty else { - errorMessage = "New password is required" + if let error = ValidationRules.validatePassword(newPassword) { + errorMessage = error.errorDescription return } - guard newPassword.count >= 8 else { - errorMessage = "Password must be at least 8 characters" + if let error = ValidationRules.validatePasswordStrength(newPassword) { + errorMessage = error.errorDescription return } - guard !confirmPassword.isEmpty else { - errorMessage = "Please confirm your password" - return - } - - guard newPassword == confirmPassword else { - errorMessage = "Passwords do not match" - return - } - - guard isValidPassword(newPassword) else { - errorMessage = "Password must contain both letters and numbers" + if let error = ValidationRules.validatePasswordMatch(newPassword, confirmPassword) { + errorMessage = error.errorDescription return } @@ -156,27 +137,20 @@ class PasswordResetViewModel: ObservableObject { sharedViewModel.resetPassword(resetToken: token, newPassword: newPassword, confirmPassword: confirmPassword) - Task { - for await state in sharedViewModel.resetPasswordState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.handleResetSuccess(response: success) - } - sharedViewModel.resetResetPasswordState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.handleApiError(errorResult: error) - } - sharedViewModel.resetResetPasswordState() - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.resetPasswordState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (_: ResetPasswordResponse) in + self?.isLoading = false + self?.successMessage = "Password reset successfully! You can now log in with your new password." + self?.currentStep = .success + }, + onError: { [weak self] error in + self?.isLoading = false + self?.errorMessage = error + }, + resetState: { [weak self] in self?.sharedViewModel.resetResetPasswordState() } + ) } /// Navigate to next step @@ -230,86 +204,16 @@ class PasswordResetViewModel: ObservableObject { // MARK: - Private Methods - @MainActor - private func handleRequestSuccess(response: ApiResultSuccess) { - isLoading = false - successMessage = "Check your email for a 6-digit verification code" - - // Automatically move to next step after short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.successMessage = nil - self.currentStep = .verifyCode - } - - print("Password reset requested for: \(email)") - } - - @MainActor - private func handleVerifySuccess(response: ApiResultSuccess) { - if let token = response.data?.resetToken { - self.resetToken = token - self.isLoading = false - self.successMessage = "Code verified! Now set your new password" - - // Automatically move to next step after short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.successMessage = nil - self.currentStep = .resetPassword - } - - print("Code verified, reset token received") + private func handleVerifyError(_ message: String) { + // Handle specific error cases + if message.contains("expired") { + errorMessage = "Reset code has expired. Please request a new one." + } else if message.contains("attempts") { + errorMessage = "Too many failed attempts. Please request a new reset code." + } else if message.contains("Invalid") && message.contains("token") { + errorMessage = "Invalid or expired reset token. Please start over." } else { - self.isLoading = false - self.errorMessage = "Failed to verify code" + errorMessage = message } } - - @MainActor - private func handleResetSuccess(response: ApiResultSuccess) { - isLoading = false - successMessage = "Password reset successfully! You can now log in with your new password." - currentStep = .success - - print("Password reset successful") - } - - @MainActor - private func handleApiError(errorResult: ApiResultError) { - self.isLoading = false - - // Handle specific error codes - if errorResult.code?.intValue == 429 { - self.errorMessage = "Too many requests. Please try again later." - } else if errorResult.code?.intValue == 400 { - // Parse error message from backend - let message = errorResult.message - if message.contains("expired") { - self.errorMessage = "Reset code has expired. Please request a new one." - } else if message.contains("attempts") { - self.errorMessage = "Too many failed attempts. Please request a new reset code." - } else if message.contains("Invalid") && message.contains("token") { - self.errorMessage = "Invalid or expired reset token. Please start over." - } else { - self.errorMessage = ErrorMessageParser.parse(message) - } - } else { - self.errorMessage = ErrorMessageParser.parse(errorResult.message) - } - - print("API Error: \(errorResult.message)") - } - - // MARK: - Validation Helpers - - private func isValidEmail(_ email: String) -> Bool { - let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" - let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) - return emailPredicate.evaluate(with: email) - } - - private func isValidPassword(_ password: String) -> Bool { - let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil - let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil - return hasLetter && hasNumber - } } diff --git a/iosApp/iosApp/Profile/ProfileViewModel.swift b/iosApp/iosApp/Profile/ProfileViewModel.swift index f1d69f3..b6cece4 100644 --- a/iosApp/iosApp/Profile/ProfileViewModel.swift +++ b/iosApp/iosApp/Profile/ProfileViewModel.swift @@ -15,13 +15,15 @@ class ProfileViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorage - private var cancellables = Set() + private let tokenStorage: TokenStorageProtocol // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.AuthViewModel() - self.tokenStorage = TokenStorage.shared + init( + sharedViewModel: ComposeApp.AuthViewModel? = nil, + tokenStorage: TokenStorageProtocol? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() // Load current user data loadCurrentUser() @@ -40,34 +42,22 @@ class ProfileViewModel: ObservableObject { sharedViewModel.getCurrentUser(forceRefresh: false) - Task { - for await state in sharedViewModel.currentUserState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoadingUser = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - if let user = success.data { - self.firstName = user.firstName ?? "" - self.lastName = user.lastName ?? "" - self.email = user.email - self.isLoadingUser = false - self.errorMessage = nil - } - } - sharedViewModel.resetCurrentUserState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoadingUser = false - } - sharedViewModel.resetCurrentUserState() - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.currentUserState, + onLoading: { [weak self] in self?.isLoadingUser = true }, + onSuccess: { [weak self] (user: User) in + self?.firstName = user.firstName ?? "" + self?.lastName = user.lastName ?? "" + self?.email = user.email + self?.isLoadingUser = false + self?.errorMessage = nil + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoadingUser = false + }, + resetState: { [weak self] in self?.sharedViewModel.resetCurrentUserState() } + ) } func updateProfile() { @@ -91,37 +81,25 @@ class ProfileViewModel: ObservableObject { email: email ) - Task { - for await state in sharedViewModel.updateProfileState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - if let user = success.data { - self.firstName = user.firstName ?? "" - self.lastName = user.lastName ?? "" - self.email = user.email - self.isLoading = false - self.errorMessage = nil - self.successMessage = "Profile updated successfully" - print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")") - } - } - sharedViewModel.resetUpdateProfileState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.isLoading = false - self.errorMessage = ErrorMessageParser.parse(error.message) - self.successMessage = nil - } - sharedViewModel.resetUpdateProfileState() - break - } - } - } + StateFlowObserver.observe( + sharedViewModel.updateProfileState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (user: User) in + self?.firstName = user.firstName ?? "" + self?.lastName = user.lastName ?? "" + self?.email = user.email + self?.isLoading = false + self?.errorMessage = nil + self?.successMessage = "Profile updated successfully" + print("Profile updated: \(user.firstName ?? "") \(user.lastName ?? "")") + }, + onError: { [weak self] error in + self?.isLoading = false + self?.errorMessage = error + self?.successMessage = nil + }, + resetState: { [weak self] in self?.sharedViewModel.resetUpdateProfileState() } + ) } func clearMessages() { diff --git a/iosApp/iosApp/Register/RegisterViewModel.swift b/iosApp/iosApp/Register/RegisterViewModel.swift index 63c6886..a825d05 100644 --- a/iosApp/iosApp/Register/RegisterViewModel.swift +++ b/iosApp/iosApp/Register/RegisterViewModel.swift @@ -15,35 +15,37 @@ class RegisterViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorage - private var cancellables = Set() + private let tokenStorage: TokenStorageProtocol // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.AuthViewModel() - self.tokenStorage = TokenStorage.shared + init( + sharedViewModel: ComposeApp.AuthViewModel? = nil, + tokenStorage: TokenStorageProtocol? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() } // MARK: - Public Methods func register() { - // Validation - guard !username.isEmpty else { - errorMessage = "Username is required" + // Validation using ValidationRules + if let error = ValidationRules.validateRequired(username, fieldName: "Username") { + errorMessage = error.errorDescription return } - guard !email.isEmpty else { - errorMessage = "Email is required" + if let error = ValidationRules.validateEmail(email) { + errorMessage = error.errorDescription return } - guard !password.isEmpty else { - errorMessage = "Password is required" + if let error = ValidationRules.validatePassword(password) { + errorMessage = error.errorDescription return } - guard password == confirmPassword else { - errorMessage = "Passwords do not match" + if let error = ValidationRules.validatePasswordMatch(password, confirmPassword) { + errorMessage = error.errorDescription return } @@ -52,44 +54,28 @@ class RegisterViewModel: ObservableObject { sharedViewModel.register(username: username, email: email, password: password) - // Observe the state - Task { - for await state in sharedViewModel.registerState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - if let token = success.data?.token, - let user = success.data?.user { - self.tokenStorage.saveToken(token: token) + StateFlowObserver.observe( + sharedViewModel.registerState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (response: AuthResponse) in + guard let self = self else { return } + let token = response.token + self.tokenStorage.saveToken(token: token) - // Initialize lookups via APILayer after successful registration - Task { - _ = try? await APILayer.shared.initializeLookups() - } - - // Update registration state - self.isRegistered = true - self.isLoading = false - - print("Registration successful! Token saved") - print("User: \(user.username)") - } - } - sharedViewModel.resetRegisterState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoading = false - } - sharedViewModel.resetRegisterState() - break + // Initialize lookups via APILayer after successful registration + Task { + _ = try? await APILayer.shared.initializeLookups() } - } - } + + self.isRegistered = true + self.isLoading = false + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false + }, + resetState: { [weak self] in self?.sharedViewModel.resetRegisterState() } + ) } func clearError() { diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index 679f82c..a497bc5 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -15,11 +15,15 @@ class ResidenceViewModel: ObservableObject { // MARK: - Private Properties public let sharedViewModel: ComposeApp.ResidenceViewModel - private var cancellables = Set() + 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 { - 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 { - 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 { - 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 { - 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 { - 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() { diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 6ccb81e..d84a6b7 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -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() diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index c1f32ba..d013641 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -5,74 +5,67 @@ import Combine @MainActor class TaskViewModel: ObservableObject { // MARK: - Published Properties - @Published var isLoading: Bool = false + @Published var actionState: ActionState = .idle @Published var errorMessage: String? - @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 + + // MARK: - Computed Properties (Backward Compatibility) + + var isLoading: Bool { actionState.isLoading } + var taskCreated: Bool { actionState.isSuccess(.create) } + var taskUpdated: Bool { actionState.isSuccess(.update) } + var taskCancelled: Bool { actionState.isSuccess(.cancel) } + var taskUncancelled: Bool { actionState.isSuccess(.uncancel) } + var taskMarkedInProgress: Bool { actionState.isSuccess(.markInProgress) } + var taskArchived: Bool { actionState.isSuccess(.archive) } + var taskUnarchived: Bool { actionState.isSuccess(.unarchive) } // MARK: - Private Properties private let sharedViewModel: ComposeApp.TaskViewModel - private var cancellables = Set() // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.TaskViewModel() + init(sharedViewModel: ComposeApp.TaskViewModel? = nil) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.TaskViewModel() } // MARK: - Public Methods func createTask(request: TaskCreateRequest, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.create) errorMessage = nil - taskCreated = false sharedViewModel.createNewTask(request: request) - // Observe the state - Task { - for await state in sharedViewModel.taskAddNewCustomTaskState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.isLoading = false - self.taskCreated = true - } - sharedViewModel.resetAddTaskState() - completion(true) - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.errorMessage = ErrorMessageParser.parse(error.message) - self.isLoading = false - } - sharedViewModel.resetAddTaskState() - completion(false) - break + StateFlowObserver.observeWithCompletion( + sharedViewModel.taskAddNewCustomTaskState, + loadingSetter: { [weak self] loading in + if loading { self?.actionState = .loading(.create) } + }, + errorSetter: { [weak self] error in + if let error = error { + self?.actionState = .error(.create, error) + self?.errorMessage = error } - } - } + }, + onSuccess: { [weak self] (_: CustomTask) in + self?.actionState = .success(.create) + }, + completion: completion, + resetState: { [weak self] in self?.sharedViewModel.resetAddTaskState() } + ) } func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.cancel) errorMessage = nil - taskCancelled = false sharedViewModel.cancelTask(taskId: id) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskCancelled = true + self.actionState = .success(.cancel) completion(true) } else { - self.errorMessage = "Failed to cancel task" + let errorMsg = "Failed to cancel task" + self.actionState = .error(.cancel, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -80,18 +73,18 @@ class TaskViewModel: ObservableObject { } func uncancelTask(id: Int32, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.uncancel) errorMessage = nil - taskUncancelled = false sharedViewModel.uncancelTask(taskId: id) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskUncancelled = true + self.actionState = .success(.uncancel) completion(true) } else { - self.errorMessage = "Failed to uncancel task" + let errorMsg = "Failed to uncancel task" + self.actionState = .error(.uncancel, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -99,18 +92,18 @@ class TaskViewModel: ObservableObject { } func markInProgress(id: Int32, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.markInProgress) errorMessage = nil - taskMarkedInProgress = false sharedViewModel.markInProgress(taskId: id) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskMarkedInProgress = true + self.actionState = .success(.markInProgress) completion(true) } else { - self.errorMessage = "Failed to mark task in progress" + let errorMsg = "Failed to mark task in progress" + self.actionState = .error(.markInProgress, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -118,18 +111,18 @@ class TaskViewModel: ObservableObject { } func archiveTask(id: Int32, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.archive) errorMessage = nil - taskArchived = false sharedViewModel.archiveTask(taskId: id) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskArchived = true + self.actionState = .success(.archive) completion(true) } else { - self.errorMessage = "Failed to archive task" + let errorMsg = "Failed to archive task" + self.actionState = .error(.archive, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -137,18 +130,18 @@ class TaskViewModel: ObservableObject { } func unarchiveTask(id: Int32, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.unarchive) errorMessage = nil - taskUnarchived = false sharedViewModel.unarchiveTask(taskId: id) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskUnarchived = true + self.actionState = .success(.unarchive) completion(true) } else { - self.errorMessage = "Failed to unarchive task" + let errorMsg = "Failed to unarchive task" + self.actionState = .error(.unarchive, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -156,18 +149,18 @@ class TaskViewModel: ObservableObject { } func updateTask(id: Int32, request: TaskCreateRequest, completion: @escaping (Bool) -> Void) { - isLoading = true + actionState = .loading(.update) errorMessage = nil - taskUpdated = false sharedViewModel.updateTask(taskId: id, request: request) { success in Task { @MainActor in - self.isLoading = false if success.boolValue { - self.taskUpdated = true + self.actionState = .success(.update) completion(true) } else { - self.errorMessage = "Failed to update task" + let errorMsg = "Failed to update task" + self.actionState = .error(.update, errorMsg) + self.errorMessage = errorMsg completion(false) } } @@ -176,16 +169,13 @@ class TaskViewModel: ObservableObject { func clearError() { errorMessage = nil + if case .error = actionState { + actionState = .idle + } } func resetState() { - taskCreated = false - taskUpdated = false - taskCancelled = false - taskUncancelled = false - taskMarkedInProgress = false - taskArchived = false - taskUnarchived = false + actionState = .idle errorMessage = nil } } diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift index 102ff08..3c0acfa 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailViewModel.swift @@ -12,25 +12,22 @@ class VerifyEmailViewModel: ObservableObject { // MARK: - Private Properties private let sharedViewModel: ComposeApp.AuthViewModel - private let tokenStorage: TokenStorage - private var cancellables = Set() + private let tokenStorage: TokenStorageProtocol // MARK: - Initialization - init() { - self.sharedViewModel = ComposeApp.AuthViewModel() - self.tokenStorage = TokenStorage.shared + init( + sharedViewModel: ComposeApp.AuthViewModel? = nil, + tokenStorage: TokenStorageProtocol? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() } // MARK: - Public Methods func verifyEmail() { - // Validation - guard code.count == 6 else { - errorMessage = "Please enter a valid 6-digit code" - return - } - - guard code.allSatisfy({ $0.isNumber }) else { - errorMessage = "Code must contain only numbers" + // Validation using ValidationRules + if let error = ValidationRules.validateCode(code, expectedLength: 6) { + errorMessage = error.errorDescription return } @@ -44,45 +41,24 @@ class VerifyEmailViewModel: ObservableObject { sharedViewModel.verifyEmail(code: code) - Task { - for await state in sharedViewModel.verifyEmailState { - if state is ApiResultLoading { - await MainActor.run { - self.isLoading = true - } - } else if let success = state as? ApiResultSuccess { - await MainActor.run { - self.handleSuccess(results: success) - } - sharedViewModel.resetVerifyEmailState() - break - } else if let error = state as? ApiResultError { - await MainActor.run { - self.handleError(message: error.message) - } - sharedViewModel.resetVerifyEmailState() - break + StateFlowObserver.observe( + sharedViewModel.verifyEmailState, + onLoading: { [weak self] in self?.isLoading = true }, + onSuccess: { [weak self] (response: VerifyEmailResponse) in + if response.verified { + self?.isVerified = true + self?.isLoading = false + } else { + self?.errorMessage = "Verification failed" + self?.isLoading = false } - } - } - } - - @MainActor - func handleError(message: String) { - self.isLoading = false - self.errorMessage = message - print("Verification error: \(message)") - } - - @MainActor - func handleSuccess(results: ApiResultSuccess) { - if let verified = results.data?.verified, verified { - self.isVerified = true - self.isLoading = false - print("Email verification successful!") - } else { - self.handleError(message: "Verification failed") - } + }, + onError: { [weak self] error in + self?.errorMessage = error + self?.isLoading = false + }, + resetState: { [weak self] in self?.sharedViewModel.resetVerifyEmailState() } + ) } func clearError() {