- 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>
270 lines
11 KiB
Markdown
270 lines
11 KiB
Markdown
# Implementation Plan: Persist PlantCareInfo in Core Data
|
|
|
|
## Overview
|
|
|
|
Cache Trefle API care info locally so the API is only called once per plant. This preserves all timing info (watering frequency, fertilizer schedule) for proper notification scheduling.
|
|
|
|
**Goal:** Reduce unnecessary API calls, improve offline experience, preserve care timing data
|
|
|
|
**Estimated Complexity:** Medium
|
|
**Risk Level:** Low (lightweight migration, optional relationships)
|
|
|
|
---
|
|
|
|
## Phase 1: Value Transformers
|
|
|
|
Add JSON-based transformers for complex types that need Core Data persistence.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 1.1 | `Core/Utilities/ValueTransformers.swift` | Add `WateringScheduleTransformer` - encodes `WateringSchedule` struct to JSON Data |
|
|
| 1.2 | `Core/Utilities/ValueTransformers.swift` | Add `TemperatureRangeTransformer` - encodes `TemperatureRange` struct to JSON Data |
|
|
| 1.3 | `Core/Utilities/ValueTransformers.swift` | Add `FertilizerScheduleTransformer` - encodes `FertilizerSchedule` struct to JSON Data |
|
|
| 1.4 | `Core/Utilities/ValueTransformers.swift` | Add `SeasonArrayTransformer` - encodes `[Season]` array to JSON Data |
|
|
| 1.5 | `App/PlantGuideApp.swift` | Register all 4 new transformers in app init |
|
|
|
|
### Acceptance Criteria
|
|
- [ ] All transformers follow existing `IdentificationResultArrayTransformer` pattern
|
|
- [ ] Transformers handle nil values gracefully
|
|
- [ ] Round-trip encoding/decoding preserves all data
|
|
- [ ] Build succeeds with no warnings
|
|
|
|
---
|
|
|
|
## Phase 2: Core Data Model Update
|
|
|
|
Add the `PlantCareInfoMO` entity and relationship to `PlantMO`.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 2.1 | `PlantGuideModel.xcdatamodeld` | Create new `PlantCareInfoMO` entity with all attributes (see schema below) |
|
|
| 2.2 | `PlantGuideModel.xcdatamodeld` | Add `plant` relationship from `PlantCareInfoMO` to `PlantMO` (optional, one-to-one) |
|
|
| 2.3 | `PlantGuideModel.xcdatamodeld` | Add `plantCareInfo` relationship from `PlantMO` to `PlantCareInfoMO` (optional, cascade delete) |
|
|
| 2.4 | `ManagedObjects/PlantMO.swift` | Add `@NSManaged public var plantCareInfo: PlantCareInfoMO?` property |
|
|
|
|
### PlantCareInfoMO Schema
|
|
|
|
| Attribute | Type | Notes |
|
|
|-----------|------|-------|
|
|
| `id` | UUID | Required, unique |
|
|
| `scientificName` | String | Required |
|
|
| `commonName` | String | Optional |
|
|
| `lightRequirement` | String | Enum rawValue |
|
|
| `wateringScheduleData` | Binary | JSON-encoded WateringSchedule |
|
|
| `temperatureRangeData` | Binary | JSON-encoded TemperatureRange |
|
|
| `fertilizerScheduleData` | Binary | Optional, JSON-encoded |
|
|
| `humidity` | String | Optional, enum rawValue |
|
|
| `growthRate` | String | Optional, enum rawValue |
|
|
| `bloomingSeasonData` | Binary | Optional, JSON-encoded [Season] |
|
|
| `additionalNotes` | String | Optional |
|
|
| `sourceURL` | URI | Optional |
|
|
| `trefleID` | Integer 32 | Optional |
|
|
| `fetchedAt` | Date | Required, for cache expiration |
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Entity created with all attributes correctly typed
|
|
- [ ] Relationships defined with proper inverse relationships
|
|
- [ ] Cascade delete rule set on PlantMO side
|
|
- [ ] Build succeeds - lightweight migration should auto-apply
|
|
|
|
---
|
|
|
|
## Phase 3: Managed Object Implementation
|
|
|
|
Create the `PlantCareInfoMO` managed object class with domain mapping.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 3.1 | `ManagedObjects/PlantCareInfoMO.swift` | Create new file with `@NSManaged` properties |
|
|
| 3.2 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `toDomainModel() -> PlantCareInfo?` - decodes JSON data to domain structs |
|
|
| 3.3 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `static func fromDomainModel(_:context:) -> PlantCareInfoMO?` - encodes domain to MO |
|
|
| 3.4 | `ManagedObjects/PlantCareInfoMO.swift` | Implement `func update(from: PlantCareInfo)` - updates existing MO from domain model |
|
|
|
|
### Acceptance Criteria
|
|
- [ ] All `@NSManaged` properties defined
|
|
- [ ] `toDomainModel()` handles all optional fields correctly
|
|
- [ ] `fromDomainModel()` creates valid managed object
|
|
- [ ] `update(from:)` preserves relationships
|
|
- [ ] JSON encoding/decoding uses transformers correctly
|
|
|
|
---
|
|
|
|
## Phase 4: Repository Layer
|
|
|
|
Create the repository protocol and Core Data implementation.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 4.1 | `Domain/RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` | Create protocol with fetch, save, delete, and cache staleness methods |
|
|
| 4.2 | `Data/DataSources/Local/CoreData/CoreDataPlantCareInfoStorage.swift` | Create implementation conforming to protocol |
|
|
| 4.3 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(scientificName:)` with predicate query |
|
|
| 4.4 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(trefleID:)` with predicate query |
|
|
| 4.5 | `CoreDataPlantCareInfoStorage.swift` | Implement `fetch(for plantID:)` via relationship |
|
|
| 4.6 | `CoreDataPlantCareInfoStorage.swift` | Implement `save(_:for:)` - creates or updates MO |
|
|
| 4.7 | `CoreDataPlantCareInfoStorage.swift` | Implement `isCacheStale(scientificName:cacheExpiration:)` - checks fetchedAt date |
|
|
| 4.8 | `CoreDataPlantCareInfoStorage.swift` | Implement `delete(for plantID:)` - removes cache entry |
|
|
|
|
### Protocol Definition
|
|
|
|
```swift
|
|
protocol PlantCareInfoRepositoryProtocol: Sendable {
|
|
func fetch(scientificName: String) async throws -> PlantCareInfo?
|
|
func fetch(trefleID: Int) async throws -> PlantCareInfo?
|
|
func fetch(for plantID: UUID) async throws -> PlantCareInfo?
|
|
func save(_ careInfo: PlantCareInfo, for plantID: UUID?) async throws
|
|
func isCacheStale(scientificName: String, cacheExpiration: TimeInterval) async throws -> Bool
|
|
func delete(for plantID: UUID) async throws
|
|
}
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Protocol is `Sendable` for Swift concurrency
|
|
- [ ] All fetch methods return optional (nil if not found)
|
|
- [ ] Save method handles both create and update cases
|
|
- [ ] Cache staleness uses 7-day default expiration
|
|
- [ ] Delete method handles nil relationship gracefully
|
|
|
|
---
|
|
|
|
## Phase 5: Use Case Integration
|
|
|
|
Update `FetchPlantCareUseCase` with cache-first logic.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 5.1 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Inject `PlantCareInfoRepositoryProtocol` dependency |
|
|
| 5.2 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Add cache check at start of `execute()` |
|
|
| 5.3 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Add cache staleness validation (7-day expiration) |
|
|
| 5.4 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Save API response to cache after successful fetch |
|
|
| 5.5 | `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Handle cache errors gracefully (fall back to API) |
|
|
|
|
### Updated Execute Logic
|
|
|
|
```swift
|
|
func execute(scientificName: String) async throws -> PlantCareInfo {
|
|
// 1. Check cache
|
|
if let cached = try await repository.fetch(scientificName: scientificName),
|
|
!(try await repository.isCacheStale(scientificName: scientificName, cacheExpiration: 7 * 24 * 60 * 60)) {
|
|
return cached
|
|
}
|
|
|
|
// 2. Fetch from API
|
|
let careInfo = try await fetchFromAPI(scientificName: scientificName)
|
|
|
|
// 3. Cache result
|
|
try await repository.save(careInfo, for: nil)
|
|
|
|
return careInfo
|
|
}
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Cache hit returns immediately without API call
|
|
- [ ] Stale cache triggers fresh API fetch
|
|
- [ ] API response is saved to cache
|
|
- [ ] Cache errors don't block API fallback
|
|
- [ ] Timing info (watering interval) preserved in cache
|
|
|
|
---
|
|
|
|
## Phase 6: Dependency Injection
|
|
|
|
Wire up all new components in DIContainer.
|
|
|
|
### Tasks
|
|
|
|
| Task | File | Description |
|
|
|------|------|-------------|
|
|
| 6.1 | `Core/DI/DIContainer.swift` | Add `_plantCareInfoStorage` lazy property |
|
|
| 6.2 | `Core/DI/DIContainer.swift` | Add `plantCareInfoRepository` computed accessor |
|
|
| 6.3 | `Core/DI/DIContainer.swift` | Update `_fetchPlantCareUseCase` to inject repository |
|
|
| 6.4 | `Core/DI/DIContainer.swift` | Add storage to `resetAll()` method for testing |
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Storage is lazy-initialized
|
|
- [ ] Repository accessor returns protocol type
|
|
- [ ] Use case receives repository dependency
|
|
- [ ] Reset clears cache for testing
|
|
|
|
---
|
|
|
|
## Phase 7: Verification
|
|
|
|
Validate the implementation works correctly.
|
|
|
|
### Tasks
|
|
|
|
| Task | Type | Description |
|
|
|------|------|-------------|
|
|
| 7.1 | Build | Run `xcodebuild -scheme PlantGuide build` - verify zero errors |
|
|
| 7.2 | Manual Test | Add new plant -> view details (should call Trefle API) |
|
|
| 7.3 | Manual Test | Navigate away and back to details (should NOT call API) |
|
|
| 7.4 | Manual Test | Verify watering `intervalDays` property works after cache retrieval |
|
|
| 7.5 | Manual Test | Create care schedule from cached info -> verify notifications |
|
|
| 7.6 | Cache Expiration | Manually set `fetchedAt` to 8 days ago -> should re-fetch |
|
|
| 7.7 | Unit Tests | Run `xcodebuild test -scheme PlantGuide` |
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Build succeeds with zero warnings
|
|
- [ ] API only called once per plant (check console logs)
|
|
- [ ] Cached care info identical to API response
|
|
- [ ] Care timing preserved for notification scheduling
|
|
- [ ] Cache expiration triggers refresh after 7 days
|
|
- [ ] All existing tests pass
|
|
|
|
---
|
|
|
|
## Files Summary
|
|
|
|
| File | Action |
|
|
|------|--------|
|
|
| `Core/Utilities/ValueTransformers.swift` | MODIFY - Add 4 transformers |
|
|
| `PlantGuideModel.xcdatamodeld` | MODIFY - Add PlantCareInfoMO entity |
|
|
| `ManagedObjects/PlantCareInfoMO.swift` | CREATE - Managed object + mappers |
|
|
| `ManagedObjects/PlantMO.swift` | MODIFY - Add relationship |
|
|
| `Domain/RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` | CREATE - Protocol |
|
|
| `Data/DataSources/Local/CoreData/CoreDataPlantCareInfoStorage.swift` | CREATE - Implementation |
|
|
| `Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift` | MODIFY - Add cache-first logic |
|
|
| `Core/DI/DIContainer.swift` | MODIFY - Register dependencies |
|
|
| `App/PlantGuideApp.swift` | MODIFY - Register transformers |
|
|
|
|
---
|
|
|
|
## Migration Strategy
|
|
|
|
**Lightweight migration** - no custom mapping model needed:
|
|
- New entity with no existing data
|
|
- New relationship is optional (nil default)
|
|
- `shouldMigrateStoreAutomatically` and `shouldInferMappingModelAutomatically` already enabled
|
|
|
|
---
|
|
|
|
## Risk Assessment
|
|
|
|
| Risk | Likelihood | Impact | Mitigation |
|
|
|------|------------|--------|------------|
|
|
| Migration failure | Low | High | Lightweight migration; optional relationships |
|
|
| Cache corruption | Low | Medium | JSON encoding is deterministic; handle decode failures gracefully |
|
|
| Stale cache served | Low | Low | 7-day expiration; manual refresh available |
|
|
| Memory pressure | Low | Low | Cache is per-plant, not bulk loaded |
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- Follow existing patterns in `IdentificationResultArrayTransformer` for transformers
|
|
- Use `@NSManaged` properties pattern from existing MO classes
|
|
- Repository pattern matches existing `PlantCollectionRepositoryProtocol`
|
|
- Cache expiration of 7 days balances freshness vs API calls
|
|
- Cascade delete ensures orphan cleanup
|