- 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>
11 KiB
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
IdentificationResultArrayTransformerpattern - 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
@NSManagedproperties defined toDomainModel()handles all optional fields correctlyfromDomainModel()creates valid managed objectupdate(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
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
Sendablefor 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
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)
shouldMigrateStoreAutomaticallyandshouldInferMappingModelAutomaticallyalready 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
IdentificationResultArrayTransformerfor transformers - Use
@NSManagedproperties 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