- 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>
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.xcdatamodeldif 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
NSManagedObjectsubclasses with@NSManagedproperties - 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
CareScheduleStorageProtocoland implementation - Implement
getUpcomingTaskswith 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
PlantGridCardcomponent: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
PlantListViewfor list mode - Create
EmptyCollectionViewwith call-to-action - Create
CollectionSkeletonViewloading 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
CachedAsyncImageSwiftUI 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
ImageStorageProtocolfor 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
SearchResultsViewfor 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
HighlightedTextcomponent 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
NSFetchedResultsControllerfor automatic UI updates - Cache eviction should prioritize keeping recently viewed images
- Filter state should persist across app launches
- Grid layout should use
LazyVGridfor 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"
}
}
}
}