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

1351 lines
48 KiB
Markdown

# 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<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`:
```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<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`:
```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<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:
```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"
}
}
}
}
```