Files
PlantGuide/Docs/Phase6_plan.md
Trey t 136dfbae33 Add PlantGuide iOS app with plant identification and care management
- 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>
2026-01-23 12:18:01 -06:00

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 APIStatusRow component:
    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:
    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:
    // 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