# Phase 6: Polish & Release **Goal:** Production-ready application with comprehensive error handling, accessibility, performance optimization, and full test coverage **Prerequisites:** Phase 5 complete (Plant collection working, Core Data persistence functional, image caching implemented, search/filter operational) --- ## Tasks ### 6.1 Build Settings View - [ ] Create `Presentation/Scenes/Settings/SettingsView.swift`: ```swift struct SettingsView: View { @State private var viewModel: SettingsViewModel @State private var showingClearCacheAlert = false @State private var showingDeleteDataAlert = false var body: some View { NavigationStack { List { identificationSection cacheSection apiStatusSection aboutSection dangerZoneSection } .navigationTitle("Settings") } } private var identificationSection: some View { Section("Identification") { Toggle("Offline Mode", isOn: $viewModel.offlineModeEnabled) Picker("Preferred Method", selection: $viewModel.preferredIdentificationMethod) { Text("Hybrid (Recommended)").tag(IdentificationMethod.hybrid) Text("On-Device Only").tag(IdentificationMethod.onDevice) Text("API Only").tag(IdentificationMethod.apiOnly) } HStack { Text("Minimum Confidence") Spacer() Text("\(Int(viewModel.minimumConfidence * 100))%") .foregroundStyle(.secondary) } Slider(value: $viewModel.minimumConfidence, in: 0.5...0.95, step: 0.05) } } private var cacheSection: some View { Section("Storage") { HStack { Text("Image Cache") Spacer() Text(viewModel.imageCacheSize) .foregroundStyle(.secondary) } HStack { Text("Identification Cache") Spacer() Text(viewModel.identificationCacheSize) .foregroundStyle(.secondary) } Button("Clear Image Cache") { showingClearCacheAlert = true } .foregroundStyle(.red) } } private var apiStatusSection: some View { Section("API Status") { APIStatusRow( name: "PlantNet API", status: viewModel.plantNetStatus, quota: viewModel.plantNetQuota ) APIStatusRow( name: "Trefle API", status: viewModel.trefleStatus, quota: nil ) HStack { Text("Network Status") Spacer() NetworkStatusBadge(isConnected: viewModel.isNetworkAvailable) } } } private var aboutSection: some View { Section("About") { HStack { Text("Version") Spacer() Text(viewModel.appVersion) .foregroundStyle(.secondary) } HStack { Text("Build") Spacer() Text(viewModel.buildNumber) .foregroundStyle(.secondary) } HStack { Text("ML Model") Spacer() Text("PlantNet-300K") .foregroundStyle(.secondary) } Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!) Link("Terms of Service", destination: URL(string: "https://example.com/terms")!) Link("Open Source Licenses", destination: URL(string: "https://example.com/licenses")!) } } private var dangerZoneSection: some View { Section("Danger Zone") { Button("Delete All Plant Data", role: .destructive) { showingDeleteDataAlert = true } } } } ``` - [ ] Create `SettingsViewModel`: ```swift @Observable final class SettingsViewModel { // Preferences var offlineModeEnabled: Bool { didSet { UserDefaults.standard.set(offlineModeEnabled, forKey: "offlineModeEnabled") } } var preferredIdentificationMethod: IdentificationMethod { didSet { UserDefaults.standard.set(preferredIdentificationMethod.rawValue, forKey: "identificationMethod") } } var minimumConfidence: Double { didSet { UserDefaults.standard.set(minimumConfidence, forKey: "minimumConfidence") } } // Cache info private(set) var imageCacheSize: String = "Calculating..." private(set) var identificationCacheSize: String = "Calculating..." // API status private(set) var plantNetStatus: APIStatus = .unknown private(set) var plantNetQuota: APIQuota? private(set) var trefleStatus: APIStatus = .unknown private(set) var isNetworkAvailable: Bool = true // App info let appVersion: String let buildNumber: String private let imageCache: ImageCacheProtocol private let identificationCache: IdentificationCacheProtocol private let plantNetService: PlantNetAPIServiceProtocol private let trefleService: TrefleAPIServiceProtocol private let networkMonitor: NetworkMonitorProtocol private let plantRepository: PlantCollectionRepositoryProtocol init(/* dependencies */) { // Load from UserDefaults self.offlineModeEnabled = UserDefaults.standard.bool(forKey: "offlineModeEnabled") self.preferredIdentificationMethod = IdentificationMethod( rawValue: UserDefaults.standard.string(forKey: "identificationMethod") ?? "" ) ?? .hybrid self.minimumConfidence = UserDefaults.standard.double(forKey: "minimumConfidence") if self.minimumConfidence == 0 { self.minimumConfidence = 0.7 } // App info self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" // Store dependencies self.imageCache = imageCache // ... other dependencies } func loadCacheInfo() async { let imageSize = await imageCache.getCacheSize() imageCacheSize = ByteCountFormatter.string(fromByteCount: imageSize, countStyle: .file) let idSize = await identificationCache.getCacheSize() identificationCacheSize = ByteCountFormatter.string(fromByteCount: idSize, countStyle: .file) } func checkAPIStatus() async { // Check PlantNet do { let status = try await plantNetService.checkStatus() plantNetStatus = .available plantNetQuota = status.quota } catch { plantNetStatus = .unavailable(error.localizedDescription) } // Check Trefle do { try await trefleService.checkStatus() trefleStatus = .available } catch { trefleStatus = .unavailable(error.localizedDescription) } } func clearImageCache() async throws { await imageCache.clearAllCache() await loadCacheInfo() } func deleteAllData() async throws { try await plantRepository.deleteAllPlants() await imageCache.clearAllCache() await identificationCache.clearAll() } } enum IdentificationMethod: String, CaseIterable { case hybrid, onDevice, apiOnly } enum APIStatus: Equatable { case unknown case available case unavailable(String) } struct APIQuota { let used: Int let limit: Int let resetsAt: Date } ``` - [ ] Create `APIStatusRow` component: ```swift struct APIStatusRow: View { let name: String let status: APIStatus let quota: APIQuota? var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(name) Spacer() StatusBadge(status: status) } if let quota { ProgressView(value: Double(quota.used), total: Double(quota.limit)) .tint(quotaColor(quota)) Text("\(quota.used)/\(quota.limit) requests today") .font(.caption) .foregroundStyle(.secondary) } } } private func quotaColor(_ quota: APIQuota) -> Color { let percentage = Double(quota.used) / Double(quota.limit) if percentage > 0.9 { return .red } if percentage > 0.7 { return .orange } return .green } } ``` - [ ] Create `NetworkStatusBadge` component - [ ] Implement network reachability monitoring with `NWPathMonitor` - [ ] Add haptic feedback for toggle changes - [ ] Register SettingsViewModel in DIContainer - [ ] Write unit tests for settings persistence **Acceptance Criteria:** Settings view displays all options, changes persist, cache info accurate, API status reflects reality --- ### 6.2 Add Comprehensive Error Handling - [ ] Create `Presentation/Common/Components/ErrorView.swift`: ```swift struct ErrorView: View { let error: AppError let retryAction: (() async -> Void)? let dismissAction: (() -> Void)? @State private var isRetrying = false var body: some View { VStack(spacing: 24) { errorIcon VStack(spacing: 8) { Text(error.title) .font(.title2) .fontWeight(.semibold) Text(error.message) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } if let suggestion = error.recoverySuggestion { Text(suggestion) .font(.callout) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } actionButtons } .padding(32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground)) } @ViewBuilder private var errorIcon: some View { ZStack { Circle() .fill(error.iconBackgroundColor.opacity(0.1)) .frame(width: 80, height: 80) Image(systemName: error.iconName) .font(.system(size: 36)) .foregroundStyle(error.iconBackgroundColor) } } @ViewBuilder private var actionButtons: some View { VStack(spacing: 12) { if let retry = retryAction { Button { Task { isRetrying = true await retry() isRetrying = false } } label: { if isRetrying { ProgressView() .frame(maxWidth: .infinity) } else { Text(error.retryButtonTitle) .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .disabled(isRetrying) } if let dismiss = dismissAction { Button("Dismiss", action: dismiss) .buttonStyle(.bordered) } } .padding(.top, 8) } } ``` - [ ] Create unified `AppError` type: ```swift // Core/Errors/AppError.swift enum AppError: Error, LocalizedError, Sendable { // Network case networkUnavailable case networkTimeout case serverError(statusCode: Int) case invalidResponse // Identification case cameraAccessDenied case photoLibraryAccessDenied case identificationFailed(underlying: Error) case noPlantDetected case lowConfidence(Double) case modelNotLoaded // API case apiKeyMissing(service: String) case rateLimitExceeded(resetsAt: Date?) case quotaExhausted case apiUnavailable(service: String) // Persistence case saveFailed(underlying: Error) case fetchFailed(underlying: Error) case deleteFailed(underlying: Error) case dataCorrupted // Care case notificationPermissionDenied case scheduleCreationFailed case careDataUnavailable // General case unknown(underlying: Error) var title: String { switch self { case .networkUnavailable: return "No Internet Connection" case .networkTimeout: return "Connection Timed Out" case .serverError: return "Server Error" case .cameraAccessDenied: return "Camera Access Required" case .photoLibraryAccessDenied: return "Photo Access Required" case .identificationFailed: return "Identification Failed" case .noPlantDetected: return "No Plant Detected" case .lowConfidence: return "Uncertain Result" case .modelNotLoaded: return "Model Not Ready" case .rateLimitExceeded: return "Too Many Requests" case .quotaExhausted: return "Daily Limit Reached" case .apiUnavailable: return "Service Unavailable" case .saveFailed: return "Save Failed" case .fetchFailed: return "Load Failed" case .deleteFailed: return "Delete Failed" case .dataCorrupted: return "Data Error" case .notificationPermissionDenied: return "Notifications Disabled" case .scheduleCreationFailed: return "Schedule Error" case .careDataUnavailable: return "Care Info Unavailable" default: return "Something Went Wrong" } } var message: String { switch self { case .networkUnavailable: return "Please check your internet connection and try again." case .cameraAccessDenied: return "Botanica needs camera access to identify plants. Please enable it in Settings." case .noPlantDetected: return "We couldn't detect a plant in this image. Try taking a clearer photo of the leaves or flowers." case .lowConfidence(let confidence): return "We're only \(Int(confidence * 100))% confident about this identification. Try a different angle or better lighting." case .rateLimitExceeded(let resetsAt): if let date = resetsAt { let formatter = RelativeDateTimeFormatter() return "Please try again \(formatter.localizedString(for: date, relativeTo: Date()))." } return "Please wait a moment before trying again." case .quotaExhausted: return "You've reached the daily identification limit. Try again tomorrow or use offline mode." default: return "An unexpected error occurred. Please try again." } } var recoverySuggestion: String? { switch self { case .networkUnavailable: return "You can still use offline identification while disconnected." case .cameraAccessDenied, .photoLibraryAccessDenied: return "Go to Settings > Botanica > Privacy" case .noPlantDetected: return "Make sure the plant fills most of the frame." case .quotaExhausted: return "Offline identification is still available." default: return nil } } var iconName: String { switch self { case .networkUnavailable, .networkTimeout: return "wifi.slash" case .cameraAccessDenied: return "camera.fill" case .photoLibraryAccessDenied: return "photo.fill" case .noPlantDetected, .lowConfidence: return "leaf.fill" case .rateLimitExceeded, .quotaExhausted: return "clock.fill" case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted: return "externaldrive.fill" case .notificationPermissionDenied: return "bell.slash.fill" default: return "exclamationmark.triangle.fill" } } var iconBackgroundColor: Color { switch self { case .networkUnavailable, .networkTimeout: return .orange case .cameraAccessDenied, .photoLibraryAccessDenied: return .blue case .noPlantDetected, .lowConfidence: return .green case .rateLimitExceeded, .quotaExhausted: return .purple case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted: return .red default: return .red } } var retryButtonTitle: String { switch self { case .cameraAccessDenied, .photoLibraryAccessDenied, .notificationPermissionDenied: return "Open Settings" case .networkUnavailable: return "Try Again" default: return "Retry" } } var isRetryable: Bool { switch self { case .networkUnavailable, .networkTimeout, .serverError, .identificationFailed, .saveFailed, .fetchFailed, .apiUnavailable: return true default: return false } } } ``` - [ ] Create inline error banner for non-blocking errors: ```swift struct ErrorBanner: View { let error: AppError let dismiss: () -> Void var body: some View { HStack(spacing: 12) { Image(systemName: error.iconName) .foregroundStyle(error.iconBackgroundColor) VStack(alignment: .leading, spacing: 2) { Text(error.title) .font(.subheadline) .fontWeight(.medium) Text(error.message) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } Spacer() Button(action: dismiss) { Image(systemName: "xmark") .font(.caption) .foregroundStyle(.secondary) } } .padding() .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 4) .padding(.horizontal) } } ``` - [ ] Create error handling view modifier: ```swift struct ErrorHandlingModifier: ViewModifier { @Binding var error: AppError? let retryAction: (() async -> Void)? func body(content: Content) -> some View { content .alert( error?.title ?? "Error", isPresented: Binding( get: { error != nil }, set: { if !$0 { error = nil } } ), presenting: error ) { error in if error.isRetryable, let retry = retryAction { Button("Retry") { Task { await retry() } } } Button("Dismiss", role: .cancel) {} } message: { error in Text(error.message) } } } extension View { func errorAlert(_ error: Binding, retry: (() async -> Void)? = nil) -> some View { modifier(ErrorHandlingModifier(error: error, retryAction: retry)) } } ``` - [ ] Add error logging service: ```swift protocol ErrorLoggingServiceProtocol: Sendable { func log(_ error: Error, context: [String: Any]?) func logWarning(_ message: String, context: [String: Any]?) } final class ErrorLoggingService: ErrorLoggingServiceProtocol, Sendable { func log(_ error: Error, context: [String: Any]?) { #if DEBUG print("ERROR: \(error.localizedDescription)") if let context { print("Context: \(context)") } #endif // In production, send to crash reporting service } } ``` - [ ] Update all ViewModels to use `AppError` - [ ] Add error recovery actions (open settings, retry, etc.) - [ ] Write unit tests for error mapping **Acceptance Criteria:** All errors display user-friendly messages, retry works for retryable errors, error context logged --- ### 6.3 Implement Loading States with Shimmer Effects - [ ] Create `ShimmerModifier`: ```swift struct ShimmerModifier: ViewModifier { @State private var phase: CGFloat = 0 let animation: Animation let gradient: Gradient init( animation: Animation = .linear(duration: 1.5).repeatForever(autoreverses: false), gradient: Gradient = Gradient(colors: [.clear, .white.opacity(0.5), .clear]) ) { self.animation = animation self.gradient = gradient } func body(content: Content) -> some View { content .overlay { GeometryReader { geometry in LinearGradient( gradient: gradient, startPoint: .leading, endPoint: .trailing ) .frame(width: geometry.size.width * 3) .offset(x: -geometry.size.width + phase * geometry.size.width * 3) } .clipped() } .onAppear { withAnimation(animation) { phase = 1 } } } } extension View { func shimmer() -> some View { modifier(ShimmerModifier()) } } ``` - [ ] Create skeleton components: ```swift struct SkeletonShape: View { let width: CGFloat? let height: CGFloat var body: some View { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) .frame(width: width, height: height) .shimmer() } } struct PlantCardSkeleton: View { var body: some View { VStack(alignment: .leading, spacing: 8) { SkeletonShape(width: nil, height: 150) .clipShape(RoundedRectangle(cornerRadius: 12)) SkeletonShape(width: 120, height: 20) SkeletonShape(width: 80, height: 14) } .padding(8) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 16)) } } struct CollectionSkeletonView: View { private let columns = [ GridItem(.adaptive(minimum: 150, maximum: 200), spacing: 16) ] var body: some View { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(0..<6, id: \.self) { _ in PlantCardSkeleton() } } .padding() } } } struct IdentificationResultSkeleton: View { var body: some View { VStack(spacing: 16) { SkeletonShape(width: nil, height: 200) .clipShape(RoundedRectangle(cornerRadius: 16)) VStack(alignment: .leading, spacing: 8) { SkeletonShape(width: 180, height: 24) SkeletonShape(width: 140, height: 16) SkeletonShape(width: nil, height: 12) SkeletonShape(width: nil, height: 12) } HStack(spacing: 16) { SkeletonShape(width: nil, height: 44) SkeletonShape(width: nil, height: 44) } } .padding() } } struct PlantDetailSkeleton: View { var body: some View { ScrollView { VStack(spacing: 20) { SkeletonShape(width: nil, height: 300) VStack(alignment: .leading, spacing: 12) { SkeletonShape(width: 200, height: 28) SkeletonShape(width: 150, height: 18) Divider() SkeletonShape(width: 100, height: 20) SkeletonShape(width: nil, height: 80) SkeletonShape(width: 100, height: 20) SkeletonShape(width: nil, height: 60) } .padding(.horizontal) } } } } struct CareScheduleSkeleton: View { var body: some View { VStack(alignment: .leading, spacing: 16) { ForEach(0..<4, id: \.self) { _ in HStack(spacing: 12) { SkeletonShape(width: 44, height: 44) .clipShape(Circle()) VStack(alignment: .leading, spacing: 4) { SkeletonShape(width: 120, height: 16) SkeletonShape(width: 80, height: 12) } Spacer() SkeletonShape(width: 60, height: 24) } .padding() .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) } } .padding() } } ``` - [ ] Create loading state view modifier: ```swift struct LoadingStateModifier: ViewModifier { let isLoading: Bool let loadingView: Loading func body(content: Content) -> some View { if isLoading { loadingView } else { content } } } extension View { func loading( _ isLoading: Bool, @ViewBuilder placeholder: () -> L ) -> some View { modifier(LoadingStateModifier(isLoading: isLoading, loadingView: placeholder())) } } ``` - [ ] Add button loading states: ```swift struct LoadingButton: View { let title: String let isLoading: Bool let action: () -> Void var body: some View { Button(action: action) { ZStack { Text(title) .opacity(isLoading ? 0 : 1) if isLoading { ProgressView() .tint(.white) } } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(isLoading) } } ``` - [ ] Create pull-to-refresh with custom animation - [ ] Add transition animations between loading and content - [ ] Implement progressive loading for large collections - [ ] Write snapshot tests for skeleton views **Acceptance Criteria:** All loading states have skeleton placeholders, shimmer animation is smooth, transitions are seamless --- ### 6.4 Add Accessibility Support - [ ] Add accessibility labels to all interactive elements: ```swift // Example: PlantGridCard with full accessibility struct PlantGridCard: View { let plant: Plant var body: some View { VStack(alignment: .leading, spacing: 8) { CachedAsyncImage(url: plant.imageURLs.first, plantID: plant.id) .frame(height: 150) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay(alignment: .topTrailing) { if plant.isFavorite { Image(systemName: "heart.fill") .foregroundStyle(.red) .padding(8) .accessibilityHidden(true) // Part of card label } } .accessibilityHidden(true) // Image described in card label Text(plant.displayName) .font(.headline) .lineLimit(1) Text(plant.scientificName) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .italic() } .padding(8) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 16)) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityHint("Double tap to view details") .accessibilityAddTraits(.isButton) } private var accessibilityLabel: String { var label = plant.displayName if plant.displayName != plant.scientificName { label += ", scientific name: \(plant.scientificName)" } if plant.isFavorite { label += ", favorite" } return label } } ``` - [ ] Support Dynamic Type throughout app: ```swift // Use semantic fonts everywhere Text(plant.displayName) .font(.headline) // Will scale with Dynamic Type // For custom sizes, use relativeTo: Text("Scientific Name") .font(.system(.caption, design: .serif)) // Ensure layouts adapt to larger text @ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24 ``` - [ ] Add VoiceOver support: ```swift // Custom accessibility actions struct PlantCard: View { var body: some View { cardContent .accessibilityAction(named: "Toggle Favorite") { toggleFavorite() } .accessibilityAction(named: "Delete") { delete() } .accessibilityAction(named: "View Care Schedule") { showCareSchedule() } } } // Announce changes func identificationComplete(_ plant: Plant) { UIAccessibility.post( notification: .announcement, argument: "Identified as \(plant.displayName) with \(Int(plant.confidenceScore ?? 0 * 100)) percent confidence" ) } ``` - [ ] Implement accessibility rotor for quick navigation: ```swift struct CollectionView: View { var body: some View { ScrollView { // ... content } .accessibilityRotor("Favorites") { ForEach(plants.filter(\.isFavorite)) { plant in AccessibilityRotorEntry(plant.displayName, id: plant.id) } } .accessibilityRotor("Needs Watering") { ForEach(plantsNeedingWater) { plant in AccessibilityRotorEntry(plant.displayName, id: plant.id) } } } } ``` - [ ] Support Reduce Motion: ```swift @Environment(\.accessibilityReduceMotion) var reduceMotion var body: some View { content .animation(reduceMotion ? nil : .spring(), value: isExpanded) } // Or use a wrapper extension Animation { static func accessibleSpring(_ reduceMotion: Bool) -> Animation? { reduceMotion ? nil : .spring() } } ``` - [ ] Support Reduce Transparency: ```swift @Environment(\.accessibilityReduceTransparency) var reduceTransparency var body: some View { content .background(reduceTransparency ? Color(.systemBackground) : .ultraThinMaterial) } ``` - [ ] Add accessibility identifiers for UI testing: ```swift enum AccessibilityID { static let cameraButton = "camera_capture_button" static let collectionGrid = "plant_collection_grid" static let searchField = "collection_search_field" static let plantCard = { (id: UUID) in "plant_card_\(id.uuidString)" } static let favoriteButton = { (id: UUID) in "favorite_button_\(id.uuidString)" } static let deleteButton = { (id: UUID) in "delete_button_\(id.uuidString)" } } // Usage Button("Capture") { capture() } .accessibilityIdentifier(AccessibilityID.cameraButton) ``` - [ ] Support Bold Text preference - [ ] Support Increase Contrast preference - [ ] Test with VoiceOver on device - [ ] Test with Dynamic Type at all sizes - [ ] Write accessibility audit checklist - [ ] Document accessibility features **Acceptance Criteria:** App fully usable with VoiceOver, all text scales with Dynamic Type, motion respects user preferences --- ### 6.5 Performance Optimization Pass - [ ] Profile app with Instruments: - Time Profiler for CPU bottlenecks - Allocations for memory usage - Leaks for memory leaks - Core Animation for rendering issues - Network for API efficiency - [ ] Optimize image loading: ```swift // Thumbnail generation for grid actor ThumbnailGenerator { private var cache: [String: UIImage] = [:] func thumbnail(for image: UIImage, size: CGSize) async -> UIImage { let key = "\(image.hash)_\(size.width)x\(size.height)" if let cached = cache[key] { return cached } let thumbnail = await Task.detached(priority: .userInitiated) { let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } }.value cache[key] = thumbnail return thumbnail } } // Downsampling for large images func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? { let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil } let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale let downsampleOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels ] as CFDictionary guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { return nil } return UIImage(cgImage: downsampledImage) } ``` - [ ] Optimize Core ML inference: ```swift // Batch predictions when possible // Use GPU for inference let config = MLModelConfiguration() config.computeUnits = .all // Use Neural Engine when available // Warm up model on launch func warmUpModel() { Task.detached(priority: .background) { _ = try? await classificationService.predict(UIImage()) } } ``` - [ ] Optimize Core Data queries: ```swift // Use fetch limits let request = PlantMO.fetchRequest() request.fetchLimit = 20 request.fetchBatchSize = 20 // Only fetch needed properties request.propertiesToFetch = ["id", "scientificName", "commonNames", "imageURLs"] // Use NSFetchedResultsController for live updates lazy var fetchedResultsController: NSFetchedResultsController = { let request = PlantMO.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \PlantMO.dateAdded, ascending: false)] request.fetchBatchSize = 20 return NSFetchedResultsController( fetchRequest: request, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: "PlantCollection" ) }() ``` - [ ] Implement view recycling for large collections: ```swift // LazyVGrid already handles this, but ensure no expensive operations in body struct OptimizedPlantGrid: View { let plants: [Plant] var body: some View { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(plants) { plant in PlantGridCard(plant: plant) .id(plant.id) // Stable identity for recycling } } } } } ``` - [ ] Reduce app launch time: ```swift // Defer non-critical initialization @main struct BotanicaApp: App { init() { // Register value transformers synchronously (required) ValueTransformers.registerAll() } var body: some Scene { WindowGroup { ContentView() .task { // Defer these to after first frame await DeferredInitialization.perform() } } } } enum DeferredInitialization { static func perform() async { // Warm up ML model await MLModelWarmup.warmUp() // Pre-fetch common data await DataPrefetcher.prefetch() // Initialize analytics Analytics.initialize() } } ``` - [ ] Optimize network requests: ```swift // Implement request deduplication actor RequestDeduplicator { private var inFlightRequests: [String: Task] = [:] func deduplicated( key: String, request: @escaping () async throws -> T ) async throws -> T { if let existing = inFlightRequests[key] { return try await existing.value as! T } let task = Task { defer { inFlightRequests[key] = nil } return try await request() } inFlightRequests[key] = task as! Task return try await task.value } } ``` - [ ] Add performance monitoring: ```swift struct PerformanceMonitor { static func measure(_ label: String, _ block: () async throws -> T) async rethrows -> T { let start = CFAbsoluteTimeGetCurrent() defer { let duration = CFAbsoluteTimeGetCurrent() - start #if DEBUG print("⏱ \(label): \(String(format: "%.3f", duration * 1000))ms") #endif } return try await block() } } ``` - [ ] Document performance benchmarks - [ ] Create performance regression tests **Acceptance Criteria:** App launch < 2 seconds, ML inference < 500ms, smooth 60fps scrolling, memory < 150MB typical usage --- ### 6.6 Write Unit Tests for Use Cases and Services - [ ] Set up testing infrastructure: ```swift // Tests/Mocks/MockPlantCollectionRepository.swift final class MockPlantCollectionRepository: PlantCollectionRepositoryProtocol { var plants: [Plant] = [] var addPlantCalled = false var deletePlantCalled = false var lastDeletedID: UUID? var shouldThrowError = false var errorToThrow: Error? func addPlant(_ plant: Plant) async throws { if shouldThrowError { throw errorToThrow ?? TestError.mock } addPlantCalled = true plants.append(plant) } func deletePlant(id: UUID) async throws { if shouldThrowError { throw errorToThrow ?? TestError.mock } deletePlantCalled = true lastDeletedID = id plants.removeAll { $0.id == id } } // ... implement all protocol methods } ``` - [ ] Test identification use cases: ```swift final class IdentifyPlantOnDeviceUseCaseTests: XCTestCase { var sut: IdentifyPlantOnDeviceUseCase! var mockClassificationService: MockPlantClassificationService! var mockLabelService: MockPlantLabelService! override func setUp() { super.setUp() mockClassificationService = MockPlantClassificationService() mockLabelService = MockPlantLabelService() sut = IdentifyPlantOnDeviceUseCase( classificationService: mockClassificationService, labelService: mockLabelService ) } func testIdentifyReturnsTopResults() async throws { // Given let testImage = UIImage.testPlantImage mockClassificationService.stubbedResults = [ ClassificationResult(classIndex: 0, confidence: 0.85), ClassificationResult(classIndex: 1, confidence: 0.10), ] mockLabelService.stubbedLabels = ["Monstera deliciosa", "Philodendron"] // When let results = try await sut.execute(image: testImage) // Then XCTAssertEqual(results.count, 2) XCTAssertEqual(results[0].scientificName, "Monstera deliciosa") XCTAssertEqual(results[0].confidence, 0.85, accuracy: 0.001) } func testIdentifyThrowsWhenNoResults() async { // Given mockClassificationService.stubbedResults = [] // When/Then await XCTAssertThrowsError( try await sut.execute(image: UIImage.testPlantImage) ) { error in XCTAssertEqual(error as? AppError, .noPlantDetected) } } func testIdentifyFiltersLowConfidenceResults() async throws { // Given mockClassificationService.stubbedResults = [ ClassificationResult(classIndex: 0, confidence: 0.02), ] // When let results = try await sut.execute(image: UIImage.testPlantImage, minimumConfidence: 0.05) // Then XCTAssertTrue(results.isEmpty) } } ``` - [ ] Test collection use cases: ```swift final class SavePlantUseCaseTests: XCTestCase { var sut: SavePlantUseCase! var mockRepository: MockPlantCollectionRepository! var mockCareScheduleUseCase: MockCreateCareScheduleUseCase! var mockNotificationService: MockNotificationService! var mockImageStorage: MockImageStorage! func testSavePlantAddsToRepository() async throws { // Given let plant = Plant.mock() // When let saved = try await sut.execute(plant: plant, capturedImage: nil, careInfo: nil, preferences: nil) // Then XCTAssertTrue(mockRepository.addPlantCalled) XCTAssertNotNil(saved.dateAdded) } func testSavePlantWithCareInfoCreatesSchedule() async throws { // Given let plant = Plant.mock() let careInfo = PlantCareInfo.mock() mockCareScheduleUseCase.stubbedSchedule = PlantCareSchedule.mock() // When _ = try await sut.execute(plant: plant, capturedImage: nil, careInfo: careInfo, preferences: nil) // Then XCTAssertTrue(mockCareScheduleUseCase.executeCalled) XCTAssertTrue(mockNotificationService.scheduleReminderCalled) } func testSavePlantWithImageStoresLocally() async throws { // Given let plant = Plant.mock() let image = UIImage.testPlantImage mockImageStorage.stubbedPath = "/local/path/image.jpg" // When let saved = try await sut.execute(plant: plant, capturedImage: image, careInfo: nil, preferences: nil) // Then XCTAssertTrue(mockImageStorage.saveCalled) XCTAssertEqual(saved.localImagePaths.first, "/local/path/image.jpg") } } ``` - [ ] Test API services: ```swift final class PlantNetAPIServiceTests: XCTestCase { var sut: PlantNetAPIService! var mockNetworkService: MockNetworkService! func testIdentifyReturnsSpecies() async throws { // Given let responseData = PlantNetIdentifyResponseDTO.mockJSON.data(using: .utf8)! mockNetworkService.stubbedResponse = (responseData, HTTPURLResponse.ok) // When let response = try await sut.identify(image: UIImage.testPlantImage) // Then XCTAssertFalse(response.results.isEmpty) XCTAssertEqual(response.results[0].species.scientificName, "Monstera deliciosa") } func testIdentifyThrowsOnRateLimit() async { // Given mockNetworkService.stubbedResponse = (Data(), HTTPURLResponse.tooManyRequests) // When/Then await XCTAssertThrowsError( try await sut.identify(image: UIImage.testPlantImage) ) { error in XCTAssertEqual(error as? AppError, .rateLimitExceeded(resetsAt: nil)) } } } ``` - [ ] Test Core Data storage: ```swift final class CoreDataPlantStorageTests: XCTestCase { var sut: CoreDataPlantStorage! var inMemoryStack: CoreDataStack! override func setUp() { super.setUp() inMemoryStack = CoreDataStack.inMemoryForTesting() sut = CoreDataPlantStorage(coreDataStack: inMemoryStack) } func testSaveAndFetch() async throws { // Given let plant = Plant.mock() // When try await sut.save(plant) let fetched = try await sut.fetch(id: plant.id) // Then XCTAssertNotNil(fetched) XCTAssertEqual(fetched?.scientificName, plant.scientificName) } func testDeleteRemovesPlant() async throws { // Given let plant = Plant.mock() try await sut.save(plant) // When try await sut.delete(id: plant.id) let fetched = try await sut.fetch(id: plant.id) // Then XCTAssertNil(fetched) } func testSearchFindsMatchingPlants() async throws { // Given let monstera = Plant.mock(scientificName: "Monstera deliciosa", commonNames: ["Swiss cheese plant"]) let pothos = Plant.mock(scientificName: "Epipremnum aureum", commonNames: ["Golden pothos"]) try await sut.save(monstera) try await sut.save(pothos) // When let results = try await sut.search(query: "cheese") // Then XCTAssertEqual(results.count, 1) XCTAssertEqual(results[0].id, monstera.id) } } ``` - [ ] Test image cache: ```swift final class ImageCacheTests: XCTestCase { var sut: ImageCache! var tempDirectory: URL! override func setUp() { super.setUp() tempDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) sut = ImageCache(directory: tempDirectory) } override func tearDown() { try? FileManager.default.removeItem(at: tempDirectory) super.tearDown() } func testCacheAndRetrieve() async throws { // Given let plantID = UUID() let url = URL(string: "https://example.com/plant.jpg")! // Mock network response... // When try await sut.cacheImage(from: url, for: plantID) let cached = await sut.getCachedImage(for: plantID, urlHash: url.absoluteString.sha256Hash) // Then XCTAssertNotNil(cached) } func testClearCacheRemovesImages() async throws { // Given let plantID = UUID() // ... cache some images // When await sut.clearCache(for: plantID) // Then let cached = await sut.getCachedImage(for: plantID, urlHash: "test") XCTAssertNil(cached) } } ``` - [ ] Test error mapping - [ ] Achieve >80% code coverage for business logic - [ ] Add test utilities and fixtures **Acceptance Criteria:** >80% coverage on use cases and services, all critical paths tested, mocks for all dependencies --- ### 6.7 Write UI Tests for Critical Flows - [ ] Set up UI testing infrastructure: ```swift // BotanicaUITests/Helpers/XCUIApplication+Launch.swift extension XCUIApplication { func launchWithCleanState() { launchArguments = ["--uitesting", "--reset-state"] launch() } func launchWithMockData() { launchArguments = ["--uitesting", "--mock-data"] launch() } func launchOffline() { launchArguments = ["--uitesting", "--offline"] launch() } } ``` - [ ] Test camera capture flow: ```swift final class CameraFlowUITests: XCTestCase { var app: XCUIApplication! override func setUp() { super.setUp() continueAfterFailure = false app = XCUIApplication() app.launchWithMockData() } func testCapturePhotoShowsIdentificationResults() { // Navigate to camera app.tabBars.buttons["Camera"].tap() // Capture photo (mock camera provides test image) let captureButton = app.buttons[AccessibilityID.cameraButton] XCTAssertTrue(captureButton.waitForExistence(timeout: 5)) captureButton.tap() // Wait for identification results let resultsView = app.otherElements["identification_results"] XCTAssertTrue(resultsView.waitForExistence(timeout: 10)) // Verify results displayed XCTAssertTrue(app.staticTexts["Monstera deliciosa"].exists) } func testCanSavePlantFromIdentification() { // ... navigate and capture // Tap save button app.buttons["Add to Collection"].tap() // Verify plant appears in collection app.tabBars.buttons["Collection"].tap() XCTAssertTrue(app.staticTexts["Monstera deliciosa"].waitForExistence(timeout: 5)) } } ``` - [ ] Test collection management: ```swift final class CollectionUITests: XCTestCase { func testSearchFiltersPlants() { app.launchWithMockData() app.tabBars.buttons["Collection"].tap() // Search for plant let searchField = app.searchFields[AccessibilityID.searchField] searchField.tap() searchField.typeText("Monstera") // Verify filtered results XCTAssertTrue(app.staticTexts["Monstera deliciosa"].exists) XCTAssertFalse(app.staticTexts["Philodendron"].exists) } func testCanDeletePlant() { app.launchWithMockData() app.tabBars.buttons["Collection"].tap() // Find plant card let plantCard = app.otherElements["plant_card_mock_uuid"] // Swipe to delete plantCard.swipeLeft() app.buttons["Delete"].tap() // Confirm deletion app.alerts.buttons["Delete"].tap() // Verify plant removed XCTAssertFalse(plantCard.exists) } func testCanToggleFavorite() { app.launchWithMockData() app.tabBars.buttons["Collection"].tap() // Tap favorite button let favoriteButton = app.buttons["favorite_button_mock_uuid"] favoriteButton.tap() // Verify heart icon appears XCTAssertTrue(app.images["heart.fill"].exists) } } ``` - [ ] Test care schedule flow: ```swift final class CareScheduleUITests: XCTestCase { func testViewCareScheduleFromPlantDetail() { app.launchWithMockData() // Navigate to plant detail app.tabBars.buttons["Collection"].tap() app.otherElements["plant_card_mock_uuid"].tap() // Tap care schedule section app.buttons["View Care Schedule"].tap() // Verify schedule displayed XCTAssertTrue(app.staticTexts["Watering"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["Every 7 days"].exists) } func testCanMarkTaskComplete() { // ... navigate to care schedule // Find task and complete it let wateringTask = app.cells["care_task_watering"] wateringTask.buttons["Complete"].tap() // Verify checkmark appears XCTAssertTrue(wateringTask.images["checkmark.circle.fill"].exists) } } ``` - [ ] Test settings flow: ```swift final class SettingsUITests: XCTestCase { func testOfflineModeToggle() { app.launchWithCleanState() app.tabBars.buttons["Settings"].tap() // Toggle offline mode let toggle = app.switches["offline_mode_toggle"] toggle.tap() // Verify toggle state changed XCTAssertEqual(toggle.value as? String, "1") } func testClearCache() { app.launchWithMockData() app.tabBars.buttons["Settings"].tap() // Tap clear cache app.buttons["Clear Image Cache"].tap() // Confirm in alert app.alerts.buttons["Clear"].tap() // Verify cache size updates XCTAssertTrue(app.staticTexts["0 bytes"].waitForExistence(timeout: 5)) } } ``` - [ ] Test offline mode: ```swift final class OfflineModeUITests: XCTestCase { func testOfflineIdentificationWorks() { app.launchOffline() // Capture photo app.tabBars.buttons["Camera"].tap() app.buttons[AccessibilityID.cameraButton].tap() // Verify on-device identification works XCTAssertTrue(app.staticTexts["Identified (Offline)"].waitForExistence(timeout: 10)) } func testCachedImagesDisplayOffline() { // First, cache some data online app.launchWithMockData() app.tabBars.buttons["Collection"].tap() // Wait for images to cache sleep(2) // Relaunch offline app.terminate() app.launchOffline() // Verify cached images display app.tabBars.buttons["Collection"].tap() let plantImage = app.images["plant_image_mock_uuid"] XCTAssertTrue(plantImage.waitForExistence(timeout: 5)) } } ``` - [ ] Test error states: ```swift final class ErrorHandlingUITests: XCTestCase { func testNetworkErrorShowsRetry() { app.launchArguments.append("--simulate-network-error") app.launch() // Trigger network request app.tabBars.buttons["Camera"].tap() app.buttons[AccessibilityID.cameraButton].tap() // Verify error view appears XCTAssertTrue(app.staticTexts["No Internet Connection"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Try Again"].exists) } } ``` - [ ] Test accessibility: ```swift final class AccessibilityUITests: XCTestCase { func testVoiceOverLabelsExist() { app.launch() app.tabBars.buttons["Collection"].tap() // Verify accessibility labels let plantCard = app.otherElements["plant_card_mock_uuid"] XCTAssertTrue(plantCard.label.contains("Monstera deliciosa")) } func testDynamicTypeSupported() { // Set large text size via launch argument or UI app.launchArguments.append("-UIPreferredContentSizeCategoryName") app.launchArguments.append("UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge") app.launch() // Verify app doesn't crash and elements are visible XCTAssertTrue(app.tabBars.buttons["Collection"].exists) } } ``` - [ ] Add screenshot tests for visual regression - [ ] Create test data fixtures **Acceptance Criteria:** All critical user flows covered, tests pass consistently, <5 min total runtime --- ### 6.8 Final QA and Bug Fixes - [ ] Create comprehensive QA checklist: ```markdown ## Pre-Release QA Checklist ### Installation & Launch - [ ] Fresh install on iOS 17 device - [ ] Fresh install on iOS 18 device - [ ] App launches without crash - [ ] App launches in under 2 seconds - [ ] All tabs accessible ### Camera & Identification - [ ] Camera permission requested correctly - [ ] Camera preview displays - [ ] Photo capture works - [ ] On-device identification returns results - [ ] API identification returns results (online) - [ ] Hybrid identification works - [ ] Results display with confidence scores - [ ] Low confidence warning shown when appropriate - [ ] No plant detected message shown for non-plant images ### Plant Collection - [ ] Save plant to collection - [ ] View collection (grid mode) - [ ] View collection (list mode) - [ ] Search by common name - [ ] Search by scientific name - [ ] Filter by favorites - [ ] Sort by date added - [ ] Sort by name - [ ] Delete plant (swipe) - [ ] Delete plant (context menu) - [ ] Toggle favorite - [ ] Empty state displayed when no plants - [ ] Pull to refresh works ### Plant Detail - [ ] View plant detail from collection - [ ] View plant detail from identification - [ ] All plant info displayed - [ ] Images load (online) - [ ] Cached images load (offline) - [ ] Edit plant notes - [ ] View identification history ### Care Schedule - [ ] Care schedule created for new plant - [ ] Upcoming tasks displayed - [ ] Mark task complete - [ ] Notifications scheduled - [ ] Notification fires at correct time - [ ] Tap notification opens correct plant ### Settings - [ ] Offline mode toggle works - [ ] Cache size displayed correctly - [ ] Clear cache works - [ ] API status displayed - [ ] Network status updates - [ ] App version displayed ### Offline Mode - [ ] On-device identification works offline - [ ] Collection displays offline - [ ] Cached images display offline - [ ] Offline indicator shown - [ ] Graceful degradation of API features ### Performance - [ ] App launch < 2 seconds - [ ] Identification < 500ms (on-device) - [ ] Smooth scrolling (60fps) - [ ] No memory warnings - [ ] No excessive battery drain ### Accessibility - [ ] VoiceOver navigation works - [ ] Dynamic Type scales correctly - [ ] Reduce Motion respected - [ ] Color contrast sufficient - [ ] All buttons have labels ### Edge Cases - [ ] Very long plant names display - [ ] 500+ plants in collection - [ ] Rapid capture attempts - [ ] Background/foreground transitions - [ ] Low storage device - [ ] Interrupted network during API call - [ ] App update migration ``` - [ ] Fix critical bugs discovered during QA - [ ] Fix high-priority bugs - [ ] Document known issues for v1.0 - [ ] Create App Store screenshots - [ ] Write App Store description - [ ] Create privacy policy page - [ ] Prepare App Store review notes - [ ] Final build and archive - [ ] TestFlight distribution - [ ] Beta tester feedback round - [ ] Address beta feedback - [ ] Final submission build **Acceptance Criteria:** All QA checklist items pass, no critical/high bugs remaining, app approved for release --- ## End-of-Phase Validation ### Functional Verification | Test | Steps | Expected Result | Status | |------|-------|-----------------|--------| | Settings Persist | Change setting → restart app | Setting retained | [ ] | | Offline Mode | Enable offline → identify plant | Uses on-device only | [ ] | | Clear Cache | Clear cache | Cache size shows 0 bytes | [ ] | | API Status | View settings with network | Shows "Available" | [ ] | | API Status Offline | View settings without network | Shows "Unavailable" | [ ] | | Error View | Trigger network error | Error view with retry button | [ ] | | Error Retry | Tap retry on error | Action retries | [ ] | | Error Dismiss | Tap dismiss on error | Error view closes | [ ] | | Loading Skeleton | Load collection | Skeleton shown during load | [ ] | | Shimmer Animation | View skeleton | Smooth shimmer effect | [ ] | | VoiceOver Navigation | Navigate with VoiceOver | All elements accessible | [ ] | | Dynamic Type Large | Set accessibility large text | UI scales correctly | [ ] | | Dynamic Type Small | Set accessibility small text | UI scales correctly | [ ] | | Reduce Motion | Enable reduce motion | No animations | [ ] | | Unit Tests Pass | Run unit tests | All pass | [ ] | | UI Tests Pass | Run UI tests | All pass | [ ] | | No Memory Leaks | Profile with Instruments | No leaks detected | [ ] | | No Crashes | Extended usage session | No crashes | [ ] | ### Code Quality Verification | Check | Criteria | Status | |-------|----------|--------| | Build | Project builds with zero warnings | [ ] | | SwiftLint | No lint warnings | [ ] | | Unit Test Coverage | >80% on business logic | [ ] | | UI Test Coverage | All critical flows | [ ] | | Accessibility Audit | All elements have labels | [ ] | | Error Handling | All errors use AppError | [ ] | | Loading States | All async operations have loading state | [ ] | | Skeleton Views | All lists have skeleton placeholders | [ ] | | Settings Persistence | All settings use UserDefaults correctly | [ ] | | Memory Management | No retain cycles | [ ] | | Thread Safety | No data races | [ ] | | API Error Handling | All API errors handled gracefully | [ ] | | Offline Handling | All features degrade gracefully | [ ] | ### Performance Verification | Metric | Target | Actual | Status | |--------|--------|--------|--------| | Cold Launch | < 2 seconds | | [ ] | | Warm Launch | < 1 second | | [ ] | | Time to First Frame | < 400ms | | [ ] | | ML Model Load | < 500ms | | [ ] | | On-Device Inference | < 500ms | | [ ] | | API Identification | < 3 seconds | | [ ] | | Collection Load (100 plants) | < 500ms | | [ ] | | Search Response | < 200ms | | [ ] | | Image Cache Hit | < 20ms | | [ ] | | Memory (typical usage) | < 150MB | | [ ] | | Memory (500 plants) | < 250MB | | [ ] | | Battery (1 hour usage) | < 10% | | [ ] | | Scroll Frame Rate | 60fps | | [ ] | | UI Test Suite | < 5 minutes | | [ ] | | Unit Test Suite | < 30 seconds | | [ ] | ### Accessibility Verification | Test | Steps | Expected Result | Status | |------|-------|-----------------|--------| | VoiceOver Camera | Navigate camera with VO | Capture button announced | [ ] | | VoiceOver Collection | Navigate collection with VO | All plants announced | [ ] | | VoiceOver Plant Detail | Navigate detail with VO | All info read | [ ] | | VoiceOver Care Schedule | Navigate schedule with VO | Tasks announced | [ ] | | VoiceOver Settings | Navigate settings with VO | All options announced | [ ] | | Dynamic Type XXL | Use accessibility XXL size | All text readable | [ ] | | Dynamic Type XS | Use accessibility XS size | UI not broken | [ ] | | Bold Text | Enable bold text | Text becomes bold | [ ] | | Reduce Motion | Enable reduce motion | No spring animations | [ ] | | Reduce Transparency | Enable reduce transparency | Solid backgrounds | [ ] | | Increase Contrast | Enable increase contrast | Higher contrast | [ ] | | Color Blind (Protanopia) | Simulate color blindness | UI distinguishable | [ ] | | Color Blind (Deuteranopia) | Simulate color blindness | UI distinguishable | [ ] | ### Error Handling Verification | Test | Trigger | Expected Result | Status | |------|---------|-----------------|--------| | Network Unavailable | Disable network | Error view with retry | [ ] | | Network Timeout | Slow network simulation | Timeout error shown | [ ] | | API Rate Limit | Exceed rate limit | Rate limit message | [ ] | | API Quota Exceeded | Use all daily quota | Quota message | [ ] | | Camera Denied | Deny camera permission | Permission error | [ ] | | Photos Denied | Deny photos permission | Permission error | [ ] | | Notification Denied | Deny notifications | Notification error | [ ] | | Save Failed | Simulate save error | Save error message | [ ] | | Model Not Loaded | Access model before load | Model error | [ ] | | Invalid Image | Provide corrupt image | Invalid image error | [ ] | | No Plant Detected | Photo of non-plant | No plant message | [ ] | | Low Confidence | Ambiguous plant photo | Low confidence warning | [ ] | ### Release Readiness Verification | Item | Criteria | Status | |------|----------|--------| | App Icon | All sizes provided | [ ] | | Launch Screen | Displays correctly | [ ] | | App Store Screenshots | All device sizes | [ ] | | App Store Description | Written and reviewed | [ ] | | Privacy Policy | URL accessible | [ ] | | Terms of Service | URL accessible | [ ] | | API Keys | Production keys configured | [ ] | | Bundle ID | Production ID set | [ ] | | Version Number | Set to 1.0.0 | [ ] | | Build Number | Incremented | [ ] | | Code Signing | Production certificate | [ ] | | Provisioning | App Store profile | [ ] | | Archive | Builds successfully | [ ] | | App Store Connect | App record created | [ ] | | TestFlight | Build uploaded | [ ] | | Beta Testing | Feedback addressed | [ ] | | App Review Notes | Written | [ ] | --- ## Phase 6 Completion Checklist - [ ] All 8 tasks completed - [ ] All functional tests pass - [ ] All code quality checks pass - [ ] All performance targets met - [ ] All accessibility tests pass - [ ] All error handling tests pass - [ ] Settings view complete with all options - [ ] Error handling comprehensive with user-friendly messages - [ ] Loading states with shimmer animations - [ ] Full accessibility support (VoiceOver, Dynamic Type, Reduce Motion) - [ ] Performance optimized (launch, inference, scrolling) - [ ] Unit tests >80% coverage on business logic - [ ] UI tests cover all critical flows - [ ] QA checklist completed - [ ] All critical/high bugs fixed - [ ] App Store assets prepared - [ ] TestFlight beta completed - [ ] Ready for App Store submission --- ## Error Handling Summary ### Settings Errors ```swift enum SettingsError: Error, LocalizedError { case cacheCleanupFailed case dataDeleteFailed case preferenceSaveFailed var errorDescription: String? { switch self { case .cacheCleanupFailed: return "Failed to clear cache" case .dataDeleteFailed: return "Failed to delete data" case .preferenceSaveFailed: return "Failed to save preference" } } } ``` --- ## Notes - Error messages should be actionable and user-friendly - Loading skeletons should match the approximate layout of real content - Accessibility is not optional - test thoroughly with real VoiceOver users if possible - Performance testing should be done on oldest supported device (iPhone 8 / iOS 17) - UI tests should be deterministic - avoid flaky tests - Beta testers should include diverse users (different devices, usage patterns) - App Store review may take 24-48 hours - plan accordingly - Keep crash reporting enabled even after release for production monitoring --- ## Dependencies | Dependency | Type | Notes | |------------|------|-------| | NWPathMonitor | System | Network reachability | | UIAccessibility | System | VoiceOver announcements | | XCTest | System | Unit testing | | XCUITest | System | UI testing | | UserDefaults | System | Settings persistence | | Bundle | System | App version info | --- ## Risk Mitigation | Risk | Mitigation | |------|------------| | App Store rejection | Follow all guidelines, provide complete review notes | | Performance regression | Automated performance tests in CI | | Accessibility issues | Test with real VoiceOver users | | Flaky UI tests | Use explicit waits, deterministic test data | | Missing error cases | Comprehensive error type coverage | | Memory leaks | Regular Instruments profiling | | Beta feedback overload | Prioritize critical issues, defer nice-to-haves | | Last-minute bugs | Freeze features early, focus on stability | | App Review delays | Submit early, have contingency timeline | | Certificate expiry | Verify certificates before submission |