# 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): ```swift // 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`: ```swift // 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`: ```swift // 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`: ```swift // 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: ```swift // 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`: ```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(_ 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`: ```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: ```swift // 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`: ```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? var lightRequirements: Set? var wateringFrequencies: Set? var isFavorite: Bool? var identificationSource: Plant.IdentificationSource? var dateRange: ClosedRange? 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`: ```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`: ```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`: ```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`: ```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`: ```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`: ```swift @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`: ```swift 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: ```swift 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`: ```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() 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: ```swift 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: ```swift 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: ```swift 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`: ```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: ```swift 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 ```swift 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 ```swift 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 ```swift 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" } } } } ```