- Implement camera capture and plant identification workflow - Add Core Data persistence for plants, care schedules, and cached API data - Create collection view with grid/list layouts and filtering - Build plant detail views with care information display - Integrate Trefle botanical API for plant care data - Add local image storage for captured plant photos - Implement dependency injection container for testability - Include accessibility support throughout the app Bug fixes in this commit: - Fix Trefle API decoding by removing duplicate CodingKeys - Fix LocalCachedImage to load from correct PlantImages directory - Set dateAdded when saving plants for proper collection sorting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
66 KiB
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: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:@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
APIStatusRowcomponent: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
NetworkStatusBadgecomponent - 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: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
AppErrortype:// 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:
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:
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<AppError?>, retry: (() async -> Void)? = nil) -> some View { modifier(ErrorHandlingModifier(error: error, retryAction: retry)) } } - Add error logging service:
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: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:
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:
struct LoadingStateModifier<Loading: View>: ViewModifier { let isLoading: Bool let loadingView: Loading func body(content: Content) -> some View { if isLoading { loadingView } else { content } } } extension View { func loading<L: View>( _ isLoading: Bool, @ViewBuilder placeholder: () -> L ) -> some View { modifier(LoadingStateModifier(isLoading: isLoading, loadingView: placeholder())) } } - Add button loading states:
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:
// 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:
// 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:
// 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:
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:
@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:
@Environment(\.accessibilityReduceTransparency) var reduceTransparency var body: some View { content .background(reduceTransparency ? Color(.systemBackground) : .ultraThinMaterial) } - Add accessibility identifiers for UI testing:
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:
// 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:
// 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:
// 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<PlantMO> = { 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:
// 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:
// 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:
// Implement request deduplication actor RequestDeduplicator { private var inFlightRequests: [String: Task<Data, Error>] = [:] func deduplicated<T>( 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<Data, Error> return try await task.value } } - Add performance monitoring:
struct PerformanceMonitor { static func measure<T>(_ 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:
// 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:
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:
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:
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:
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:
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:
// 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:
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:
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:
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:
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:
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:
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:
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:
## 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
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 |