Files
PlantGuide/Docs/Phase5_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

48 KiB

Phase 5: Plant Collection & Persistence

Goal: Saved plants with full offline support and collection management

Prerequisites: Phase 4 complete (Trefle API integration working, care schedules functional, notifications implemented)


Tasks

5.1 Define Core Data Models

  • Create Botanica.xcdatamodeld if not already present
  • Define PlantMO (Managed Object):
    // Core Data Entity: Plant
    // Attributes:
    //   id: UUID
    //   scientificName: String
    //   commonNames: Transformable ([String])
    //   family: String
    //   genus: String
    //   imageURLs: Transformable ([URL])
    //   localImagePaths: Transformable ([String])
    //   dateIdentified: Date
    //   dateAdded: Date
    //   identificationSource: String (enum raw value)
    //   confidenceScore: Double
    //   notes: String?
    //   isFavorite: Bool
    //   customName: String?
    //   location: String?
    //
    // Relationships:
    //   careSchedule: CareScheduleMO (1:1, cascade delete)
    //   identifications: [IdentificationMO] (1:many, cascade delete)
    
  • Define CareScheduleMO:
    // Core Data Entity: CareSchedule
    // Attributes:
    //   id: UUID
    //   lightRequirement: String (enum raw value)
    //   wateringFrequency: String
    //   wateringAmount: String
    //   temperatureMin: Double
    //   temperatureMax: Double
    //   temperatureOptimal: Double?
    //   fertilizerFrequency: String?
    //   fertilizerType: String?
    //   humidity: String?
    //   lastUpdated: Date
    //
    // Relationships:
    //   plant: PlantMO (inverse)
    //   tasks: [CareTaskMO] (1:many, cascade delete)
    
  • Define CareTaskMO:
    // Core Data Entity: CareTask
    // Attributes:
    //   id: UUID
    //   type: String (enum raw value)
    //   scheduledDate: Date
    //   isCompleted: Bool
    //   completedDate: Date?
    //   notes: String?
    //   notificationID: String?
    //
    // Relationships:
    //   schedule: CareScheduleMO (inverse)
    
  • Define IdentificationMO:
    // Core Data Entity: Identification
    // Attributes:
    //   id: UUID
    //   date: Date
    //   source: String (onDevice, plantNetAPI, hybrid)
    //   confidenceScore: Double
    //   topResults: Transformable ([IdentificationResult])
    //   imageData: Binary (external storage)
    //   latitude: Double?
    //   longitude: Double?
    //
    // Relationships:
    //   plant: PlantMO (inverse)
    
  • Create NSManagedObject subclasses with @NSManaged properties
  • Add value transformers for custom types:
    // Core/Utilities/ValueTransformers.swift
    @objc(URLArrayTransformer)
    final class URLArrayTransformer: NSSecureUnarchiveFromDataTransformer {
        static let name = NSValueTransformerName(rawValue: "URLArrayTransformer")
    
        override static var allowedTopLevelClasses: [AnyClass] {
            [NSArray.self, NSURL.self]
        }
    
        static func register() {
            ValueTransformer.setValueTransformer(
                URLArrayTransformer(),
                forName: name
            )
        }
    }
    
    @objc(StringArrayTransformer)
    final class StringArrayTransformer: NSSecureUnarchiveFromDataTransformer {
        static let name = NSValueTransformerName(rawValue: "StringArrayTransformer")
    
        override static var allowedTopLevelClasses: [AnyClass] {
            [NSArray.self, NSString.self]
        }
    
        static func register() {
            ValueTransformer.setValueTransformer(
                StringArrayTransformer(),
                forName: name
            )
        }
    }
    
  • Register transformers in App initialization
  • Add lightweight migration support for future schema changes
  • Write unit tests for model relationships

Acceptance Criteria: Core Data models compile, relationships work correctly, transformers handle custom types


5.2 Implement Core Data Plant Storage

  • Create Data/DataSources/Local/CoreData/CoreDataStack.swift:
    actor CoreDataStack {
        static let shared = CoreDataStack()
    
        private let container: NSPersistentContainer
    
        var viewContext: NSManagedObjectContext {
            container.viewContext
        }
    
        private init() {
            container = NSPersistentContainer(name: "Botanica")
    
            // Enable automatic migration
            let description = container.persistentStoreDescriptions.first
            description?.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption)
            description?.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption)
    
            container.loadPersistentStores { _, error in
                if let error {
                    fatalError("Core Data failed to load: \(error.localizedDescription)")
                }
            }
    
            container.viewContext.automaticallyMergesChangesFromParent = true
            container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        }
    
        func newBackgroundContext() -> NSManagedObjectContext {
            let context = container.newBackgroundContext()
            context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
            return context
        }
    
        func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T {
            try await container.performBackgroundTask { context in
                try block(context)
            }
        }
    }
    
  • Create Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift:
    protocol PlantStorageProtocol: Sendable {
        func save(_ plant: Plant) async throws
        func fetch(id: UUID) async throws -> Plant?
        func fetchAll() async throws -> [Plant]
        func update(_ plant: Plant) async throws
        func delete(id: UUID) async throws
        func search(query: String) async throws -> [Plant]
        func fetchFavorites() async throws -> [Plant]
        func setFavorite(id: UUID, isFavorite: Bool) async throws
    }
    
    final class CoreDataPlantStorage: PlantStorageProtocol, Sendable {
        private let coreDataStack: CoreDataStack
    
        init(coreDataStack: CoreDataStack = .shared) {
            self.coreDataStack = coreDataStack
        }
    
        func save(_ plant: Plant) async throws {
            try await coreDataStack.performBackgroundTask { context in
                let plantMO = PlantMO(context: context)
                self.mapToManagedObject(plant, managedObject: plantMO)
                try context.save()
            }
        }
    
        func fetchAll() async throws -> [Plant] {
            try await coreDataStack.performBackgroundTask { context in
                let request = PlantMO.fetchRequest()
                request.sortDescriptors = [NSSortDescriptor(keyPath: \PlantMO.dateAdded, ascending: false)]
                let results = try context.fetch(request)
                return results.map { self.mapToDomainEntity($0) }
            }
        }
    
        // ... implement remaining methods
    }
    
  • Implement mapping between domain entities and managed objects:
    // Data/Mappers/CoreDataPlantMapper.swift
    struct CoreDataPlantMapper {
        static func mapToManagedObject(_ plant: Plant, managedObject: PlantMO) {
            managedObject.id = plant.id
            managedObject.scientificName = plant.scientificName
            managedObject.commonNames = plant.commonNames as NSArray
            managedObject.family = plant.family
            managedObject.genus = plant.genus
            managedObject.imageURLs = plant.imageURLs as NSArray
            managedObject.dateIdentified = plant.dateIdentified
            managedObject.dateAdded = plant.dateAdded ?? Date()
            managedObject.identificationSource = plant.identificationSource.rawValue
            managedObject.confidenceScore = plant.confidenceScore ?? 0
            managedObject.notes = plant.notes
            managedObject.isFavorite = plant.isFavorite
            managedObject.customName = plant.customName
            managedObject.location = plant.location
        }
    
        static func mapToDomainEntity(_ managedObject: PlantMO) -> Plant {
            Plant(
                id: managedObject.id ?? UUID(),
                scientificName: managedObject.scientificName ?? "",
                commonNames: managedObject.commonNames as? [String] ?? [],
                family: managedObject.family ?? "",
                genus: managedObject.genus ?? "",
                imageURLs: managedObject.imageURLs as? [URL] ?? [],
                dateIdentified: managedObject.dateIdentified ?? Date(),
                identificationSource: Plant.IdentificationSource(rawValue: managedObject.identificationSource ?? "") ?? .onDevice,
                dateAdded: managedObject.dateAdded,
                confidenceScore: managedObject.confidenceScore,
                notes: managedObject.notes,
                isFavorite: managedObject.isFavorite,
                customName: managedObject.customName,
                location: managedObject.location
            )
        }
    }
    
  • Handle Core Data errors with custom error types
  • Implement batch delete for performance
  • Add fetch request templates in model
  • Write unit tests with in-memory store

Acceptance Criteria: Storage saves, fetches, updates, deletes plants correctly with thread safety


5.3 Build Plant Collection Repository

  • Create Domain/RepositoryInterfaces/PlantCollectionRepositoryProtocol.swift:
    protocol PlantCollectionRepositoryProtocol: Sendable {
        // CRUD
        func addPlant(_ plant: Plant) async throws
        func getPlant(id: UUID) async throws -> Plant?
        func getAllPlants() async throws -> [Plant]
        func updatePlant(_ plant: Plant) async throws
        func deletePlant(id: UUID) async throws
    
        // Collection management
        func searchPlants(query: String) async throws -> [Plant]
        func filterPlants(by filter: PlantFilter) async throws -> [Plant]
        func getFavorites() async throws -> [Plant]
        func setFavorite(plantID: UUID, isFavorite: Bool) async throws
    
        // Care schedule
        func saveCareSchedule(_ schedule: PlantCareSchedule, for plantID: UUID) async throws
        func getCareSchedule(for plantID: UUID) async throws -> PlantCareSchedule?
        func getUpcomingTasks(days: Int) async throws -> [CareTask]
    
        // Identification history
        func saveIdentification(_ identification: PlantIdentification, for plantID: UUID) async throws
        func getIdentificationHistory(for plantID: UUID) async throws -> [PlantIdentification]
    
        // Statistics
        func getCollectionStatistics() async throws -> CollectionStatistics
    }
    
    struct PlantFilter: Sendable {
        var searchQuery: String?
        var families: Set<String>?
        var lightRequirements: Set<LightRequirement>?
        var wateringFrequencies: Set<WateringFrequency>?
        var isFavorite: Bool?
        var identificationSource: Plant.IdentificationSource?
        var dateRange: ClosedRange<Date>?
        var sortBy: SortOption = .dateAdded
        var sortAscending: Bool = false
    
        enum SortOption: String, CaseIterable, Sendable {
            case dateAdded, dateIdentified, name, family
        }
    }
    
    struct CollectionStatistics: Sendable {
        let totalPlants: Int
        let favoriteCount: Int
        let familyDistribution: [String: Int]
        let identificationSourceBreakdown: [Plant.IdentificationSource: Int]
        let plantsAddedThisMonth: Int
        let upcomingTasksCount: Int
        let overdueTasksCount: Int
    }
    
  • Create Data/Repositories/PlantCollectionRepository.swift:
    final class PlantCollectionRepository: PlantCollectionRepositoryProtocol, Sendable {
        private let plantStorage: PlantStorageProtocol
        private let careScheduleStorage: CareScheduleStorageProtocol
        private let imageCache: ImageCacheProtocol
    
        init(
            plantStorage: PlantStorageProtocol,
            careScheduleStorage: CareScheduleStorageProtocol,
            imageCache: ImageCacheProtocol
        ) {
            self.plantStorage = plantStorage
            self.careScheduleStorage = careScheduleStorage
            self.imageCache = imageCache
        }
    
        func addPlant(_ plant: Plant) async throws {
            // Save plant to Core Data
            try await plantStorage.save(plant)
    
            // Cache images for offline access
            for url in plant.imageURLs {
                try? await imageCache.cacheImage(from: url, for: plant.id)
            }
        }
    
        func filterPlants(by filter: PlantFilter) async throws -> [Plant] {
            var plants = try await plantStorage.fetchAll()
    
            if let query = filter.searchQuery, !query.isEmpty {
                plants = plants.filter { plant in
                    plant.scientificName.localizedCaseInsensitiveContains(query) ||
                    plant.commonNames.contains { $0.localizedCaseInsensitiveContains(query) } ||
                    plant.family.localizedCaseInsensitiveContains(query)
                }
            }
    
            if let families = filter.families {
                plants = plants.filter { families.contains($0.family) }
            }
    
            if let isFavorite = filter.isFavorite {
                plants = plants.filter { $0.isFavorite == isFavorite }
            }
    
            // Apply sorting
            plants.sort { lhs, rhs in
                let comparison: Bool
                switch filter.sortBy {
                case .dateAdded:
                    comparison = (lhs.dateAdded ?? .distantPast) > (rhs.dateAdded ?? .distantPast)
                case .dateIdentified:
                    comparison = lhs.dateIdentified > rhs.dateIdentified
                case .name:
                    comparison = lhs.displayName < rhs.displayName
                case .family:
                    comparison = lhs.family < rhs.family
                }
                return filter.sortAscending ? !comparison : comparison
            }
    
            return plants
        }
    
        // ... implement remaining methods
    }
    
  • Add CareScheduleStorageProtocol and implementation
  • Implement getUpcomingTasks with proper date filtering
  • Add observation/publisher for collection changes
  • Register in DIContainer
  • Write integration tests

Acceptance Criteria: Repository coordinates storage, filtering, and statistics correctly


5.4 Create Collection Use Cases

  • Create Domain/UseCases/Collection/SavePlantUseCase.swift:
    protocol SavePlantUseCaseProtocol: Sendable {
        func execute(
            plant: Plant,
            capturedImage: UIImage?,
            careInfo: PlantCareInfo?,
            preferences: CarePreferences?
        ) async throws -> Plant
    }
    
    final class SavePlantUseCase: SavePlantUseCaseProtocol, Sendable {
        private let repository: PlantCollectionRepositoryProtocol
        private let careScheduleUseCase: CreateCareScheduleUseCaseProtocol
        private let notificationService: NotificationServiceProtocol
        private let imageStorage: ImageStorageProtocol
    
        init(
            repository: PlantCollectionRepositoryProtocol,
            careScheduleUseCase: CreateCareScheduleUseCaseProtocol,
            notificationService: NotificationServiceProtocol,
            imageStorage: ImageStorageProtocol
        ) {
            self.repository = repository
            self.careScheduleUseCase = careScheduleUseCase
            self.notificationService = notificationService
            self.imageStorage = imageStorage
        }
    
        func execute(
            plant: Plant,
            capturedImage: UIImage?,
            careInfo: PlantCareInfo?,
            preferences: CarePreferences?
        ) async throws -> Plant {
            var plantToSave = plant
            plantToSave.dateAdded = Date()
    
            // Save captured image locally
            if let image = capturedImage {
                let localPath = try await imageStorage.save(image, for: plant.id)
                plantToSave.localImagePaths.append(localPath)
            }
    
            // Add plant to collection
            try await repository.addPlant(plantToSave)
    
            // Create care schedule if care info available
            if let careInfo {
                let schedule = try await careScheduleUseCase.execute(
                    for: plantToSave,
                    careInfo: careInfo,
                    userPreferences: preferences
                )
    
                try await repository.saveCareSchedule(schedule, for: plant.id)
    
                // Schedule notifications
                for task in schedule.tasks.prefix(20) { // Limit to avoid notification cap
                    try? await notificationService.scheduleReminder(for: task, plant: plantToSave)
                }
            }
    
            return plantToSave
        }
    }
    
  • Create Domain/UseCases/Collection/FetchCollectionUseCase.swift:
    protocol FetchCollectionUseCaseProtocol: Sendable {
        func execute() async throws -> [Plant]
        func execute(filter: PlantFilter) async throws -> [Plant]
        func fetchStatistics() async throws -> CollectionStatistics
    }
    
    final class FetchCollectionUseCase: FetchCollectionUseCaseProtocol, Sendable {
        private let repository: PlantCollectionRepositoryProtocol
    
        init(repository: PlantCollectionRepositoryProtocol) {
            self.repository = repository
        }
    
        func execute() async throws -> [Plant] {
            try await repository.getAllPlants()
        }
    
        func execute(filter: PlantFilter) async throws -> [Plant] {
            try await repository.filterPlants(by: filter)
        }
    
        func fetchStatistics() async throws -> CollectionStatistics {
            try await repository.getCollectionStatistics()
        }
    }
    
  • Create Domain/UseCases/Collection/DeletePlantUseCase.swift:
    protocol DeletePlantUseCaseProtocol: Sendable {
        func execute(plantID: UUID) async throws
    }
    
    final class DeletePlantUseCase: DeletePlantUseCaseProtocol, Sendable {
        private let repository: PlantCollectionRepositoryProtocol
        private let notificationService: NotificationServiceProtocol
        private let imageStorage: ImageStorageProtocol
    
        func execute(plantID: UUID) async throws {
            // Cancel all notifications for this plant
            await notificationService.cancelAllReminders(for: plantID)
    
            // Delete cached images
            try await imageStorage.deleteAll(for: plantID)
    
            // Delete from repository (cascades to care schedule and tasks)
            try await repository.deletePlant(id: plantID)
        }
    }
    
  • Create Domain/UseCases/Collection/UpdatePlantUseCase.swift
  • Create Domain/UseCases/Collection/ToggleFavoriteUseCase.swift
  • Register all use cases in DIContainer
  • Write unit tests for each use case

Acceptance Criteria: Use cases handle complete plant lifecycle with notifications and image cleanup


5.5 Build Collection View

  • Create Presentation/Scenes/Collection/CollectionView.swift:
    struct CollectionView: View {
        @State private var viewModel: CollectionViewModel
        @State private var searchText = ""
        @State private var showingFilter = false
        @State private var selectedPlant: Plant?
        @State private var viewMode: ViewMode = .grid
    
        enum ViewMode: String, CaseIterable {
            case grid, list
        }
    
        var body: some View {
            NavigationStack {
                Group {
                    if viewModel.isLoading && viewModel.plants.isEmpty {
                        CollectionSkeletonView()
                    } else if viewModel.plants.isEmpty {
                        EmptyCollectionView(onAddPlant: { /* navigate to camera */ })
                    } else {
                        collectionContent
                    }
                }
                .navigationTitle("My Plants")
                .searchable(text: $searchText, prompt: "Search plants...")
                .onChange(of: searchText) { _, newValue in
                    viewModel.search(query: newValue)
                }
                .toolbar {
                    ToolbarItem(placement: .topBarLeading) {
                        ViewModePicker(selection: $viewMode)
                    }
                    ToolbarItem(placement: .topBarTrailing) {
                        FilterButton(isActive: viewModel.hasActiveFilters) {
                            showingFilter = true
                        }
                    }
                }
                .sheet(isPresented: $showingFilter) {
                    FilterView(filter: $viewModel.filter, onApply: viewModel.applyFilter)
                }
                .navigationDestination(item: $selectedPlant) { plant in
                    PlantDetailView(plant: plant)
                }
            }
            .task {
                await viewModel.loadCollection()
            }
        }
    
        @ViewBuilder
        private var collectionContent: some View {
            switch viewMode {
            case .grid:
                PlantGridView(
                    plants: viewModel.plants,
                    onSelect: { selectedPlant = $0 },
                    onDelete: viewModel.deletePlant,
                    onToggleFavorite: viewModel.toggleFavorite
                )
            case .list:
                PlantListView(
                    plants: viewModel.plants,
                    onSelect: { selectedPlant = $0 },
                    onDelete: viewModel.deletePlant,
                    onToggleFavorite: viewModel.toggleFavorite
                )
            }
        }
    }
    
  • Create CollectionViewModel:
    @Observable
    final class CollectionViewModel {
        private(set) var plants: [Plant] = []
        private(set) var statistics: CollectionStatistics?
        private(set) var isLoading = false
        private(set) var error: Error?
        var filter = PlantFilter()
    
        private let fetchUseCase: FetchCollectionUseCaseProtocol
        private let deleteUseCase: DeletePlantUseCaseProtocol
        private let toggleFavoriteUseCase: ToggleFavoriteUseCaseProtocol
    
        var hasActiveFilters: Bool {
            filter.families != nil ||
            filter.isFavorite != nil ||
            filter.searchQuery?.isEmpty == false
        }
    
        func loadCollection() async {
            isLoading = true
            defer { isLoading = false }
    
            do {
                plants = try await fetchUseCase.execute(filter: filter)
                statistics = try await fetchUseCase.fetchStatistics()
            } catch {
                self.error = error
            }
        }
    
        func search(query: String) {
            filter.searchQuery = query.isEmpty ? nil : query
            Task { await loadCollection() }
        }
    
        func deletePlant(_ plant: Plant) {
            Task {
                do {
                    try await deleteUseCase.execute(plantID: plant.id)
                    plants.removeAll { $0.id == plant.id }
                } catch {
                    self.error = error
                }
            }
        }
    
        func toggleFavorite(_ plant: Plant) {
            Task {
                do {
                    try await toggleFavoriteUseCase.execute(plantID: plant.id)
                    if let index = plants.firstIndex(where: { $0.id == plant.id }) {
                        plants[index].isFavorite.toggle()
                    }
                } catch {
                    self.error = error
                }
            }
        }
    }
    
  • Create PlantGridView:
    struct PlantGridView: View {
        let plants: [Plant]
        let onSelect: (Plant) -> Void
        let onDelete: (Plant) -> Void
        let onToggleFavorite: (Plant) -> Void
    
        private let columns = [
            GridItem(.adaptive(minimum: 150, maximum: 200), spacing: 16)
        ]
    
        var body: some View {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(plants) { plant in
                        PlantGridCard(plant: plant)
                            .onTapGesture { onSelect(plant) }
                            .contextMenu {
                                FavoriteButton(plant: plant, action: onToggleFavorite)
                                DeleteButton(plant: plant, action: onDelete)
                            }
                    }
                }
                .padding()
            }
        }
    }
    
  • Create PlantGridCard component:
    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)
                        }
                    }
    
                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))
        }
    }
    
  • Create PlantListView for list mode
  • Create EmptyCollectionView with call-to-action
  • Create CollectionSkeletonView loading state
  • Add pull-to-refresh
  • Implement swipe actions for quick delete/favorite

Acceptance Criteria: Collection view displays plants in grid/list with search, filter, and management actions


5.6 Implement Image Cache

  • Create Data/DataSources/Local/Cache/ImageCache.swift:
    protocol ImageCacheProtocol: Sendable {
        func cacheImage(from url: URL, for plantID: UUID) async throws
        func getCachedImage(for plantID: UUID, urlHash: String) async -> UIImage?
        func clearCache(for plantID: UUID) async
        func clearAllCache() async
        func getCacheSize() async -> Int64
    }
    
    actor ImageCache: ImageCacheProtocol {
        private let fileManager = FileManager.default
        private let memoryCache = NSCache<NSString, UIImage>()
        private let cacheDirectory: URL
    
        init() {
            let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            cacheDirectory = cachesDirectory.appendingPathComponent("PlantImages", isDirectory: true)
    
            try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
    
            memoryCache.countLimit = 50
            memoryCache.totalCostLimit = 100 * 1024 * 1024 // 100MB
        }
    
        func cacheImage(from url: URL, for plantID: UUID) async throws {
            let (data, _) = try await URLSession.shared.data(from: url)
    
            guard let image = UIImage(data: data) else {
                throw ImageCacheError.invalidImageData
            }
    
            let plantDirectory = cacheDirectory.appendingPathComponent(plantID.uuidString)
            try fileManager.createDirectory(at: plantDirectory, withIntermediateDirectories: true)
    
            let filename = url.absoluteString.sha256Hash + ".jpg"
            let filePath = plantDirectory.appendingPathComponent(filename)
    
            // Save compressed JPEG
            guard let jpegData = image.jpegData(compressionQuality: 0.8) else {
                throw ImageCacheError.compressionFailed
            }
    
            try jpegData.write(to: filePath)
    
            // Add to memory cache
            let cacheKey = "\(plantID.uuidString)-\(filename)" as NSString
            memoryCache.setObject(image, forKey: cacheKey, cost: jpegData.count)
        }
    
        func getCachedImage(for plantID: UUID, urlHash: String) async -> UIImage? {
            let cacheKey = "\(plantID.uuidString)-\(urlHash).jpg" as NSString
    
            // Check memory cache first
            if let cached = memoryCache.object(forKey: cacheKey) {
                return cached
            }
    
            // Check disk cache
            let filePath = cacheDirectory
                .appendingPathComponent(plantID.uuidString)
                .appendingPathComponent("\(urlHash).jpg")
    
            guard let data = try? Data(contentsOf: filePath),
                  let image = UIImage(data: data) else {
                return nil
            }
    
            // Populate memory cache
            memoryCache.setObject(image, forKey: cacheKey, cost: data.count)
    
            return image
        }
    
        func clearCache(for plantID: UUID) async {
            let plantDirectory = cacheDirectory.appendingPathComponent(plantID.uuidString)
            try? fileManager.removeItem(at: plantDirectory)
        }
    
        func getCacheSize() async -> Int64 {
            guard let enumerator = fileManager.enumerator(
                at: cacheDirectory,
                includingPropertiesForKeys: [.fileSizeKey]
            ) else { return 0 }
    
            var totalSize: Int64 = 0
            for case let fileURL as URL in enumerator {
                let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize
                totalSize += Int64(size ?? 0)
            }
            return totalSize
        }
    }
    
    enum ImageCacheError: Error, LocalizedError {
        case invalidImageData
        case compressionFailed
        case writeFailed
    
        var errorDescription: String? {
            switch self {
            case .invalidImageData: return "Invalid image data"
            case .compressionFailed: return "Failed to compress image"
            case .writeFailed: return "Failed to write image to cache"
            }
        }
    }
    
  • Create CachedAsyncImage SwiftUI component:
    struct CachedAsyncImage: View {
        let url: URL?
        let plantID: UUID
    
        @State private var image: UIImage?
        @State private var isLoading = false
    
        @Environment(\.imageCache) private var imageCache
    
        var body: some View {
            Group {
                if let image {
                    Image(uiImage: image)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } else if isLoading {
                    ProgressView()
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(Color.gray.opacity(0.1))
                } else {
                    Image(systemName: "leaf.fill")
                        .font(.largeTitle)
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(Color.gray.opacity(0.1))
                }
            }
            .task {
                await loadImage()
            }
        }
    
        private func loadImage() async {
            guard let url else { return }
    
            isLoading = true
            defer { isLoading = false }
    
            let urlHash = url.absoluteString.sha256Hash
    
            // Try cache first
            if let cached = await imageCache.getCachedImage(for: plantID, urlHash: urlHash) {
                self.image = cached
                return
            }
    
            // Download and cache
            do {
                try await imageCache.cacheImage(from: url, for: plantID)
                self.image = await imageCache.getCachedImage(for: plantID, urlHash: urlHash)
            } catch {
                // Fallback to direct load without caching
                if let (data, _) = try? await URLSession.shared.data(from: url),
                   let downloadedImage = UIImage(data: data) {
                    self.image = downloadedImage
                }
            }
        }
    }
    
  • Add SHA256 hash extension for URL strings:
    extension String {
        var sha256Hash: String {
            let data = Data(self.utf8)
            var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
            data.withUnsafeBytes {
                _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
            }
            return hash.map { String(format: "%02x", $0) }.joined()
        }
    }
    
  • Create ImageStorageProtocol for captured photos:
    protocol ImageStorageProtocol: Sendable {
        func save(_ image: UIImage, for plantID: UUID) async throws -> String
        func load(path: String) async -> UIImage?
        func delete(path: String) async throws
        func deleteAll(for plantID: UUID) async throws
    }
    
  • Implement LocalImageStorage (saves to Documents directory)
  • Add cache eviction policy (LRU, max size)
  • Register in DIContainer
  • Write unit tests

Acceptance Criteria: Images cached to disk, loaded from cache when available, evicted when needed


5.7 Add Search and Filter in Collection

  • Create Presentation/Scenes/Collection/FilterView.swift:
    struct FilterView: View {
        @Binding var filter: PlantFilter
        let onApply: () -> Void
        @Environment(\.dismiss) private var dismiss
    
        var body: some View {
            NavigationStack {
                Form {
                    Section("Sort By") {
                        Picker("Sort", selection: $filter.sortBy) {
                            ForEach(PlantFilter.SortOption.allCases, id: \.self) { option in
                                Text(option.displayName).tag(option)
                            }
                        }
                        Toggle("Ascending", isOn: $filter.sortAscending)
                    }
    
                    Section("Filter") {
                        Toggle("Favorites Only", isOn: Binding(
                            get: { filter.isFavorite == true },
                            set: { filter.isFavorite = $0 ? true : nil }
                        ))
    
                        if !availableFamilies.isEmpty {
                            NavigationLink("Plant Family") {
                                FamilyFilterView(
                                    families: availableFamilies,
                                    selected: $filter.families
                                )
                            }
                        }
    
                        NavigationLink("Light Requirements") {
                            LightFilterView(selected: $filter.lightRequirements)
                        }
    
                        NavigationLink("Watering Frequency") {
                            WateringFilterView(selected: $filter.wateringFrequencies)
                        }
                    }
    
                    Section("Identification Source") {
                        Picker("Source", selection: $filter.identificationSource) {
                            Text("All").tag(nil as Plant.IdentificationSource?)
                            ForEach(Plant.IdentificationSource.allCases, id: \.self) { source in
                                Text(source.displayName).tag(source as Plant.IdentificationSource?)
                            }
                        }
                    }
                }
                .navigationTitle("Filter Plants")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Reset") {
                            filter = PlantFilter()
                        }
                    }
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Apply") {
                            onApply()
                            dismiss()
                        }
                    }
                }
            }
        }
    }
    
  • Implement family filter view with multi-select
  • Implement light requirement filter
  • Implement watering frequency filter
  • Create SearchResultsView for highlighted matches:
    struct SearchResultsView: View {
        let plants: [Plant]
        let searchQuery: String
        let onSelect: (Plant) -> Void
    
        var body: some View {
            List(plants) { plant in
                SearchResultRow(plant: plant, query: searchQuery)
                    .onTapGesture { onSelect(plant) }
            }
        }
    }
    
    struct SearchResultRow: View {
        let plant: Plant
        let query: String
    
        var body: some View {
            HStack {
                CachedAsyncImage(url: plant.imageURLs.first, plantID: plant.id)
                    .frame(width: 60, height: 60)
                    .clipShape(RoundedRectangle(cornerRadius: 8))
    
                VStack(alignment: .leading) {
                    HighlightedText(plant.displayName, highlight: query)
                        .font(.headline)
                    HighlightedText(plant.scientificName, highlight: query)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .italic()
                    if plant.family.localizedCaseInsensitiveContains(query) {
                        HighlightedText(plant.family, highlight: query)
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                    }
                }
            }
        }
    }
    
  • Create HighlightedText component for search highlighting
  • Add search suggestions based on existing plants
  • Implement recent searches storage
  • Add voice search support (optional)
  • Save filter preferences to UserDefaults
  • Debounce search input (300ms)

Acceptance Criteria: Search filters plants in real-time, filters combine correctly, results highlight matches


End-of-Phase Validation

Functional Verification

Test Steps Expected Result Status
Save Plant Identify plant → Tap "Add to Collection" Plant saved, appears in collection [ ]
Fetch Collection Open Collection tab All saved plants displayed [ ]
Delete Plant Swipe plant → Delete Plant removed, notifications cancelled [ ]
Update Plant Edit plant name/notes Changes persisted after restart [ ]
Toggle Favorite Tap heart icon Favorite state persists [ ]
Search by Name Type "Monstera" Matching plants shown [ ]
Search by Family Type "Araceae" Plants from family shown [ ]
Search by Scientific Name Type "Deliciosa" Matching plants shown [ ]
Filter Favorites Enable "Favorites Only" Only favorites shown [ ]
Filter by Family Select "Araceae" Only that family shown [ ]
Sort by Date Added Sort descending Newest first [ ]
Sort by Name Sort ascending Alphabetical order [ ]
Grid View Select grid mode Plants in grid layout [ ]
List View Select list mode Plants in list layout [ ]
Empty State No plants saved Empty state with CTA [ ]
Image Cache Hit View plant, go back, return Image loads instantly [ ]
Image Cache Miss Clear cache, view plant Image downloads and caches [ ]
Offline Images Disable network, view collection Cached images display [ ]
App Restart Add plant, kill app, relaunch Plant still in collection [ ]
Care Schedule Saved Save plant with care info Schedule created and persists [ ]

Code Quality Verification

Check Criteria Status
Build Project builds with zero warnings [ ]
Architecture Repository pattern isolates Core Data [ ]
Core Data Stack Thread-safe with actor isolation [ ]
Value Transformers Custom types serialize correctly [ ]
Protocols All storage/repository uses protocols [ ]
Sendable All new types conform to Sendable [ ]
Mappers Bidirectional mapping works correctly [ ]
Use Cases Business logic in use cases, not ViewModels [ ]
DI Container All new services registered [ ]
Error Types Collection-specific errors defined [ ]
Unit Tests Storage, repository, use cases tested [ ]
Memory Management No retain cycles in image cache [ ]
Cascade Delete Deleting plant removes schedule/tasks [ ]

Performance Verification

Metric Target Actual Status
Collection Load (10 plants) < 100ms [ ]
Collection Load (100 plants) < 500ms [ ]
Collection Load (500 plants) < 2 seconds [ ]
Search Response (10 plants) < 50ms [ ]
Search Response (100 plants) < 200ms [ ]
Image Cache Lookup < 20ms [ ]
Image Cache Write < 100ms [ ]
Plant Save < 500ms [ ]
Plant Delete < 200ms [ ]
Filter Apply < 100ms [ ]
Grid Scroll (60fps) No dropped frames [ ]
Memory (100 plants + images) < 150MB [ ]

Persistence Verification

Test Steps Expected Result Status
Data Survives Restart Add plants, force quit, relaunch All data present [ ]
Data Survives Update Simulate app update Data migrates correctly [ ]
Relationships Intact Save plant with schedule Schedule linked correctly [ ]
Cascade Delete Works Delete plant Schedule and tasks deleted [ ]
Transformers Work Save plant with arrays Arrays restore correctly [ ]
Background Save Save from background No crash, data persists [ ]
Concurrent Access Save/fetch simultaneously No race conditions [ ]
Large Collection Add 500 plants No memory issues [ ]
Corrupt Data Handling Invalid data in store Graceful error, no crash [ ]

Image Cache Verification

Test Steps Expected Result Status
Network Image Cached View plant with URL image Image cached to disk [ ]
Cached Image Used View same plant again Loads from cache, no network [ ]
Memory Cache Works View image, scroll, return Instant load from memory [ ]
Disk Cache Works View image, restart app Loads from disk cache [ ]
Cache Per Plant Cache images for plant A Not used for plant B [ ]
Cache Cleared on Delete Delete plant Cached images removed [ ]
Clear All Cache Call clearAllCache() All images removed [ ]
Cache Size Accurate Add 10 images Size reports correctly [ ]
Cache Eviction Exceed cache limit Old images evicted [ ]
Invalid URL Handled Provide broken URL Placeholder shown, no crash [ ]
Offline Mode Disable network Cached images work [ ]
Offline New Image Disable network, new plant Placeholder shown [ ]

Search & Filter Verification

Test Steps Expected Result Status
Empty Search Search "" All plants shown [ ]
No Results Search "xyzabc123" "No results" message [ ]
Case Insensitive Search "MONSTERA" Finds "Monstera" [ ]
Partial Match Search "Mon" Finds "Monstera" [ ]
Common Name Match Search "Swiss cheese" Finds Monstera deliciosa [ ]
Scientific Name Match Search "deliciosa" Finds correct plant [ ]
Family Match Search "Araceae" Finds all Araceae plants [ ]
Combined Filters Favorites + Family filter Intersection shown [ ]
Filter Reset Tap "Reset" All filters cleared [ ]
Sort Persists Change sort, leave, return Sort preserved [ ]
Debounce Works Type quickly Single search at end [ ]
Highlight Works Search "del" "del" highlighted in results [ ]

Phase 5 Completion Checklist

  • All 7 tasks completed (core implementation)
  • All functional tests pass
  • All code quality checks pass
  • All performance targets met
  • Core Data models defined correctly
  • Value transformers working for arrays
  • Plant storage CRUD operations working
  • Repository coordinates all data access
  • Use cases handle complete workflows
  • Collection view displays in grid and list
  • Image cache working (memory + disk)
  • Search filters plants correctly
  • Filter combinations work correctly
  • Offline mode works (cached data + images)
  • App restart preserves all data
  • Deleting plant cascades to schedule/tasks/images
  • Unit tests for storage, repository, use cases
  • UI tests for collection management flows
  • Code committed with descriptive message
  • Ready for Phase 6 (Polish & Release)

Error Handling

Persistence Errors

enum PersistenceError: Error, LocalizedError {
    case saveFailed(underlying: Error)
    case fetchFailed(underlying: Error)
    case deleteFailed(underlying: Error)
    case migrationFailed(underlying: Error)
    case plantNotFound(id: UUID)
    case duplicatePlant(id: UUID)
    case invalidData(reason: String)
    case contextError

    var errorDescription: String? {
        switch self {
        case .saveFailed(let error):
            return "Failed to save: \(error.localizedDescription)"
        case .fetchFailed(let error):
            return "Failed to fetch: \(error.localizedDescription)"
        case .deleteFailed(let error):
            return "Failed to delete: \(error.localizedDescription)"
        case .migrationFailed(let error):
            return "Data migration failed: \(error.localizedDescription)"
        case .plantNotFound(let id):
            return "Plant not found: \(id)"
        case .duplicatePlant(let id):
            return "Plant already exists: \(id)"
        case .invalidData(let reason):
            return "Invalid data: \(reason)"
        case .contextError:
            return "Database context error"
        }
    }
}

Collection Errors

enum CollectionError: Error, LocalizedError {
    case emptyCollection
    case filterFailed
    case statisticsUnavailable
    case exportFailed
    case importFailed(reason: String)

    var errorDescription: String? {
        switch self {
        case .emptyCollection:
            return "Your collection is empty"
        case .filterFailed:
            return "Failed to filter plants"
        case .statisticsUnavailable:
            return "Statistics unavailable"
        case .exportFailed:
            return "Failed to export collection"
        case .importFailed(let reason):
            return "Failed to import: \(reason)"
        }
    }
}

Notes

  • Core Data should use background contexts for all write operations
  • Value transformers must be registered before Core Data stack initialization
  • Image cache should use separate memory and disk layers for performance
  • Search should be debounced to avoid excessive filtering on each keystroke
  • Large collections (500+ plants) may need pagination in the view
  • Consider using NSFetchedResultsController for automatic UI updates
  • Cache eviction should prioritize keeping recently viewed images
  • Filter state should persist across app launches
  • Grid layout should use LazyVGrid for virtualization
  • Test on older devices (iPhone 8) for performance verification
  • Consider background app refresh for pre-caching images
  • Document directory for user photos (survives backup), cache directory for remote images

Dependencies

Dependency Type Notes
Core Data System Persistence framework
NSCache System Memory cache for images
FileManager System Disk cache for images
CommonCrypto System SHA256 for cache keys
UIKit (UIImage) System Image processing

Risk Mitigation

Risk Mitigation
Core Data migration fails Use lightweight migration, test upgrade paths
Image cache grows too large Implement size limit with LRU eviction
Memory pressure with large collection Use lazy loading, purge memory cache on warning
Search performance degrades Add Core Data fetch predicates, debounce input
Concurrent access crashes Use actor isolation, merge policies
Data loss on crash Save context after each operation
Value transformer registration race Register in app initialization before Core Data
Orphaned images on delete Implement cascade cleanup in delete use case
Filter combinations produce no results Show helpful "try different filters" message
Large images cause OOM Compress before caching, use thumbnails in grid

Domain Entity Updates

Extended Plant Entity

struct Plant: Identifiable, Sendable {
    let id: UUID
    let scientificName: String
    let commonNames: [String]
    let family: String
    let genus: String
    let imageURLs: [URL]
    var localImagePaths: [String]  // Added for offline
    let dateIdentified: Date
    var dateAdded: Date?           // Added for collection
    let identificationSource: IdentificationSource
    var confidenceScore: Double?   // Added for display
    var notes: String?             // Added for user notes
    var isFavorite: Bool           // Added for favorites
    var customName: String?        // Added for personalization
    var location: String?          // Added for organization

    var displayName: String {
        customName ?? commonNames.first ?? scientificName
    }

    enum IdentificationSource: String, Codable, CaseIterable, Sendable {
        case onDevice, plantNetAPI, hybrid

        var displayName: String {
            switch self {
            case .onDevice: return "On-Device"
            case .plantNetAPI: return "PlantNet"
            case .hybrid: return "Hybrid"
            }
        }
    }
}