- 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>
1351 lines
48 KiB
Markdown
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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|