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>
This commit is contained in:
289
Docs/ARCHITECTURE_REMEDIATION_PLAN.md
Normal file
289
Docs/ARCHITECTURE_REMEDIATION_PLAN.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Architecture Remediation Plan
|
||||
|
||||
**Project:** PlantGuide iOS App
|
||||
**Date:** 2026-01-23
|
||||
**Priority:** Pre-Production Cleanup
|
||||
**Estimated Scope:** Medium (1-2 sprint cycles)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This plan addresses architectural concerns identified during code review. Items are prioritized by risk and impact to production stability.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Critical Fixes (Do First)
|
||||
|
||||
These items could cause runtime crashes or data inconsistency if not addressed.
|
||||
|
||||
### Task 1.1: Fix Repository Protocol Conformance
|
||||
|
||||
**File:** `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Problem:** `InMemoryPlantRepository` is returned as `PlantRepositoryProtocol` in DIContainer but doesn't actually conform to that protocol.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Add `PlantRepositoryProtocol` conformance to `InMemoryPlantRepository`
|
||||
- [ ] Implement any missing required methods from the protocol
|
||||
- [ ] Verify compilation succeeds
|
||||
- [ ] Add unit test to verify conformance
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Code compiles without warnings
|
||||
- `DIContainer.plantRepository` returns a valid `PlantRepositoryProtocol` instance
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Fix Core Data Stack Thread Safety
|
||||
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift`
|
||||
|
||||
**Problem:** `lazy var persistentContainer` initialization isn't thread-safe despite class being marked `@unchecked Sendable`.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Replace `lazy var` with a thread-safe initialization pattern
|
||||
- [ ] Option A: Use `DispatchQueue.sync` for synchronized access
|
||||
- [ ] Option B: Use an actor for the Core Data stack
|
||||
- [ ] Option C: Initialize in `init()` instead of lazily
|
||||
- [ ] Add concurrency tests to verify thread safety
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- No race conditions when accessing `persistentContainer` from multiple threads
|
||||
- Existing Core Data tests still pass
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Replace Unowned with Weak References in DIContainer
|
||||
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
**Problem:** `[unowned self]` in lazy closures could crash if accessed after deallocation.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Find all `[unowned self]` captures in DIContainer (lines ~121, 150, 196, 204, 213)
|
||||
- [ ] Replace with `[weak self]` and add guard statements
|
||||
- [ ] Example pattern:
|
||||
```swift
|
||||
LazyService { [weak self] in
|
||||
guard let self else { fatalError("DIContainer deallocated unexpectedly") }
|
||||
return PlantNetAPIService.configured(...)
|
||||
}
|
||||
```
|
||||
- [ ] Consider if fatalError is appropriate or if optional return is better
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- No `unowned` references in DIContainer
|
||||
- App behaves correctly during normal lifecycle
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Architectural Consistency (High Priority)
|
||||
|
||||
These items address design inconsistencies that affect maintainability.
|
||||
|
||||
### Task 2.1: Resolve SwiftData vs Core Data Conflict
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/PlantGuideApp.swift`
|
||||
- `PlantGuide/Item.swift`
|
||||
|
||||
**Problem:** App initializes SwiftData ModelContainer but uses Core Data for persistence. `Item.swift` appears to be unused template code.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] **Decision Required:** Confirm with team - Are we using SwiftData or Core Data?
|
||||
- [ ] If Core Data (current approach):
|
||||
- [ ] Remove `sharedModelContainer` from `PlantGuideApp.swift`
|
||||
- [ ] Remove `.modelContainer(sharedModelContainer)` modifier
|
||||
- [ ] Delete `Item.swift`
|
||||
- [ ] Remove `import SwiftData` if no longer needed
|
||||
- [ ] If migrating to SwiftData (future):
|
||||
- [ ] Document migration plan
|
||||
- [ ] Keep current Core Data as transitional
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Only one persistence technology in active use
|
||||
- No dead code related to unused persistence layer
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: Unify Repository Implementation
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/Core/DI/DIContainer.swift`
|
||||
- `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Problem:** `plantRepository` returns in-memory storage while `plantCollectionRepository` returns Core Data. This creates potential data sync issues.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] **Decision Required:** Should all plant data use Core Data in production?
|
||||
- [ ] If yes (recommended):
|
||||
- [ ] Update `DIContainer.plantRepository` to return `_coreDataPlantStorage.value`
|
||||
- [ ] Ensure `CoreDataPlantStorage` conforms to `PlantRepositoryProtocol`
|
||||
- [ ] Keep `InMemoryPlantRepository` for testing only
|
||||
- [ ] Add `#if DEBUG` around sample data in `InMemoryPlantRepository`
|
||||
- [ ] Update any code that assumes in-memory behavior
|
||||
- [ ] Run full test suite to verify no regressions
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Single source of truth for plant data in production
|
||||
- Tests can still use in-memory repository via DI
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: Guard Sample Data with DEBUG Flag
|
||||
|
||||
**File:** `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Problem:** `seedWithSampleData()` runs in production builds.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Wrap `seedWithSampleData()` call in `init()` with `#if DEBUG`:
|
||||
```swift
|
||||
private init() {
|
||||
#if DEBUG
|
||||
seedWithSampleData()
|
||||
#endif
|
||||
}
|
||||
```
|
||||
- [ ] Consider making sample data opt-in via a parameter
|
||||
- [ ] Verify production builds don't include sample plants
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Release builds start with empty repository
|
||||
- Debug builds can optionally include sample data
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Clean Architecture Compliance (Medium Priority)
|
||||
|
||||
These items improve separation of concerns and testability.
|
||||
|
||||
### Task 3.1: Extract UI Extensions from Domain Enums
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/Domain/Entities/Enums.swift` (source)
|
||||
- Create: `PlantGuide/Presentation/Extensions/CareTaskType+UI.swift`
|
||||
- Create: `PlantGuide/Presentation/Extensions/Enums+UI.swift`
|
||||
|
||||
**Problem:** Domain enums import SwiftUI and contain UI-specific code (colors, icons), violating Clean Architecture.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Create new file `Presentation/Extensions/Enums+UI.swift`
|
||||
- [ ] Move these extensions from `Enums.swift`:
|
||||
- `CareTaskType.iconName`
|
||||
- `CareTaskType.iconColor`
|
||||
- `CareTaskType.description`
|
||||
- `LightRequirement.description`
|
||||
- `WateringFrequency.description`
|
||||
- `FertilizerFrequency.description`
|
||||
- `HumidityLevel.description`
|
||||
- [ ] Remove `import SwiftUI` from `Enums.swift`
|
||||
- [ ] Verify all views still compile and display correctly
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- `Domain/Entities/Enums.swift` has no SwiftUI import
|
||||
- All UI functionality preserved in Presentation layer
|
||||
- Domain layer has zero UI framework dependencies
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: Reduce Singleton Usage (Optional Refactor)
|
||||
|
||||
**Files:** Multiple
|
||||
|
||||
**Problem:** Heavy singleton usage reduces testability and flexibility.
|
||||
|
||||
**Action Items:**
|
||||
- [ ] **Low Priority** - Document current singletons:
|
||||
- `DIContainer.shared`
|
||||
- `CoreDataStack.shared`
|
||||
- `InMemoryPlantRepository.shared`
|
||||
- `FilterPreferencesStorage.shared`
|
||||
- [ ] For new code, prefer dependency injection over `.shared` access
|
||||
- [ ] Consider refactoring `FilterPreferencesStorage` to be injected
|
||||
- [ ] Keep `DIContainer.shared` as acceptable app-level singleton
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- New code uses DI patterns
|
||||
- Existing singletons documented
|
||||
- No new singletons added without justification
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Code Cleanup (Low Priority)
|
||||
|
||||
Minor improvements for code hygiene.
|
||||
|
||||
### Task 4.1: Remove Unused Template Files
|
||||
|
||||
**File:** `PlantGuide/Item.swift`
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Verify `Item` is not referenced anywhere (search project)
|
||||
- [ ] Delete `Item.swift`
|
||||
- [ ] Remove from Xcode project if needed
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- No unused files in project
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: Review @unchecked Sendable Usage
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/Data/DataSources/Remote/NetworkService/NetworkService.swift`
|
||||
- `PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift`
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Audit each `@unchecked Sendable` usage
|
||||
- [ ] Document why each is safe OR fix the underlying issue
|
||||
- [ ] Add code comments explaining thread-safety guarantees
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Each `@unchecked Sendable` has documented justification
|
||||
- No hidden thread-safety bugs
|
||||
|
||||
---
|
||||
|
||||
## Task Checklist Summary
|
||||
|
||||
### Must Complete Before Release
|
||||
- [ ] 1.1 - Fix Repository Protocol Conformance
|
||||
- [ ] 1.2 - Fix Core Data Stack Thread Safety
|
||||
- [ ] 1.3 - Replace Unowned References
|
||||
- [ ] 2.1 - Resolve SwiftData vs Core Data
|
||||
- [ ] 2.2 - Unify Repository Implementation
|
||||
- [ ] 2.3 - Guard Sample Data
|
||||
|
||||
### Should Complete
|
||||
- [ ] 3.1 - Extract UI Extensions from Domain
|
||||
- [ ] 4.1 - Remove Unused Template Files
|
||||
|
||||
### Nice to Have
|
||||
- [ ] 3.2 - Reduce Singleton Usage
|
||||
- [ ] 4.2 - Review @unchecked Sendable
|
||||
|
||||
---
|
||||
|
||||
## Decision Log
|
||||
|
||||
| Decision | Options | Chosen | Date | Decided By |
|
||||
|----------|---------|--------|------|------------|
|
||||
| Persistence technology | SwiftData / Core Data | TBD | | |
|
||||
| Production repository | Core Data / In-Memory | TBD | | |
|
||||
|
||||
---
|
||||
|
||||
## Notes for Developer
|
||||
|
||||
1. **Start with Phase 1** - These are potential crash sources
|
||||
2. **Run tests after each task** - Ensure no regressions
|
||||
3. **Create separate commits** - One commit per task for easy review/revert
|
||||
4. **Update this doc** - Check off items as completed
|
||||
5. **Ask questions** - Flag any blockers in standup
|
||||
|
||||
---
|
||||
|
||||
*Document created by: Project Manager*
|
||||
*Last updated: 2026-01-23*
|
||||
616
Docs/ARCHITECTURE_REMEDIATION_PLAN_Shit.md
Normal file
616
Docs/ARCHITECTURE_REMEDIATION_PLAN_Shit.md
Normal file
@@ -0,0 +1,616 @@
|
||||
# Architecture Remediation - Executable Task Plan
|
||||
|
||||
**Project:** PlantGuide iOS App
|
||||
**Created:** 2026-01-23
|
||||
**Owner:** Senior Developer
|
||||
**Source:** ARCHITECTURE_REMEDIATION_PLAN.md
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Plan
|
||||
|
||||
Each task has:
|
||||
- **Validation Command**: Run this to verify completion
|
||||
- **Done When**: Specific observable criteria
|
||||
- **Commit Message**: Use this exact message format
|
||||
|
||||
Work through tasks in order. Do not skip ahead.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1: Critical Fixes (Crash Prevention)
|
||||
|
||||
### Task 1.1.1: Add Protocol Conformance Declaration
|
||||
|
||||
**File:** `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Do:**
|
||||
```swift
|
||||
// Find this line:
|
||||
final class InMemoryPlantRepository { ... }
|
||||
|
||||
// Change to:
|
||||
final class InMemoryPlantRepository: PlantRepositoryProtocol { ... }
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Build the project - should compile without errors
|
||||
xcodebuild -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED)"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] Build succeeds with no errors
|
||||
- [ ] `InMemoryPlantRepository` explicitly declares `PlantRepositoryProtocol` conformance
|
||||
|
||||
**Commit:** `fix(data): add PlantRepositoryProtocol conformance to InMemoryPlantRepository`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.1.2: Implement Missing Protocol Methods (if any)
|
||||
|
||||
**File:** `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Do:**
|
||||
1. Check what methods `PlantRepositoryProtocol` requires
|
||||
2. Compare against what `InMemoryPlantRepository` implements
|
||||
3. Add any missing methods
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Grep for protocol definition
|
||||
grep -A 50 "protocol PlantRepositoryProtocol" PlantGuide/Domain/Interfaces/Repositories/*.swift
|
||||
# Compare with implementation
|
||||
grep -E "func (save|delete|fetch|get)" PlantGuide/Data/Repositories/InMemoryPlantRepository.swift
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] All protocol-required methods exist in `InMemoryPlantRepository`
|
||||
- [ ] Build succeeds
|
||||
|
||||
**Commit:** `fix(data): implement missing PlantRepositoryProtocol methods in InMemoryPlantRepository`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.1.3: Add Protocol Conformance Unit Test
|
||||
|
||||
**File:** Create `PlantGuideTests/Data/Repositories/InMemoryPlantRepositoryTests.swift`
|
||||
|
||||
**Do:**
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import PlantGuide
|
||||
|
||||
final class InMemoryPlantRepositoryTests: XCTestCase {
|
||||
func testConformsToPlantRepositoryProtocol() {
|
||||
let repo: PlantRepositoryProtocol = InMemoryPlantRepository.shared
|
||||
XCTAssertNotNil(repo)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
xcodebuild test -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:PlantGuideTests/InMemoryPlantRepositoryTests 2>&1 | grep -E "(Test Suite|passed|failed)"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] Test file exists
|
||||
- [ ] Test passes
|
||||
- [ ] Test explicitly checks protocol conformance at compile time
|
||||
|
||||
**Commit:** `test(data): add protocol conformance test for InMemoryPlantRepository`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2.1: Identify Current Thread Safety Issue
|
||||
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift`
|
||||
|
||||
**Do:**
|
||||
1. Read the file
|
||||
2. Find `lazy var persistentContainer`
|
||||
3. Document the race condition scenario
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Check current implementation
|
||||
grep -A 10 "lazy var persistentContainer" PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] Understand where race condition occurs
|
||||
- [ ] Decision made: Actor / DispatchQueue / Eager init
|
||||
|
||||
**Commit:** N/A (research task)
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2.2: Fix Thread Safety with Eager Initialization
|
||||
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift`
|
||||
|
||||
**Do:**
|
||||
```swift
|
||||
// Replace lazy var with let + init
|
||||
// BEFORE:
|
||||
lazy var persistentContainer: NSPersistentContainer = { ... }()
|
||||
|
||||
// AFTER:
|
||||
let persistentContainer: NSPersistentContainer
|
||||
|
||||
init() {
|
||||
persistentContainer = NSPersistentContainer(name: "PlantGuide")
|
||||
persistentContainer.loadPersistentStores { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Verify no lazy var for persistentContainer
|
||||
grep "lazy var persistentContainer" PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift
|
||||
# Should return nothing
|
||||
|
||||
# Build and run tests
|
||||
xcodebuild test -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | grep -E "(BUILD|Test Suite)"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] No `lazy var` for `persistentContainer`
|
||||
- [ ] Initialization happens in `init()`
|
||||
- [ ] All existing Core Data tests pass
|
||||
|
||||
**Commit:** `fix(coredata): make persistentContainer thread-safe with eager initialization`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3.1: Find All Unowned References in DIContainer
|
||||
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
grep -n "unowned self" PlantGuide/Core/DI/DIContainer.swift
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# List all line numbers with unowned self
|
||||
grep -n "unowned self" PlantGuide/Core/DI/DIContainer.swift | wc -l
|
||||
# Document the count
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] All `unowned self` locations documented
|
||||
- [ ] Count recorded: _____ occurrences
|
||||
|
||||
**Commit:** N/A (audit task)
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3.2: Replace Unowned with Weak + Guard
|
||||
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
**Do:**
|
||||
For each occurrence found in 1.3.1:
|
||||
```swift
|
||||
// BEFORE:
|
||||
LazyService { [unowned self] in
|
||||
return SomeService(dependency: self.otherService)
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return SomeService(dependency: self.otherService)
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Zero unowned self references should remain
|
||||
grep "unowned self" PlantGuide/Core/DI/DIContainer.swift
|
||||
# Should return nothing
|
||||
|
||||
# Verify weak self exists
|
||||
grep "weak self" PlantGuide/Core/DI/DIContainer.swift | wc -l
|
||||
# Should match the count from 1.3.1
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] Zero `unowned self` in DIContainer
|
||||
- [ ] All replaced with `weak self` + guard
|
||||
- [ ] App launches successfully
|
||||
- [ ] Build succeeds
|
||||
|
||||
**Commit:** `fix(di): replace unowned with weak references in DIContainer`
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2: Architectural Consistency
|
||||
|
||||
### Task 2.1.1: Audit SwiftData Usage
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/PlantGuideApp.swift`
|
||||
- `PlantGuide/Item.swift`
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
# Check for SwiftData imports
|
||||
grep -r "import SwiftData" PlantGuide/
|
||||
# Check for @Model usage
|
||||
grep -r "@Model" PlantGuide/
|
||||
# Check for modelContainer usage
|
||||
grep -r "modelContainer" PlantGuide/
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
Record findings:
|
||||
- SwiftData imports: _____ files
|
||||
- @Model usages: _____ types
|
||||
- ModelContainer usage: _____ locations
|
||||
|
||||
**Done When:**
|
||||
- [ ] Complete inventory of SwiftData usage
|
||||
- [ ] Decision documented: Keep SwiftData or Remove
|
||||
|
||||
**Commit:** N/A (audit task)
|
||||
|
||||
---
|
||||
|
||||
### Task 2.1.2: Remove Unused SwiftData (if decision is Core Data only)
|
||||
|
||||
**Files:**
|
||||
- `PlantGuide/PlantGuideApp.swift`
|
||||
- `PlantGuide/Item.swift`
|
||||
|
||||
**Do:**
|
||||
1. Delete `Item.swift`
|
||||
2. In `PlantGuideApp.swift`:
|
||||
- Remove `sharedModelContainer` property
|
||||
- Remove `.modelContainer(sharedModelContainer)` modifier
|
||||
- Remove `import SwiftData`
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Item.swift should not exist
|
||||
ls PlantGuide/Item.swift 2>&1
|
||||
# Should return "No such file"
|
||||
|
||||
# No SwiftData in PlantGuideApp
|
||||
grep -E "(SwiftData|modelContainer|sharedModelContainer)" PlantGuide/PlantGuideApp.swift
|
||||
# Should return nothing
|
||||
|
||||
# Build succeeds
|
||||
xcodebuild -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep "BUILD SUCCEEDED"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] `Item.swift` deleted
|
||||
- [ ] No SwiftData references in `PlantGuideApp.swift`
|
||||
- [ ] Build succeeds
|
||||
- [ ] App launches correctly
|
||||
|
||||
**Commit:** `refactor(app): remove unused SwiftData setup, keeping Core Data`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2.1: Verify CoreDataPlantStorage Conforms to PlantRepositoryProtocol
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
grep -A 5 "class CoreDataPlantStorage" PlantGuide/Data/DataSources/Local/CoreData/
|
||||
grep "PlantRepositoryProtocol" PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- [ ] `CoreDataPlantStorage` explicitly conforms to `PlantRepositoryProtocol`
|
||||
|
||||
If not conforming, add conformance first.
|
||||
|
||||
**Done When:**
|
||||
- [ ] Conformance verified or added
|
||||
|
||||
**Commit:** `fix(coredata): ensure CoreDataPlantStorage conforms to PlantRepositoryProtocol`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2.2: Update DIContainer to Use Core Data Repository
|
||||
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
**Do:**
|
||||
```swift
|
||||
// Find plantRepository property
|
||||
// BEFORE:
|
||||
var plantRepository: PlantRepositoryProtocol {
|
||||
return InMemoryPlantRepository.shared
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
var plantRepository: PlantRepositoryProtocol {
|
||||
return _coreDataPlantStorage.value
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Check plantRepository returns Core Data
|
||||
grep -A 3 "var plantRepository:" PlantGuide/Core/DI/DIContainer.swift
|
||||
# Should reference coreData, not InMemory
|
||||
|
||||
# Run tests
|
||||
xcodebuild test -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | grep -E "(Test Suite|passed|failed)"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] `plantRepository` returns Core Data storage
|
||||
- [ ] All tests pass
|
||||
- [ ] App displays persisted data correctly
|
||||
|
||||
**Commit:** `refactor(di): switch plantRepository to Core Data storage`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3.1: Guard Sample Data Seeding
|
||||
|
||||
**File:** `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift`
|
||||
|
||||
**Do:**
|
||||
```swift
|
||||
// Find seedWithSampleData() call in init
|
||||
// Wrap with DEBUG flag:
|
||||
private init() {
|
||||
#if DEBUG
|
||||
seedWithSampleData()
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Check DEBUG guard exists
|
||||
grep -A 5 "private init()" PlantGuide/Data/Repositories/InMemoryPlantRepository.swift
|
||||
# Should show #if DEBUG around seedWithSampleData
|
||||
|
||||
# Build release config
|
||||
xcodebuild -scheme PlantGuide -configuration Release -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep "BUILD SUCCEEDED"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] `seedWithSampleData()` wrapped in `#if DEBUG`
|
||||
- [ ] Release build succeeds
|
||||
|
||||
**Commit:** `fix(data): guard sample data seeding with DEBUG flag`
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: Clean Architecture Compliance
|
||||
|
||||
### Task 3.1.1: Create UI Extensions File
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
mkdir -p PlantGuide/Presentation/Extensions
|
||||
touch PlantGuide/Presentation/Extensions/Enums+UI.swift
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
ls PlantGuide/Presentation/Extensions/Enums+UI.swift
|
||||
# Should exist
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] File created at correct path
|
||||
|
||||
**Commit:** N/A (file creation only)
|
||||
|
||||
---
|
||||
|
||||
### Task 3.1.2: Move UI Extensions from Domain
|
||||
|
||||
**Files:**
|
||||
- Source: `PlantGuide/Domain/Entities/Enums.swift`
|
||||
- Destination: `PlantGuide/Presentation/Extensions/Enums+UI.swift`
|
||||
|
||||
**Do:**
|
||||
1. Copy these extensions to new file:
|
||||
- `CareTaskType.iconName`
|
||||
- `CareTaskType.iconColor`
|
||||
- `CareTaskType.description`
|
||||
- `LightRequirement.description`
|
||||
- `WateringFrequency.description`
|
||||
- `FertilizerFrequency.description`
|
||||
- `HumidityLevel.description`
|
||||
|
||||
2. Remove from original file
|
||||
3. Add `import SwiftUI` to new file only
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# No SwiftUI in Domain Enums
|
||||
grep "import SwiftUI" PlantGuide/Domain/Entities/Enums.swift
|
||||
# Should return nothing
|
||||
|
||||
# SwiftUI in Presentation extension
|
||||
grep "import SwiftUI" PlantGuide/Presentation/Extensions/Enums+UI.swift
|
||||
# Should return the import
|
||||
|
||||
# Build succeeds
|
||||
xcodebuild -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep "BUILD SUCCEEDED"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] `Domain/Entities/Enums.swift` has no SwiftUI import
|
||||
- [ ] All UI extensions live in `Presentation/Extensions/Enums+UI.swift`
|
||||
- [ ] Build succeeds
|
||||
- [ ] UI displays correctly (icons, colors visible)
|
||||
|
||||
**Commit:** `refactor(architecture): extract UI extensions from domain enums to presentation layer`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2.1: Document Existing Singletons
|
||||
|
||||
**Do:**
|
||||
Create list of all `.shared` singletons:
|
||||
|
||||
```bash
|
||||
grep -r "\.shared" PlantGuide/ --include="*.swift" | grep -v "Tests" | grep -v ".build"
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
Document findings in this task:
|
||||
- [ ] `DIContainer.shared` - Location: _____
|
||||
- [ ] `CoreDataStack.shared` - Location: _____
|
||||
- [ ] `InMemoryPlantRepository.shared` - Location: _____
|
||||
- [ ] `FilterPreferencesStorage.shared` - Location: _____
|
||||
- [ ] Other: _____
|
||||
|
||||
**Done When:**
|
||||
- [ ] All singletons documented
|
||||
|
||||
**Commit:** N/A (documentation task)
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: Code Cleanup
|
||||
|
||||
### Task 4.1.1: Verify Item.swift is Unused
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
# Search for any reference to Item type
|
||||
grep -r "Item" PlantGuide/ --include="*.swift" | grep -v "Item.swift" | grep -v "MenuItem" | grep -v "ListItem" | grep -v "// Item"
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- [ ] No meaningful references to `Item` type found
|
||||
|
||||
**Done When:**
|
||||
- [ ] Confirmed `Item.swift` is dead code
|
||||
|
||||
**Commit:** N/A (verification only)
|
||||
|
||||
---
|
||||
|
||||
### Task 4.1.2: Delete Item.swift
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
rm PlantGuide/Item.swift
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# File should not exist
|
||||
ls PlantGuide/Item.swift 2>&1 | grep "No such file"
|
||||
|
||||
# Build succeeds
|
||||
xcodebuild -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | grep "BUILD SUCCEEDED"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] File deleted
|
||||
- [ ] Build succeeds
|
||||
|
||||
**Commit:** `chore: remove unused Item.swift template file`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2.1: Document @unchecked Sendable Usage
|
||||
|
||||
**Do:**
|
||||
```bash
|
||||
grep -rn "@unchecked Sendable" PlantGuide/ --include="*.swift"
|
||||
```
|
||||
|
||||
For each occurrence, add a comment explaining WHY it's safe:
|
||||
|
||||
```swift
|
||||
// MARK: - Thread Safety
|
||||
// This type is @unchecked Sendable because:
|
||||
// - All mutable state is protected by [mechanism]
|
||||
// - Public interface is thread-safe because [reason]
|
||||
@unchecked Sendable
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
```bash
|
||||
# Each @unchecked Sendable should have a comment within 5 lines above it
|
||||
grep -B 5 "@unchecked Sendable" PlantGuide/Data/DataSources/Local/CoreData/CoreDataStack.swift | grep -E "(Thread Safety|thread-safe|Sendable because)"
|
||||
```
|
||||
|
||||
**Done When:**
|
||||
- [ ] Every `@unchecked Sendable` has justification comment
|
||||
- [ ] No hidden thread-safety bugs identified
|
||||
|
||||
**Commit:** `docs(concurrency): document thread-safety justification for @unchecked Sendable types`
|
||||
|
||||
---
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
### Phase 1 (Critical - Do First)
|
||||
- [ ] 1.1.1 - Protocol conformance declaration
|
||||
- [ ] 1.1.2 - Missing protocol methods
|
||||
- [ ] 1.1.3 - Protocol conformance test
|
||||
- [ ] 1.2.1 - Identify thread safety issue
|
||||
- [ ] 1.2.2 - Fix thread safety
|
||||
- [ ] 1.3.1 - Find unowned references
|
||||
- [ ] 1.3.2 - Replace unowned with weak
|
||||
|
||||
### Phase 2 (High Priority)
|
||||
- [ ] 2.1.1 - Audit SwiftData
|
||||
- [ ] 2.1.2 - Remove unused SwiftData
|
||||
- [ ] 2.2.1 - Verify CoreDataPlantStorage conformance
|
||||
- [ ] 2.2.2 - Update DIContainer
|
||||
- [ ] 2.3.1 - Guard sample data
|
||||
|
||||
### Phase 3 (Medium Priority)
|
||||
- [ ] 3.1.1 - Create UI extensions file
|
||||
- [ ] 3.1.2 - Move UI extensions
|
||||
- [ ] 3.2.1 - Document singletons
|
||||
|
||||
### Phase 4 (Low Priority)
|
||||
- [ ] 4.1.1 - Verify Item.swift unused
|
||||
- [ ] 4.1.2 - Delete Item.swift
|
||||
- [ ] 4.2.1 - Document @unchecked Sendable
|
||||
|
||||
---
|
||||
|
||||
## Final Validation
|
||||
|
||||
After all tasks complete:
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
xcodebuild -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' clean build 2>&1 | tail -5
|
||||
|
||||
# Full test suite
|
||||
xcodebuild test -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 16' 2>&1 | grep -E "Test Suite.*passed"
|
||||
|
||||
# No unowned self
|
||||
grep -r "unowned self" PlantGuide/ --include="*.swift" | wc -l
|
||||
# Should be 0
|
||||
|
||||
# No SwiftUI in Domain
|
||||
grep -r "import SwiftUI" PlantGuide/Domain/ --include="*.swift" | wc -l
|
||||
# Should be 0
|
||||
```
|
||||
|
||||
**All Done When:**
|
||||
- [ ] Clean build succeeds
|
||||
- [ ] All tests pass
|
||||
- [ ] Zero `unowned self` in codebase
|
||||
- [ ] Zero SwiftUI imports in Domain layer
|
||||
- [ ] App runs correctly on simulator
|
||||
|
||||
---
|
||||
|
||||
*Plan created: 2026-01-23*
|
||||
327
Docs/IMPLEMENTATION_PLAN.md
Normal file
327
Docs/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Botanica - Plant Identification iOS App
|
||||
|
||||
## Overview
|
||||
Custom iOS 17+ app using SwiftUI that identifies plants via camera with care schedules.
|
||||
|
||||
**Stack:**
|
||||
- On-device ML: PlantNet-300K model converted to Core ML
|
||||
- Online API: Pl@ntNet (my.plantnet.org) for higher accuracy
|
||||
- Care data: Trefle API (open source botanical database)
|
||||
- Architecture: Clean Architecture + MVVM
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Week 1-2)
|
||||
|
||||
**Goal:** Core infrastructure with camera capture
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 1.1 | Create Xcode project (iOS 17+, SwiftUI) |
|
||||
| 1.2 | Set up folder structure (App/, Core/, Domain/, Data/, ML/, Presentation/) |
|
||||
| 1.3 | Implement `DIContainer.swift` for dependency injection |
|
||||
| 1.4 | Create domain entities: `Plant`, `PlantIdentification`, `PlantCareSchedule`, `CareTask` |
|
||||
| 1.5 | Define repository protocols in `Domain/RepositoryInterfaces/` |
|
||||
| 1.6 | Build `NetworkService.swift` with async/await and multipart upload |
|
||||
| 1.7 | Implement `CameraView` + `CameraViewModel` with AVFoundation |
|
||||
| 1.8 | Set up Core Data stack for persistence |
|
||||
| 1.9 | Create tab navigation (Camera, Collection, Care, Settings) |
|
||||
|
||||
**Deliverable:** Working camera capture with photo preview
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: On-Device ML (Week 3-4)
|
||||
|
||||
**Goal:** Offline plant identification with Core ML
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 2.1 | Download PlantNet-300K pre-trained ResNet weights |
|
||||
| 2.2 | Convert to Core ML using `coremltools` (Python script) |
|
||||
| 2.3 | Add `PlantNet300K.mlpackage` to Xcode |
|
||||
| 2.4 | Create `PlantLabels.json` with 1,081 species names |
|
||||
| 2.5 | Implement `PlantClassificationService.swift` using Vision framework |
|
||||
| 2.6 | Create `ImagePreprocessor.swift` for model input normalization |
|
||||
| 2.7 | Build `IdentifyPlantOnDeviceUseCase.swift` |
|
||||
| 2.8 | Create `IdentificationView` showing results with confidence scores |
|
||||
| 2.9 | Build `SpeciesMatchCard` and `ConfidenceIndicator` components |
|
||||
| 2.10 | Performance test on device (target: <500ms inference) |
|
||||
|
||||
**Deliverable:** End-to-end offline identification flow
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: PlantNet API Integration (Week 5-6)
|
||||
|
||||
**Goal:** Hybrid identification with API fallback
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 3.1 | Register at my.plantnet.org for API key |
|
||||
| 3.2 | Create `PlantNetEndpoints.swift` (POST /v2/identify/{project}) |
|
||||
| 3.3 | Implement `PlantNetAPIService.swift` with multipart image upload |
|
||||
| 3.4 | Create DTOs: `PlantNetIdentifyResponseDTO`, `PlantNetSpeciesDTO` |
|
||||
| 3.5 | Build `PlantNetMapper.swift` (DTO → Domain entity) |
|
||||
| 3.6 | Implement `IdentifyPlantOnlineUseCase.swift` |
|
||||
| 3.7 | Create `HybridIdentificationUseCase.swift` (on-device first, API for confirmation) |
|
||||
| 3.8 | Add network reachability monitoring |
|
||||
| 3.9 | Handle rate limiting (500 free requests/day) |
|
||||
| 3.10 | Implement `IdentificationCache.swift` for previous results |
|
||||
|
||||
**Deliverable:** Hybrid identification combining on-device + API
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Trefle API & Plant Care (Week 7-8)
|
||||
|
||||
**Goal:** Complete care information and scheduling
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 4.1 | Register at trefle.io for API token |
|
||||
| 4.2 | Create `TrefleEndpoints.swift` (GET /plants/search, GET /species/{slug}) |
|
||||
| 4.3 | Implement `TrefleAPIService.swift` |
|
||||
| 4.4 | Create DTOs: `TrefleSpeciesDTO`, `GrowthDataDTO` |
|
||||
| 4.5 | Build `TrefleMapper.swift` mapping growth data to care schedules |
|
||||
| 4.6 | Implement `FetchPlantCareUseCase.swift` |
|
||||
| 4.7 | Create `CreateCareScheduleUseCase.swift` |
|
||||
| 4.8 | Build `PlantDetailView` with `CareInformationSection` |
|
||||
| 4.9 | Implement `CareScheduleView` with upcoming tasks |
|
||||
| 4.10 | Add local notifications for care reminders |
|
||||
|
||||
**Deliverable:** Full plant care data with watering/fertilizer schedules
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Plant Collection & Persistence (Week 9-10)
|
||||
|
||||
**Goal:** Saved plants with full offline support
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 5.1 | Define Core Data models (PlantMO, CareScheduleMO, IdentificationMO) |
|
||||
| 5.2 | Implement `CoreDataPlantStorage.swift` |
|
||||
| 5.3 | Build `PlantCollectionRepository.swift` |
|
||||
| 5.4 | Create use cases: `SavePlantUseCase`, `FetchCollectionUseCase` |
|
||||
| 5.5 | Build `CollectionView` with grid layout |
|
||||
| 5.6 | Implement `ImageCache.swift` for offline images |
|
||||
| 5.7 | Add search/filter in collection |
|
||||
|
||||
**Deliverable:** Full plant collection management with offline support
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Release (Week 11-12)
|
||||
|
||||
**Goal:** Production-ready application
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 6.1 | Build `SettingsView` (offline mode toggle, API status, cache clear) |
|
||||
| 6.2 | Add comprehensive error handling with `ErrorView` |
|
||||
| 6.3 | Implement loading states with shimmer effects |
|
||||
| 6.4 | Add accessibility labels and Dynamic Type support |
|
||||
| 6.5 | Performance optimization pass |
|
||||
| 6.6 | Write unit tests for use cases and services |
|
||||
| 6.7 | Write UI tests for critical flows |
|
||||
| 6.8 | Final QA and bug fixes |
|
||||
|
||||
**Deliverable:** App Store ready application
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Botanica/
|
||||
├── App/
|
||||
│ ├── BotanicaApp.swift
|
||||
│ └── Configuration/
|
||||
│ ├── AppConfiguration.swift
|
||||
│ └── APIKeys.swift
|
||||
├── Core/
|
||||
│ ├── DI/DIContainer.swift
|
||||
│ ├── Extensions/
|
||||
│ └── Utilities/
|
||||
├── Domain/
|
||||
│ ├── Entities/
|
||||
│ │ ├── Plant.swift
|
||||
│ │ ├── PlantIdentification.swift
|
||||
│ │ ├── PlantCareSchedule.swift
|
||||
│ │ └── CareTask.swift
|
||||
│ ├── UseCases/
|
||||
│ │ ├── Identification/
|
||||
│ │ │ ├── IdentifyPlantOnDeviceUseCase.swift
|
||||
│ │ │ ├── IdentifyPlantOnlineUseCase.swift
|
||||
│ │ │ └── HybridIdentificationUseCase.swift
|
||||
│ │ ├── PlantCare/
|
||||
│ │ │ ├── FetchPlantCareUseCase.swift
|
||||
│ │ │ └── CreateCareScheduleUseCase.swift
|
||||
│ │ └── Collection/
|
||||
│ └── RepositoryInterfaces/
|
||||
├── Data/
|
||||
│ ├── Repositories/
|
||||
│ ├── DataSources/
|
||||
│ │ ├── Remote/
|
||||
│ │ │ ├── PlantNetAPI/
|
||||
│ │ │ │ ├── PlantNetAPIService.swift
|
||||
│ │ │ │ └── DTOs/
|
||||
│ │ │ ├── TrefleAPI/
|
||||
│ │ │ │ ├── TrefleAPIService.swift
|
||||
│ │ │ │ └── DTOs/
|
||||
│ │ │ └── NetworkService/
|
||||
│ │ └── Local/
|
||||
│ │ ├── CoreData/
|
||||
│ │ └── Cache/
|
||||
│ └── Mappers/
|
||||
├── ML/
|
||||
│ ├── Models/
|
||||
│ │ └── PlantNet300K.mlpackage
|
||||
│ ├── Services/
|
||||
│ │ └── PlantClassificationService.swift
|
||||
│ └── Preprocessing/
|
||||
├── Presentation/
|
||||
│ ├── Scenes/
|
||||
│ │ ├── Camera/
|
||||
│ │ ├── Identification/
|
||||
│ │ ├── PlantDetail/
|
||||
│ │ ├── Collection/
|
||||
│ │ ├── CareSchedule/
|
||||
│ │ └── Settings/
|
||||
│ ├── Common/Components/
|
||||
│ └── Navigation/
|
||||
└── Resources/
|
||||
├── PlantLabels.json
|
||||
└── Assets.xcassets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Details
|
||||
|
||||
### Pl@ntNet API
|
||||
- **Base URL:** `https://my-api.plantnet.org`
|
||||
- **Endpoint:** `POST /v2/identify/{project}`
|
||||
- **Free tier:** 500 requests/day
|
||||
- **Coverage:** 77,565 species
|
||||
- **Documentation:** [my.plantnet.org/doc](https://my.plantnet.org/doc)
|
||||
|
||||
### Trefle API
|
||||
- **Base URL:** `https://trefle.io/api/v1`
|
||||
- **Endpoints:**
|
||||
- `GET /plants/search?q={name}`
|
||||
- `GET /species/{slug}`
|
||||
- **Data:** Light requirements, watering, soil, temperature, fertilizer, growth info
|
||||
- **Documentation:** [docs.trefle.io](https://docs.trefle.io)
|
||||
|
||||
---
|
||||
|
||||
## Core ML Conversion
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
pip install torch torchvision coremltools pillow numpy
|
||||
```
|
||||
|
||||
### Conversion Script
|
||||
```python
|
||||
# scripts/convert_plantnet_to_coreml.py
|
||||
import torch
|
||||
import torchvision.models as models
|
||||
import coremltools as ct
|
||||
|
||||
# Load PlantNet-300K pre-trained ResNet
|
||||
model = models.resnet50(weights=None)
|
||||
model.fc = torch.nn.Linear(model.fc.in_features, 1081)
|
||||
model.load_state_dict(torch.load("resnet50_weights_best_acc.tar", map_location='cpu')['state_dict'])
|
||||
model.eval()
|
||||
|
||||
# Trace for conversion
|
||||
traced = torch.jit.trace(model, torch.rand(1, 3, 224, 224))
|
||||
|
||||
# Convert to Core ML
|
||||
image_input = ct.ImageType(
|
||||
name="image",
|
||||
shape=(1, 3, 224, 224),
|
||||
scale=1/255.0,
|
||||
bias=[-0.485/0.229, -0.456/0.224, -0.406/0.225],
|
||||
color_layout=ct.colorlayout.RGB
|
||||
)
|
||||
|
||||
mlmodel = ct.convert(
|
||||
traced,
|
||||
inputs=[image_input],
|
||||
convert_to="mlprogram",
|
||||
minimum_deployment_target=ct.target.iOS17,
|
||||
compute_precision=ct.precision.FLOAT16,
|
||||
)
|
||||
|
||||
mlmodel.save("PlantNet300K.mlpackage")
|
||||
```
|
||||
|
||||
### Download Weights
|
||||
```bash
|
||||
# From Zenodo (PlantNet-300K official)
|
||||
wget https://zenodo.org/records/4726653/files/resnet50_weights_best_acc.tar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Data Models
|
||||
|
||||
### 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]
|
||||
let dateIdentified: Date
|
||||
let identificationSource: IdentificationSource
|
||||
|
||||
enum IdentificationSource: String {
|
||||
case onDevice, plantNetAPI, hybrid
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PlantCareSchedule Entity
|
||||
```swift
|
||||
struct PlantCareSchedule: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let plantID: UUID
|
||||
let lightRequirement: LightRequirement
|
||||
let wateringSchedule: WateringSchedule
|
||||
let temperatureRange: TemperatureRange
|
||||
let fertilizerSchedule: FertilizerSchedule?
|
||||
let tasks: [CareTask]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
| Test | Expected Result |
|
||||
|------|-----------------|
|
||||
| Camera capture | Take photo → preview displays |
|
||||
| On-device ML | Photo → top 10 species with confidence scores (<500ms) |
|
||||
| PlantNet API | Photo → API results match/exceed on-device accuracy |
|
||||
| Trefle API | Scientific name → care data (watering, light, fertilizer) |
|
||||
| Save plant | Save to collection → persists after app restart |
|
||||
| Offline mode | Disable network → on-device identification still works |
|
||||
| Care reminders | Create schedule → notification fires at scheduled time |
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [PlantNet-300K GitHub](https://github.com/plantnet/PlantNet-300K)
|
||||
- [PlantNet-300K Dataset (Zenodo)](https://zenodo.org/records/4726653)
|
||||
- [Pl@ntNet API Docs](https://my.plantnet.org/doc)
|
||||
- [Trefle API Docs](https://docs.trefle.io)
|
||||
- [Apple Core ML Tools](https://github.com/apple/coremltools)
|
||||
- [Vision Framework](https://developer.apple.com/documentation/vision)
|
||||
151
Docs/MakeShitWork.md
Normal file
151
Docs/MakeShitWork.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Make Shit Work - Base User Flow Plan
|
||||
|
||||
**Goal:** Ensure the core plant identification flow works end-to-end
|
||||
|
||||
## User Flow Under Review
|
||||
|
||||
```
|
||||
1. User takes photo of plant
|
||||
2. App calls PlantNet API to identify plant
|
||||
3. App displays identification results (match list with confidence %)
|
||||
4. User selects a plant from results (REPORTED NOT WORKING)
|
||||
5. User taps "Save to Collection"
|
||||
6. Plant is saved to user's collection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Investigation Tasks
|
||||
|
||||
### Phase 1: Verify Data Flow (Read-Only)
|
||||
|
||||
- [ ] **T1.1** - Trace camera capture to IdentificationView handoff
|
||||
- File: `Presentation/Scenes/Camera/CameraView.swift`
|
||||
- File: `Presentation/Scenes/Camera/CameraViewModel.swift`
|
||||
- Verify: `capturedImage` is correctly passed to IdentificationView
|
||||
|
||||
- [ ] **T1.2** - Verify PlantNet API call works
|
||||
- File: `Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift`
|
||||
- File: `Domain/UseCases/Identification/IdentifyPlantOnlineUseCase.swift`
|
||||
- Test: Add logging to confirm API is called and returns results
|
||||
- Check: API key validity, rate limits, response parsing
|
||||
|
||||
- [ ] **T1.3** - Verify predictions are mapped correctly
|
||||
- File: `Data/Mappers/PlantNetMapper.swift`
|
||||
- File: `Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
- Verify: `PlantNetResultDTO` → `ViewPlantPrediction` mapping preserves all fields
|
||||
|
||||
- [ ] **T1.4** - Inspect results list rendering
|
||||
- File: `Presentation/Scenes/Identification/IdentificationView.swift` (lines 267-278)
|
||||
- Verify: `predictions` array is populated and displayed
|
||||
- Check: `ForEach` enumerates correctly, `PredictionRow` renders
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Debug Selection Issue (PRIORITY)
|
||||
|
||||
- [ ] **T2.1** - Analyze `selectPrediction()` implementation
|
||||
- File: `Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
- Find: `selectPrediction(_ prediction:)` method
|
||||
- Check: Does it update `@Published var selectedPrediction`?
|
||||
- Check: Is there a state conflict preventing updates?
|
||||
|
||||
- [ ] **T2.2** - Check tap gesture binding in IdentificationView
|
||||
- File: `Presentation/Scenes/Identification/IdentificationView.swift`
|
||||
- Verify: `.onTapGesture { viewModel.selectPrediction(prediction) }`
|
||||
- Check: Is gesture attached to correct view hierarchy?
|
||||
- Check: Any overlapping gestures or hit testing issues?
|
||||
|
||||
- [ ] **T2.3** - Verify visual selection feedback
|
||||
- File: `Presentation/Scenes/Identification/Components/PredictionRow.swift` (if exists)
|
||||
- Check: `isSelected` property updates row appearance
|
||||
- Check: Border color / checkmark renders when selected
|
||||
|
||||
- [ ] **T2.4** - Test auto-selection of first result
|
||||
- File: `Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
- Code: `selectedPrediction = predictions.first`
|
||||
- Verify: This runs after API results are received
|
||||
- Check: Does it fire before user interaction is possible?
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Verify Save to Collection
|
||||
|
||||
- [ ] **T3.1** - Trace save button action
|
||||
- File: `Presentation/Scenes/Identification/IdentificationView.swift`
|
||||
- Find: "Save to Collection" button action
|
||||
- Verify: Calls `viewModel.saveToCollection()`
|
||||
|
||||
- [ ] **T3.2** - Verify `saveToCollection()` uses selected prediction
|
||||
- File: `Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
- Check: Does it use `selectedPrediction` (not first prediction)?
|
||||
- Check: What happens if `selectedPrediction` is nil?
|
||||
|
||||
- [ ] **T3.3** - Verify prediction-to-plant mapping
|
||||
- File: `Data/Mappers/PredictionToPlantMapper.swift`
|
||||
- Verify: `ViewPlantPrediction` → `Plant` conversion is correct
|
||||
- Check: All required fields populated
|
||||
|
||||
- [ ] **T3.4** - Verify SavePlantUseCase execution
|
||||
- File: `Domain/UseCases/Collection/SavePlantUseCase.swift`
|
||||
- Trace: Repository save call
|
||||
- Check: Core Data persistence actually commits
|
||||
|
||||
- [ ] **T3.5** - Verify plant appears in collection
|
||||
- File: `Data/Repositories/InMemoryPlantRepository.swift` (current impl)
|
||||
- File: `Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift`
|
||||
- Check: Which repository is active? (DI container)
|
||||
- Check: Fetch returns saved plants
|
||||
|
||||
---
|
||||
|
||||
## Fix Tasks (After Investigation)
|
||||
|
||||
### If Selection Not Working
|
||||
|
||||
- [ ] **F1** - Fix tap gesture if not firing
|
||||
- [ ] **F2** - Fix `selectPrediction()` state update
|
||||
- [ ] **F3** - Ensure selected state propagates to view
|
||||
|
||||
### If Save Not Working
|
||||
|
||||
- [ ] **F4** - Fix `saveToCollection()` to use selected prediction
|
||||
- [ ] **F5** - Fix repository persistence if needed
|
||||
- [ ] **F6** - Ensure save success/error state updates UI
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Component | File Path |
|
||||
|-----------|-----------|
|
||||
| Camera View | `PlantGuide/Presentation/Scenes/Camera/CameraView.swift` |
|
||||
| Camera VM | `PlantGuide/Presentation/Scenes/Camera/CameraViewModel.swift` |
|
||||
| Identification View | `PlantGuide/Presentation/Scenes/Identification/IdentificationView.swift` |
|
||||
| Identification VM | `PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift` |
|
||||
| PlantNet API | `PlantGuide/Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift` |
|
||||
| API DTOs | `PlantGuide/Data/DataSources/Remote/PlantNetAPI/DTOs/PlantNetDTOs.swift` |
|
||||
| PlantNet Mapper | `PlantGuide/Data/Mappers/PlantNetMapper.swift` |
|
||||
| Prediction→Plant Mapper | `PlantGuide/Data/Mappers/PredictionToPlantMapper.swift` |
|
||||
| Save Use Case | `PlantGuide/Domain/UseCases/Collection/SavePlantUseCase.swift` |
|
||||
| Plant Repository | `PlantGuide/Data/Repositories/InMemoryPlantRepository.swift` |
|
||||
| Core Data Storage | `PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantStorage.swift` |
|
||||
| DI Container | `PlantGuide/Core/DI/DIContainer.swift` |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. User can take a photo and see identification results
|
||||
2. User can tap any result row and see it visually selected
|
||||
3. User can tap "Save to Collection" and the SELECTED plant is saved
|
||||
4. Saved plant appears in collection view
|
||||
5. No crashes or error states during flow
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Selection issue reported by user - investigate Phase 2 first
|
||||
- Repository may be InMemory (lost on restart) vs CoreData (persistent)
|
||||
- Check DI container for which repository implementation is wired
|
||||
341
Docs/Phase1_plan.md
Normal file
341
Docs/Phase1_plan.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Phase 1: Foundation + Local Plant Database
|
||||
|
||||
**Goal:** Core infrastructure with camera capture and local plant database integration using `houseplants_list.json` (2,278 plants, 11 categories, 50 families)
|
||||
|
||||
---
|
||||
|
||||
## Data Source Overview
|
||||
|
||||
**File:** `data/houseplants_list.json`
|
||||
- **Total Plants:** 2,278
|
||||
- **Categories:** Air Plant, Bromeliad, Cactus, Fern, Flowering Houseplant, Herb, Orchid, Palm, Succulent, Trailing/Climbing, Tropical Foliage
|
||||
- **Families:** 50 unique botanical families
|
||||
- **Structure per plant:**
|
||||
```json
|
||||
{
|
||||
"scientific_name": "Philodendron hederaceum",
|
||||
"common_names": ["Heartleaf Philodendron", "Sweetheart Plant"],
|
||||
"family": "Araceae",
|
||||
"category": "Tropical Foliage"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1.1 Create Local Plant Database Model
|
||||
**File:** `PlantGuide/Data/DataSources/Local/PlantDatabase/LocalPlantEntry.swift`
|
||||
|
||||
- [ ] Create `LocalPlantEntry` Codable struct matching JSON structure:
|
||||
```swift
|
||||
struct LocalPlantEntry: Codable, Identifiable, Sendable {
|
||||
let scientificName: String
|
||||
let commonNames: [String]
|
||||
let family: String
|
||||
let category: PlantCategory
|
||||
|
||||
var id: String { scientificName }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scientificName = "scientific_name"
|
||||
case commonNames = "common_names"
|
||||
case family
|
||||
case category
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Create `PlantCategory` enum with 11 cases matching JSON categories
|
||||
- [ ] Create `LocalPlantDatabase` Codable wrapper:
|
||||
```swift
|
||||
struct LocalPlantDatabase: Codable, Sendable {
|
||||
let sourceDate: String
|
||||
let totalPlants: Int
|
||||
let sources: [String]
|
||||
let plants: [LocalPlantEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sourceDate = "source_date"
|
||||
case totalPlants = "total_plants"
|
||||
case sources, plants
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Acceptance Criteria:** Models compile and can decode `houseplants_list.json` without errors
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Implement Plant Database Service
|
||||
**File:** `PlantGuide/Data/DataSources/Local/PlantDatabase/PlantDatabaseService.swift`
|
||||
|
||||
- [ ] Create `PlantDatabaseServiceProtocol`:
|
||||
```swift
|
||||
protocol PlantDatabaseServiceProtocol: Sendable {
|
||||
func loadDatabase() async throws
|
||||
func searchByScientificName(_ query: String) async -> [LocalPlantEntry]
|
||||
func searchByCommonName(_ query: String) async -> [LocalPlantEntry]
|
||||
func searchAll(_ query: String) async -> [LocalPlantEntry]
|
||||
func getByFamily(_ family: String) async -> [LocalPlantEntry]
|
||||
func getByCategory(_ category: PlantCategory) async -> [LocalPlantEntry]
|
||||
func getPlant(scientificName: String) async -> LocalPlantEntry?
|
||||
var allCategories: [PlantCategory] { get }
|
||||
var allFamilies: [String] { get }
|
||||
var plantCount: Int { get }
|
||||
}
|
||||
```
|
||||
- [ ] Implement `PlantDatabaseService` actor for thread safety:
|
||||
- Load JSON from bundle on first access
|
||||
- Build search indices for fast lookups
|
||||
- Implement fuzzy matching for search (handle typos)
|
||||
- Cache loaded database in memory
|
||||
- [ ] Create `PlantDatabaseError` enum:
|
||||
- `fileNotFound`
|
||||
- `decodingFailed(Error)`
|
||||
- `notLoaded`
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Service loads all 2,278 plants without memory issues
|
||||
- Search returns results in < 50ms for any query
|
||||
- Case-insensitive search works for scientific and common names
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Add JSON to Xcode Bundle
|
||||
- [ ] Copy `data/houseplants_list.json` to `PlantGuide/Resources/` folder
|
||||
- [ ] Add file to Xcode project target (ensure "Copy Bundle Resources" includes it)
|
||||
- [ ] Verify file accessible via `Bundle.main.url(forResource:withExtension:)`
|
||||
|
||||
**Acceptance Criteria:** `Bundle.main.url(forResource: "houseplants_list", withExtension: "json")` returns valid URL
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Create Plant Lookup Use Case
|
||||
**File:** `PlantGuide/Domain/UseCases/PlantLookup/LookupPlantUseCase.swift`
|
||||
|
||||
- [ ] Create `LookupPlantUseCase`:
|
||||
```swift
|
||||
protocol LookupPlantUseCaseProtocol: Sendable {
|
||||
func execute(scientificName: String) async -> LocalPlantEntry?
|
||||
func search(query: String) async -> [LocalPlantEntry]
|
||||
func suggestMatches(for identifiedName: String, confidence: Double) async -> [LocalPlantEntry]
|
||||
}
|
||||
```
|
||||
- [ ] Implement suggestion logic:
|
||||
- If confidence < 0.7, return top 5 fuzzy matches from local database
|
||||
- If confidence >= 0.7, return exact match + similar species from same genus
|
||||
- [ ] Handle cultivar names (e.g., `'Brasil'`, `'Pink Princess'`) by matching base species
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- `suggestMatches(for: "Philodendron hederaceum", confidence: 0.9)` returns the plant + related cultivars
|
||||
- Fuzzy search for "Philo brasil" finds "Philodendron hederaceum 'Brasil'"
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Integrate with Identification Flow
|
||||
**File:** `PlantGuide/Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
|
||||
- [ ] Inject `LookupPlantUseCaseProtocol` via DI container
|
||||
- [ ] After ML identification, look up plant in local database:
|
||||
- Enrich results with category and family data
|
||||
- Show "Found in local database" badge for verified matches
|
||||
- Display related species suggestions for low-confidence identifications
|
||||
- [ ] Add `localDatabaseMatch: LocalPlantEntry?` property to view model state
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Identification results show category (e.g., "Tropical Foliage") from local database
|
||||
- Low-confidence results display "Did you mean..." suggestions from local database
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Create Plant Browse View
|
||||
**File:** `PlantGuide/Presentation/Scenes/Browse/BrowsePlantsView.swift`
|
||||
|
||||
- [ ] Create `BrowsePlantsView` with:
|
||||
- Category filter chips (11 categories)
|
||||
- Search bar for name lookup
|
||||
- Alphabetical section list grouped by first letter
|
||||
- Plant count badge showing total matches
|
||||
- [ ] Create `BrowsePlantsViewModel`:
|
||||
```swift
|
||||
@MainActor
|
||||
final class BrowsePlantsViewModel: ObservableObject {
|
||||
@Published var searchQuery = ""
|
||||
@Published var selectedCategory: PlantCategory?
|
||||
@Published var plants: [LocalPlantEntry] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
func loadPlants() async
|
||||
func search() async
|
||||
func filterByCategory(_ category: PlantCategory?) async
|
||||
}
|
||||
```
|
||||
- [ ] Create `LocalPlantRow` component showing:
|
||||
- Scientific name (primary)
|
||||
- Common names (secondary, comma-separated)
|
||||
- Family badge
|
||||
- Category icon
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Browse view displays all 2,278 plants with smooth scrolling
|
||||
- Category filter correctly shows only plants in selected category
|
||||
- Search finds plants by any name (scientific or common)
|
||||
|
||||
---
|
||||
|
||||
### 1.7 Add Browse Tab to Navigation
|
||||
**File:** `PlantGuide/Presentation/Navigation/MainTabView.swift`
|
||||
|
||||
- [ ] Add "Browse" tab between Camera and Collection:
|
||||
- Icon: `book.fill` or `leaf.fill`
|
||||
- Label: "Browse"
|
||||
- [ ] Update tab order: Camera → Browse → Collection → Care → Settings
|
||||
- [ ] Wire up `BrowsePlantsView` with DI container dependencies
|
||||
|
||||
**Acceptance Criteria:** Browse tab displays and switches correctly, shows plant database
|
||||
|
||||
---
|
||||
|
||||
### 1.8 Update DI Container
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
- [ ] Register `PlantDatabaseService` as singleton (load once, reuse)
|
||||
- [ ] Register `LookupPlantUseCase` with database service dependency
|
||||
- [ ] Register `BrowsePlantsViewModel` factory
|
||||
- [ ] Add lazy initialization for database service (load on first access, not app launch)
|
||||
|
||||
**Acceptance Criteria:** All new dependencies resolve correctly without circular references
|
||||
|
||||
---
|
||||
|
||||
### 1.9 Create Local Database Tests
|
||||
**File:** `PlantGuideTests/Data/PlantDatabaseServiceTests.swift`
|
||||
|
||||
- [ ] Test JSON loading success
|
||||
- [ ] Test plant count equals 2,278
|
||||
- [ ] Test category count equals 11
|
||||
- [ ] Test family count equals 50
|
||||
- [ ] Test search by scientific name (exact match)
|
||||
- [ ] Test search by common name (partial match)
|
||||
- [ ] Test case-insensitive search
|
||||
- [ ] Test category filter returns only plants in category
|
||||
- [ ] Test empty search returns empty array
|
||||
- [ ] Test cultivar name matching (e.g., searching "Pink Princess" finds `Philodendron erubescens 'Pink Princess'`)
|
||||
|
||||
**Acceptance Criteria:** All tests pass, code coverage > 80% for `PlantDatabaseService`
|
||||
|
||||
---
|
||||
|
||||
## End-of-Phase Validation
|
||||
|
||||
### Functional Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| Database Load | Launch app, go to Browse tab | Plants display without crash, count shows 2,278 | [ ] |
|
||||
| Category Filter | Select "Cactus" category | Only cactus plants shown, count updates | [ ] |
|
||||
| Search Scientific | Search "Monstera deliciosa" | Exact match appears at top | [ ] |
|
||||
| Search Common | Search "Snake Plant" | Sansevieria varieties appear | [ ] |
|
||||
| Search Partial | Search "philo" | All Philodendron species appear | [ ] |
|
||||
| Identification Enrichment | Identify a plant via camera | Category and family from local DB shown in results | [ ] |
|
||||
| Low Confidence Suggestions | Get low-confidence identification | "Did you mean..." suggestions appear from local DB | [ ] |
|
||||
| Scroll Performance | Scroll through all plants quickly | No dropped frames, smooth 60fps | [ ] |
|
||||
| Memory Usage | Load database, navigate away, return | Memory stable, no leaks | [ ] |
|
||||
|
||||
### Code Quality Verification
|
||||
|
||||
| Check | Criteria | Status |
|
||||
|-------|----------|--------|
|
||||
| Build | Project builds with zero warnings | [ ] |
|
||||
| Tests | All PlantDatabaseService tests pass | [ ] |
|
||||
| Coverage | Code coverage > 80% for new code | [ ] |
|
||||
| Sendable | All new types conform to Sendable | [ ] |
|
||||
| Actor Isolation | PlantDatabaseService is thread-safe actor | [ ] |
|
||||
| Error Handling | All async functions have proper try/catch | [ ] |
|
||||
| Accessibility | Browse view has accessibility labels | [ ] |
|
||||
|
||||
### Performance Verification
|
||||
|
||||
| Metric | Target | Status |
|
||||
|--------|--------|--------|
|
||||
| Database Load | < 500ms first load | [ ] |
|
||||
| Search Response | < 50ms per query | [ ] |
|
||||
| Memory (Browse) | < 30 MB additional | [ ] |
|
||||
| Scroll FPS | 60 fps constant | [ ] |
|
||||
| App Launch Impact | < 100ms added to launch | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Completion Checklist
|
||||
|
||||
- [ ] All 9 tasks completed
|
||||
- [ ] All functional tests pass
|
||||
- [ ] All code quality checks pass
|
||||
- [ ] All performance targets met
|
||||
- [ ] Unit tests written and passing
|
||||
- [ ] Code committed with descriptive message
|
||||
- [ ] Ready for Phase 2 (On-Device ML Integration)
|
||||
|
||||
---
|
||||
|
||||
## File Manifest
|
||||
|
||||
New files to create:
|
||||
```
|
||||
PlantGuide/
|
||||
├── Data/
|
||||
│ └── DataSources/
|
||||
│ └── Local/
|
||||
│ └── PlantDatabase/
|
||||
│ ├── LocalPlantEntry.swift
|
||||
│ ├── LocalPlantDatabase.swift
|
||||
│ ├── PlantCategory.swift
|
||||
│ ├── PlantDatabaseService.swift
|
||||
│ └── PlantDatabaseError.swift
|
||||
├── Domain/
|
||||
│ └── UseCases/
|
||||
│ └── PlantLookup/
|
||||
│ └── LookupPlantUseCase.swift
|
||||
├── Presentation/
|
||||
│ └── Scenes/
|
||||
│ └── Browse/
|
||||
│ ├── BrowsePlantsView.swift
|
||||
│ ├── BrowsePlantsViewModel.swift
|
||||
│ └── Components/
|
||||
│ └── LocalPlantRow.swift
|
||||
└── Resources/
|
||||
└── houseplants_list.json (copied from data/)
|
||||
|
||||
PlantGuideTests/
|
||||
└── Data/
|
||||
└── PlantDatabaseServiceTests.swift
|
||||
```
|
||||
|
||||
Files to modify:
|
||||
```
|
||||
PlantGuide/
|
||||
├── Core/DI/DIContainer.swift (add new registrations)
|
||||
├── Presentation/Navigation/MainTabView.swift (add Browse tab)
|
||||
└── Presentation/Scenes/Identification/IdentificationViewModel.swift (add local lookup)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `actor` for `PlantDatabaseService` to ensure thread safety for concurrent searches
|
||||
- Consider implementing Trie data structure for fast prefix-based search if needed
|
||||
- The JSON should be loaded lazily on first browse access, not at app launch
|
||||
- For cultivar matching, strip quotes and match base species name
|
||||
- Category icons suggestion:
|
||||
- Air Plant: `leaf.arrow.triangle.circlepath`
|
||||
- Bromeliad: `sparkles`
|
||||
- Cactus: `sun.max.fill`
|
||||
- Fern: `leaf.fill`
|
||||
- Flowering Houseplant: `camera.macro`
|
||||
- Herb: `leaf.circle`
|
||||
- Orchid: `camera.macro.circle`
|
||||
- Palm: `tree.fill`
|
||||
- Succulent: `drop.fill`
|
||||
- Trailing/Climbing: `arrow.up.right`
|
||||
- Tropical Foliage: `leaf.fill`
|
||||
327
Docs/Phase2_plan.md
Normal file
327
Docs/Phase2_plan.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Phase 2: On-Device ML
|
||||
|
||||
**Goal:** Offline plant identification with Core ML
|
||||
|
||||
**Prerequisites:** Phase 1 complete (camera capture working, folder structure in place)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 2.1 Download PlantNet-300K Pre-trained Weights
|
||||
- [ ] Create `scripts/` directory at project root
|
||||
- [ ] Download ResNet50 weights from Zenodo:
|
||||
```bash
|
||||
wget https://zenodo.org/records/4726653/files/resnet50_weights_best_acc.tar
|
||||
```
|
||||
- [ ] Verify file integrity (expected ~100MB)
|
||||
- [ ] Document download source and version in `scripts/README.md`
|
||||
|
||||
**Acceptance Criteria:** `resnet50_weights_best_acc.tar` file present and verified
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Convert Model to Core ML
|
||||
- [ ] Install Python dependencies:
|
||||
```bash
|
||||
pip install torch torchvision coremltools pillow numpy
|
||||
```
|
||||
- [ ] Create `scripts/convert_plantnet_to_coreml.py`:
|
||||
- Load ResNet50 architecture with 1,081 output classes
|
||||
- Load PlantNet-300K weights
|
||||
- Trace model with dummy input
|
||||
- Configure image input preprocessing (RGB, 224x224, normalized)
|
||||
- Convert to ML Program format
|
||||
- Target iOS 17+ deployment
|
||||
- Use FLOAT16 precision for performance
|
||||
- [ ] Run conversion script
|
||||
- [ ] Verify output `PlantNet300K.mlpackage` created successfully
|
||||
|
||||
**Acceptance Criteria:** `PlantNet300K.mlpackage` generated without errors, file size ~50-100MB
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Add Core ML Model to Xcode
|
||||
- [ ] Copy `PlantNet300K.mlpackage` to `ML/Models/`
|
||||
- [ ] Add to Xcode project target
|
||||
- [ ] Verify Xcode generates `PlantNet300K.swift` interface
|
||||
- [ ] Configure model to compile at build time (not runtime)
|
||||
- [ ] Test that project still builds successfully
|
||||
|
||||
**Acceptance Criteria:** Model visible in Xcode, auto-generated Swift interface available
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Create Plant Labels JSON
|
||||
- [ ] Download species list from PlantNet-300K dataset
|
||||
- [ ] Create `Resources/PlantLabels.json` with structure:
|
||||
```json
|
||||
{
|
||||
"labels": [
|
||||
{
|
||||
"index": 0,
|
||||
"scientificName": "Acer campestre",
|
||||
"commonNames": ["Field Maple", "Hedge Maple"],
|
||||
"family": "Sapindaceae"
|
||||
}
|
||||
],
|
||||
"version": "1.0",
|
||||
"source": "PlantNet-300K"
|
||||
}
|
||||
```
|
||||
- [ ] Ensure all 1,081 species mapped correctly
|
||||
- [ ] Validate JSON syntax
|
||||
- [ ] Create `ML/Services/PlantLabelService.swift` to load and query labels
|
||||
|
||||
**Acceptance Criteria:** JSON contains 1,081 species entries, loads without parsing errors
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Implement Plant Classification Service
|
||||
- [ ] Create `ML/Services/PlantClassificationService.swift`
|
||||
- [ ] Define `PlantClassificationServiceProtocol`:
|
||||
```swift
|
||||
protocol PlantClassificationServiceProtocol: Sendable {
|
||||
func classify(image: CGImage) async throws -> [PlantPrediction]
|
||||
}
|
||||
```
|
||||
- [ ] Create `PlantPrediction` struct:
|
||||
- `speciesIndex: Int`
|
||||
- `confidence: Float`
|
||||
- `scientificName: String`
|
||||
- `commonNames: [String]`
|
||||
- [ ] Implement using Vision framework:
|
||||
- Create `VNCoreMLRequest` with PlantNet300K model
|
||||
- Configure `imageCropAndScaleOption` to `.centerCrop`
|
||||
- Process results from `VNClassificationObservation`
|
||||
- [ ] Return top 10 predictions sorted by confidence
|
||||
- [ ] Handle model loading errors gracefully
|
||||
|
||||
**Acceptance Criteria:** Service returns predictions for any valid CGImage input
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Create Image Preprocessor
|
||||
- [ ] Create `ML/Preprocessing/ImagePreprocessor.swift`
|
||||
- [ ] Define `ImagePreprocessorProtocol`:
|
||||
```swift
|
||||
protocol ImagePreprocessorProtocol: Sendable {
|
||||
func prepare(image: UIImage) -> CGImage?
|
||||
func prepare(data: Data) -> CGImage?
|
||||
}
|
||||
```
|
||||
- [ ] Implement preprocessing pipeline:
|
||||
- Convert UIImage to CGImage
|
||||
- Handle orientation correction (EXIF)
|
||||
- Resize to 224x224 if needed (Vision handles this, but validate)
|
||||
- Convert color space to sRGB if needed
|
||||
- [ ] Add validation for minimum image dimensions
|
||||
- [ ] Handle edge cases (nil image, corrupt data)
|
||||
|
||||
**Acceptance Criteria:** Preprocessor handles images from camera and photo library correctly
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Build Identify Plant On-Device Use Case
|
||||
- [ ] Create `Domain/UseCases/Identification/IdentifyPlantOnDeviceUseCase.swift`
|
||||
- [ ] Define use case protocol:
|
||||
```swift
|
||||
protocol IdentifyPlantOnDeviceUseCaseProtocol: Sendable {
|
||||
func execute(image: UIImage) async throws -> PlantIdentification
|
||||
}
|
||||
```
|
||||
- [ ] Implement use case:
|
||||
- Preprocess image
|
||||
- Call classification service
|
||||
- Map predictions to `PlantIdentification` entity
|
||||
- Set `source` to `.onDevice`
|
||||
- Record timestamp
|
||||
- [ ] Add to DIContainer factory methods
|
||||
- [ ] Create unit test with mock classification service
|
||||
|
||||
**Acceptance Criteria:** Use case integrates preprocessor and classifier, returns domain entity
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Create Identification View
|
||||
- [ ] Create `Presentation/Scenes/Identification/IdentificationView.swift`
|
||||
- [ ] Create `Presentation/Scenes/Identification/IdentificationViewModel.swift`
|
||||
- [ ] Implement UI states:
|
||||
- Loading (during inference)
|
||||
- Success (show results)
|
||||
- Error (show retry option)
|
||||
- [ ] Display captured image at top
|
||||
- [ ] Show list of top 10 species matches
|
||||
- [ ] Add "Identify Again" button
|
||||
- [ ] Add "Save to Collection" button (disabled for Phase 2)
|
||||
- [ ] Navigate from CameraView after capture
|
||||
|
||||
**Acceptance Criteria:** View displays results after photo capture, handles all states
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Build Species Match Components
|
||||
- [ ] Create `Presentation/Common/Components/SpeciesMatchCard.swift`:
|
||||
- Scientific name (primary text)
|
||||
- Common names (secondary text)
|
||||
- Confidence score
|
||||
- Ranking number (1-10)
|
||||
- Chevron for detail navigation (future)
|
||||
- [ ] Create `Presentation/Common/Components/ConfidenceIndicator.swift`:
|
||||
- Visual progress bar
|
||||
- Percentage text
|
||||
- Color coding:
|
||||
- Green: > 70%
|
||||
- Yellow: 40-70%
|
||||
- Red: < 40%
|
||||
- [ ] Style components with consistent design language
|
||||
- [ ] Ensure accessibility labels set correctly
|
||||
|
||||
**Acceptance Criteria:** Components render correctly with sample data, accessible
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Performance Testing
|
||||
- [ ] Create `ML/Tests/ClassificationPerformanceTests.swift`
|
||||
- [ ] Measure inference time on real device:
|
||||
- Test with 10 different plant images
|
||||
- Record min/max/average times
|
||||
- Target: < 500ms average
|
||||
- [ ] Measure memory usage during inference:
|
||||
- Target: < 200MB peak
|
||||
- [ ] Test on oldest supported device (if available)
|
||||
- [ ] Profile with Instruments (Core ML template)
|
||||
- [ ] Document results in `Docs/Phase2_performance_results.md`
|
||||
|
||||
**Acceptance Criteria:** Average inference < 500ms on target device
|
||||
|
||||
---
|
||||
|
||||
## End-of-Phase Validation
|
||||
|
||||
### Functional Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| Model Loads | Launch app | No model loading errors in console | [ ] |
|
||||
| Labels Load | Launch app | PlantLabels.json parsed successfully | [ ] |
|
||||
| Camera → Identify | Take photo, wait | Identification results appear | [ ] |
|
||||
| Results Display | View results | 10 species shown with confidence scores | [ ] |
|
||||
| Confidence Colors | View varied results | Colors match confidence levels | [ ] |
|
||||
| Loading State | Take photo | Loading indicator shown during inference | [ ] |
|
||||
| Error Handling | Force error (mock) | Error view displays with retry | [ ] |
|
||||
| Retry Flow | Tap retry | Returns to camera | [ ] |
|
||||
|
||||
### Code Quality Verification
|
||||
|
||||
| Check | Criteria | Status |
|
||||
|-------|----------|--------|
|
||||
| Build | Project builds with zero warnings | [ ] |
|
||||
| Architecture | ML code isolated in ML/ folder | [ ] |
|
||||
| Protocols | Classification service uses protocol | [ ] |
|
||||
| Sendable | All ML services are Sendable | [ ] |
|
||||
| Use Case | Identification logic in use case, not ViewModel | [ ] |
|
||||
| DI Container | Classification service injected via container | [ ] |
|
||||
| Error Types | Custom errors defined for ML failures | [ ] |
|
||||
| Unit Tests | Use case has at least one unit test | [ ] |
|
||||
|
||||
### Performance Verification
|
||||
|
||||
| Metric | Target | Actual | Status |
|
||||
|--------|--------|--------|--------|
|
||||
| Model Load Time | < 2 seconds | | [ ] |
|
||||
| Inference Time (avg) | < 500ms | | [ ] |
|
||||
| Inference Time (max) | < 1000ms | | [ ] |
|
||||
| Memory During Inference | < 200MB | | [ ] |
|
||||
| Memory After Inference | Returns to baseline | | [ ] |
|
||||
| App Size Increase | < 100MB (model) | | [ ] |
|
||||
|
||||
### Accuracy Verification
|
||||
|
||||
| Test Image | Expected Top Match | Actual Top Match | Confidence | Status |
|
||||
|------------|-------------------|------------------|------------|--------|
|
||||
| Rose photo | Rosa sp. | | | [ ] |
|
||||
| Oak leaf | Quercus sp. | | | [ ] |
|
||||
| Sunflower | Helianthus annuus | | | [ ] |
|
||||
| Tulip | Tulipa sp. | | | [ ] |
|
||||
| Fern | Pteridophyta sp. | | | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 Completion Checklist
|
||||
|
||||
- [ ] All 10 tasks completed
|
||||
- [ ] All functional tests pass
|
||||
- [ ] All code quality checks pass
|
||||
- [ ] All performance targets met
|
||||
- [ ] Accuracy spot-checked with 5+ real plant images
|
||||
- [ ] Core ML model included in app bundle
|
||||
- [ ] PlantLabels.json contains all 1,081 species
|
||||
- [ ] Performance results documented
|
||||
- [ ] Code committed with descriptive message
|
||||
- [ ] Ready for Phase 3 (PlantNet API Integration)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### ML-Specific Errors
|
||||
```swift
|
||||
enum PlantClassificationError: Error, LocalizedError {
|
||||
case modelLoadFailed
|
||||
case imagePreprocessingFailed
|
||||
case inferenceTimeout
|
||||
case noResultsReturned
|
||||
case labelsNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelLoadFailed:
|
||||
return "Unable to load plant identification model"
|
||||
case .imagePreprocessingFailed:
|
||||
return "Unable to process image for analysis"
|
||||
case .inferenceTimeout:
|
||||
return "Identification took too long"
|
||||
case .noResultsReturned:
|
||||
return "No plant species identified"
|
||||
case .labelsNotFound:
|
||||
return "Plant database not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Vision framework handles image scaling/cropping automatically via `imageCropAndScaleOption`
|
||||
- Core ML models should be loaded once and reused (expensive to initialize)
|
||||
- Use `MLModelConfiguration` to control compute units (CPU, GPU, Neural Engine)
|
||||
- FLOAT16 precision reduces model size with minimal accuracy loss
|
||||
- Test on real device early - simulator performance not representative
|
||||
- Consider background thread for inference to keep UI responsive
|
||||
- PlantNet-300K trained on European flora - accuracy varies by region
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Type | Notes |
|
||||
|------------|------|-------|
|
||||
| PlantNet300K.mlpackage | Core ML Model | ~50-100MB, bundled |
|
||||
| PlantLabels.json | Data File | 1,081 species, bundled |
|
||||
| Vision.framework | System | iOS 11+ |
|
||||
| CoreML.framework | System | iOS 11+ |
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Model too large for App Store | Use on-demand resources or model quantization |
|
||||
| Inference too slow | Profile with Instruments, use Neural Engine |
|
||||
| Low accuracy | Phase 3 adds API fallback for confirmation |
|
||||
| Memory pressure | Unload model when not in use (trade-off: reload time) |
|
||||
| Unsupported species | Show "Unknown" with low confidence, suggest API |
|
||||
513
Docs/Phase3_plan.md
Normal file
513
Docs/Phase3_plan.md
Normal file
@@ -0,0 +1,513 @@
|
||||
# Phase 3: PlantNet API Integration
|
||||
|
||||
**Goal:** Hybrid identification with API fallback for improved accuracy
|
||||
|
||||
**Prerequisites:** Phase 2 complete (on-device ML working, identification flow functional)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 3.1 Register for PlantNet API Access ✅
|
||||
- [x] Navigate to [my.plantnet.org](https://my.plantnet.org)
|
||||
- [x] Create developer account
|
||||
- [x] Generate API key
|
||||
- [x] Review API documentation and rate limits
|
||||
- [x] Create `App/Configuration/APIKeys.swift`:
|
||||
```swift
|
||||
enum APIKeys {
|
||||
static let plantNetAPIKey: String = {
|
||||
// Load from environment or secure storage
|
||||
guard let key = Bundle.main.object(forInfoDictionaryKey: "PLANTNET_API_KEY") as? String else {
|
||||
fatalError("PlantNet API key not configured")
|
||||
}
|
||||
return key
|
||||
}()
|
||||
}
|
||||
```
|
||||
- [x] Add `PLANTNET_API_KEY` to Info.plist (via xcconfig for security)
|
||||
- [x] Create `.xcconfig` file for API keys (add to .gitignore)
|
||||
- [ ] Document API key setup in project README
|
||||
|
||||
**Acceptance Criteria:** API key configured and accessible in code, not committed to git ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Create PlantNet Endpoints ✅
|
||||
- [x] Create `Data/DataSources/Remote/PlantNetAPI/PlantNetEndpoints.swift`
|
||||
- [ ] Define endpoint configuration:
|
||||
```swift
|
||||
enum PlantNetEndpoint: Endpoint {
|
||||
case identify(project: String, imageData: Data, organs: [String])
|
||||
|
||||
var baseURL: URL { URL(string: "https://my-api.plantnet.org")! }
|
||||
var path: String { "/v2/identify/\(project)" }
|
||||
var method: HTTPMethod { .post }
|
||||
var headers: [String: String] {
|
||||
["Api-Key": APIKeys.plantNetAPIKey]
|
||||
}
|
||||
}
|
||||
```
|
||||
- [x] Support multiple project types:
|
||||
- `all` - All flora
|
||||
- `weurope` - Western Europe
|
||||
- `canada` - Canada
|
||||
- `useful` - Useful plants
|
||||
- [x] Define organ types: `leaf`, `flower`, `fruit`, `bark`, `auto`
|
||||
- [x] Create query parameter builder for organs
|
||||
|
||||
**Acceptance Criteria:** Endpoint builds correct URL with headers and query params ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Implement PlantNet API Service ✅
|
||||
- [x] Create `Data/DataSources/Remote/PlantNetAPI/PlantNetAPIService.swift`
|
||||
- [ ] Define protocol:
|
||||
```swift
|
||||
protocol PlantNetAPIServiceProtocol: Sendable {
|
||||
func identify(
|
||||
imageData: Data,
|
||||
organs: [PlantOrgan],
|
||||
project: PlantNetProject
|
||||
) async throws -> PlantNetIdentifyResponseDTO
|
||||
}
|
||||
```
|
||||
- [x] Implement multipart form-data upload:
|
||||
- Build multipart boundary
|
||||
- Add image data with correct MIME type (image/jpeg)
|
||||
- Add organs parameter
|
||||
- Set Content-Type header with boundary
|
||||
- [x] Handle response parsing
|
||||
- [ ] Implement retry logic with exponential backoff (1 retry)
|
||||
- [x] Add request timeout (30 seconds)
|
||||
- [x] Log request/response for debugging
|
||||
|
||||
**Acceptance Criteria:** Service can upload image and receive valid response from API ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Create PlantNet DTOs ✅
|
||||
- [x] Create `Data/DataSources/Remote/PlantNetAPI/DTOs/PlantNetDTOs.swift`:
|
||||
```swift
|
||||
struct PlantNetIdentifyResponseDTO: Decodable {
|
||||
let query: QueryDTO
|
||||
let language: String
|
||||
let preferedReferential: String
|
||||
let results: [PlantNetResultDTO]
|
||||
let version: String
|
||||
let remainingIdentificationRequests: Int
|
||||
}
|
||||
```
|
||||
- [x] Create `PlantNetResultDTO` (consolidated in PlantNetDTOs.swift)
|
||||
- [x] Create `PlantNetSpeciesDTO` (consolidated in PlantNetDTOs.swift)
|
||||
- [x] Create supporting DTOs: `PlantNetGenusDTO`, `PlantNetFamilyDTO`, `PlantNetGBIFDataDTO`, `PlantNetQueryDTO`
|
||||
- [x] Add CodingKeys where API uses different naming conventions
|
||||
- [ ] Write unit tests for DTO decoding with sample JSON
|
||||
|
||||
**Acceptance Criteria:** DTOs decode actual PlantNet API response without errors ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Build PlantNet Mapper ✅
|
||||
- [x] Create `Data/Mappers/PlantNetMapper.swift`
|
||||
- [x] Implement mapping functions:
|
||||
```swift
|
||||
struct PlantNetMapper {
|
||||
static func mapToIdentification(
|
||||
from response: PlantNetIdentifyResponseDTO,
|
||||
imageData: Data
|
||||
) -> PlantIdentification
|
||||
|
||||
static func mapToPredictions(
|
||||
from results: [PlantNetResultDTO]
|
||||
) -> [PlantPrediction]
|
||||
}
|
||||
```
|
||||
- [x] Map API confidence scores (0.0-1.0) to percentage
|
||||
- [x] Handle missing optional fields gracefully
|
||||
- [x] Map common names (may be empty array)
|
||||
- [x] Set identification source to `.plantNetAPI`
|
||||
- [x] Include remaining API requests in metadata
|
||||
|
||||
**Acceptance Criteria:** Mapper produces valid domain entities from all DTO variations ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Implement Online Identification Use Case ✅
|
||||
- [x] Create `Domain/UseCases/Identification/IdentifyPlantOnlineUseCase.swift`
|
||||
- [x] Define protocol:
|
||||
```swift
|
||||
protocol IdentifyPlantOnlineUseCaseProtocol: Sendable {
|
||||
func execute(
|
||||
image: UIImage,
|
||||
organs: [PlantOrgan],
|
||||
project: PlantNetProject
|
||||
) async throws -> PlantIdentification
|
||||
}
|
||||
```
|
||||
- [x] Implement use case:
|
||||
- Convert UIImage to JPEG data (quality: 0.8)
|
||||
- Validate image size (max 2MB, resize if needed)
|
||||
- Call PlantNet API service
|
||||
- Map response to domain entity
|
||||
- Handle specific API errors
|
||||
- [x] Add to DIContainer
|
||||
- [ ] Create unit test with mocked API service
|
||||
|
||||
**Acceptance Criteria:** Use case returns identification from API, handles errors gracefully ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Create Hybrid Identification Use Case ✅
|
||||
- [x] Create `Domain/UseCases/Identification/HybridIdentificationUseCase.swift`
|
||||
- [x] Define protocol:
|
||||
```swift
|
||||
protocol HybridIdentificationUseCaseProtocol: Sendable {
|
||||
func execute(
|
||||
image: UIImage,
|
||||
strategy: HybridStrategy
|
||||
) async throws -> HybridIdentificationResult
|
||||
}
|
||||
```
|
||||
- [ ] Define `HybridStrategy` enum:
|
||||
```swift
|
||||
enum HybridStrategy {
|
||||
case onDeviceOnly
|
||||
case apiOnly
|
||||
case onDeviceFirst(apiThreshold: Float) // Use API if confidence below threshold
|
||||
case parallel // Run both, prefer API results
|
||||
}
|
||||
```
|
||||
- [ ] Define `HybridIdentificationResult`:
|
||||
```swift
|
||||
struct HybridIdentificationResult: Sendable {
|
||||
let onDeviceResult: PlantIdentification?
|
||||
let apiResult: PlantIdentification?
|
||||
let preferredResult: PlantIdentification
|
||||
let source: IdentificationSource
|
||||
let processingTime: TimeInterval
|
||||
}
|
||||
```
|
||||
- [x] Implement strategies:
|
||||
- `onDeviceFirst`: Run on-device, call API if top confidence < threshold (default 70%)
|
||||
- `parallel`: Run both concurrently, merge results
|
||||
- [x] Handle offline gracefully (fall back to on-device only)
|
||||
- [ ] Track timing for analytics
|
||||
- [x] Add to DIContainer
|
||||
|
||||
**Acceptance Criteria:** Hybrid use case correctly implements all strategies ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Add Network Reachability Monitoring ✅
|
||||
- [x] Create `Core/Utilities/NetworkMonitor.swift`
|
||||
- [x] Implement using `NWPathMonitor`:
|
||||
```swift
|
||||
@Observable
|
||||
final class NetworkMonitor: Sendable {
|
||||
private(set) var isConnected: Bool = true
|
||||
private(set) var connectionType: ConnectionType = .unknown
|
||||
|
||||
enum ConnectionType: Sendable {
|
||||
case wifi, cellular, ethernet, unknown
|
||||
}
|
||||
}
|
||||
```
|
||||
- [x] Start monitoring on app launch
|
||||
- [x] Publish connectivity changes
|
||||
- [x] Create SwiftUI environment key for injection
|
||||
- [ ] Update IdentificationViewModel to check connectivity
|
||||
- [ ] Show offline indicator in UI when disconnected
|
||||
|
||||
**Acceptance Criteria:** App detects network changes and updates UI accordingly ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Handle API Rate Limiting ✅
|
||||
- [x] Create `Data/DataSources/Remote/PlantNetAPI/RateLimitTracker.swift`
|
||||
- [x] Track remaining requests from API response header
|
||||
- [x] Persist daily count to UserDefaults:
|
||||
```swift
|
||||
actor RateLimitTracker {
|
||||
private(set) var remainingRequests: Int
|
||||
private(set) var resetDate: Date
|
||||
|
||||
func recordUsage(remaining: Int)
|
||||
func canMakeRequest() -> Bool
|
||||
}
|
||||
```
|
||||
- [x] Define threshold warnings:
|
||||
- 100 remaining: Show subtle indicator
|
||||
- 50 remaining: Show warning
|
||||
- 10 remaining: Show critical warning
|
||||
- 0 remaining: Block API calls, use on-device only
|
||||
- [ ] Add rate limit status to Settings view
|
||||
- [x] Reset counter daily at midnight UTC
|
||||
- [x] Handle 429 Too Many Requests response
|
||||
|
||||
**Acceptance Criteria:** App tracks usage, warns user, blocks when exhausted ✅
|
||||
|
||||
---
|
||||
|
||||
### 3.10 Implement Identification Cache ✅
|
||||
- [x] Create `Data/DataSources/Local/Cache/IdentificationCache.swift`
|
||||
- [x] Define protocol:
|
||||
```swift
|
||||
protocol IdentificationCacheProtocol: Sendable {
|
||||
func get(for imageHash: String) async -> PlantIdentification?
|
||||
func store(_ identification: PlantIdentification, imageHash: String) async
|
||||
func clear() async
|
||||
func clearExpired() async
|
||||
}
|
||||
```
|
||||
- [x] Implement cache with:
|
||||
- Image hash as key (SHA256)
|
||||
- TTL: 7 days for cached results
|
||||
- Max entries: 100 (LRU eviction)
|
||||
- Persistence: file-based JSON
|
||||
- [x] Create `ImageHasher` for consistent hashing (in IdentificationCache.swift)
|
||||
- [ ] Check cache before API call in use cases
|
||||
- [ ] Store successful identifications
|
||||
- [ ] Add cache statistics to Settings
|
||||
|
||||
**Acceptance Criteria:** Repeat identifications served from cache, reduces API usage ✅
|
||||
|
||||
---
|
||||
|
||||
## End-of-Phase Validation
|
||||
|
||||
### Functional Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| API Key Configured | Build app | No crash on API key access | [ ] |
|
||||
| Online Identification | Take photo with network | API results displayed | [ ] |
|
||||
| Offline Fallback | Disable network, take photo | On-device results displayed, offline indicator shown | [ ] |
|
||||
| Hybrid Strategy | Use onDeviceFirst with low confidence | API called for confirmation | [ ] |
|
||||
| Hybrid Strategy | Use onDeviceFirst with high confidence | API not called | [ ] |
|
||||
| Rate Limit Display | Check settings | Shows remaining requests | [ ] |
|
||||
| Rate Limit Warning | Simulate low remaining | Warning displayed | [ ] |
|
||||
| Rate Limit Block | Simulate 0 remaining | API blocked, on-device used | [ ] |
|
||||
| Cache Hit | Identify same plant twice | Second result instant, no API call | [ ] |
|
||||
| Cache Miss | Identify new plant | API called, result cached | [ ] |
|
||||
| Network Recovery | Restore network after offline | API becomes available | [ ] |
|
||||
| API Error Handling | Force API error | Error message shown with retry | [ ] |
|
||||
| Multipart Upload | Verify request format | Image uploaded correctly | [ ] |
|
||||
|
||||
### Code Quality Verification
|
||||
|
||||
| Check | Criteria | Status |
|
||||
|-------|----------|--------|
|
||||
| Build | Project builds with zero warnings | [x] |
|
||||
| Architecture | API code isolated in Data/DataSources/Remote/ | [x] |
|
||||
| Protocols | All services use protocols for testability | [x] |
|
||||
| Sendable | All new types conform to Sendable | [x] |
|
||||
| DTOs | DTOs decode sample API responses correctly | [x] |
|
||||
| Mapper | Mapper handles all optional fields | [x] |
|
||||
| Use Cases | Business logic in use cases, not ViewModels | [x] |
|
||||
| DI Container | New services registered in container | [x] |
|
||||
| Error Types | API-specific errors defined | [x] |
|
||||
| Unit Tests | DTOs and mappers have unit tests | [ ] |
|
||||
| Secrets | API key not in source control | [x] |
|
||||
|
||||
### Performance Verification
|
||||
|
||||
| Metric | Target | Actual | Status |
|
||||
|--------|--------|--------|--------|
|
||||
| API Response Time | < 5 seconds | | [ ] |
|
||||
| Image Upload Size | < 2 MB (compressed) | | [ ] |
|
||||
| Cache Lookup Time | < 50ms | | [ ] |
|
||||
| Hybrid (onDeviceFirst) | < 1s when not calling API | | [ ] |
|
||||
| Hybrid (parallel) | < max(onDevice, API) + 100ms | | [ ] |
|
||||
| Memory (cache full) | < 50 MB additional | | [ ] |
|
||||
| Network Monitor | < 100ms to detect change | | [ ] |
|
||||
|
||||
### API Integration Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| Valid Image | Upload clear plant photo | Results with >50% confidence | [ ] |
|
||||
| Multiple Organs | Specify leaf + flower | Improved accuracy vs single | [ ] |
|
||||
| Non-Plant Image | Upload random image | Low confidence or "not a plant" | [ ] |
|
||||
| Large Image | Upload 4000x3000 image | Resized and uploaded successfully | [ ] |
|
||||
| HEIC Image | Use iPhone camera (HEIC) | Converted to JPEG, uploaded | [ ] |
|
||||
| Rate Limit Header | Check response | remainingIdentificationRequests present | [ ] |
|
||||
| Project Parameter | Use different projects | Results reflect flora scope | [ ] |
|
||||
|
||||
### Hybrid Strategy Verification
|
||||
|
||||
| Strategy | Scenario | Expected Behavior | Status |
|
||||
|----------|----------|-------------------|--------|
|
||||
| onDeviceOnly | Any image | Only on-device result returned | [ ] |
|
||||
| apiOnly | Any image | Only API result returned | [ ] |
|
||||
| onDeviceFirst | High confidence (>70%) | On-device result used, no API call | [ ] |
|
||||
| onDeviceFirst | Low confidence (<70%) | API called for confirmation | [ ] |
|
||||
| onDeviceFirst | Offline | On-device result used, no error | [ ] |
|
||||
| parallel | Online | Both results returned, API preferred | [ ] |
|
||||
| parallel | Offline | On-device result returned | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 Completion Checklist
|
||||
|
||||
- [x] All 10 tasks completed (core implementation)
|
||||
- [ ] All functional tests pass (requires runtime verification)
|
||||
- [x] All code quality checks pass
|
||||
- [ ] All performance targets met (requires runtime verification)
|
||||
- [ ] API integration verified with real requests (requires runtime verification)
|
||||
- [x] Hybrid strategies working correctly (code complete)
|
||||
- [x] Rate limiting tracked and enforced (code complete)
|
||||
- [x] Cache reduces redundant API calls (code complete)
|
||||
- [x] Offline mode works seamlessly (code complete)
|
||||
- [x] API key secured (not in git)
|
||||
- [ ] Unit tests for DTOs, mappers, and use cases
|
||||
- [ ] Code committed with descriptive message
|
||||
- [x] Ready for Phase 4 (Trefle API & Plant Care)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API-Specific Errors
|
||||
```swift
|
||||
enum PlantNetAPIError: Error, LocalizedError {
|
||||
case invalidAPIKey
|
||||
case rateLimitExceeded(resetDate: Date)
|
||||
case imageUploadFailed
|
||||
case invalidImageFormat
|
||||
case imageTooLarge(maxSize: Int)
|
||||
case serverError(statusCode: Int)
|
||||
case networkUnavailable
|
||||
case timeout
|
||||
case invalidResponse
|
||||
case noResultsFound
|
||||
case projectNotFound(project: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAPIKey:
|
||||
return "Invalid API key. Please check configuration."
|
||||
case .rateLimitExceeded(let resetDate):
|
||||
return "Daily limit reached. Resets \(resetDate.formatted())."
|
||||
case .imageUploadFailed:
|
||||
return "Failed to upload image. Please try again."
|
||||
case .invalidImageFormat:
|
||||
return "Image format not supported."
|
||||
case .imageTooLarge(let maxSize):
|
||||
return "Image too large. Maximum size: \(maxSize / 1_000_000)MB."
|
||||
case .serverError(let code):
|
||||
return "Server error (\(code)). Please try again later."
|
||||
case .networkUnavailable:
|
||||
return "No network connection. Using offline identification."
|
||||
case .timeout:
|
||||
return "Request timed out. Please try again."
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server."
|
||||
case .noResultsFound:
|
||||
return "No plant species identified."
|
||||
case .projectNotFound(let project):
|
||||
return "Plant database '\(project)' not available."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hybrid Identification Errors
|
||||
```swift
|
||||
enum HybridIdentificationError: Error, LocalizedError {
|
||||
case bothSourcesFailed(onDevice: Error, api: Error)
|
||||
case configurationError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .bothSourcesFailed:
|
||||
return "Unable to identify plant. Please try again."
|
||||
case .configurationError:
|
||||
return "Identification service not configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- PlantNet API free tier: 500 requests/day - track carefully
|
||||
- API supports multiple images per request (future enhancement)
|
||||
- Organs parameter significantly improves accuracy - default to "auto"
|
||||
- API returns GBIF data for scientific validation
|
||||
- Consider caching based on perceptual hash (similar images → same result)
|
||||
- NetworkMonitor should be injected via environment for testability
|
||||
- Rate limit resets at midnight UTC, not local time
|
||||
- Hybrid parallel strategy uses TaskGroup for concurrent execution
|
||||
- Cache should survive app updates (use stable storage location)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Type | Notes |
|
||||
|------------|------|-------|
|
||||
| NetworkMonitor | NWPathMonitor | System framework, iOS 12+ |
|
||||
| PlantNet API | External API | 500 req/day free tier |
|
||||
| URLSession | System | Multipart upload support |
|
||||
| CryptoKit | System | For image hashing (SHA256) |
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| API key exposed | Use xcconfig, add to .gitignore |
|
||||
| Rate limit exceeded | Track usage, warn user, fall back to on-device |
|
||||
| API downtime | Hybrid mode ensures on-device always available |
|
||||
| Slow API response | Timeout at 30s, show loading state |
|
||||
| Large image upload | Compress/resize to <2MB before upload |
|
||||
| Cache grows too large | LRU eviction, max 100 entries |
|
||||
| Network flapping | Debounce network status changes |
|
||||
| API response changes | DTO tests catch breaking changes early |
|
||||
|
||||
---
|
||||
|
||||
## Sample API Response
|
||||
|
||||
```json
|
||||
{
|
||||
"query": {
|
||||
"project": "all",
|
||||
"images": ["image_1"],
|
||||
"organs": ["leaf"],
|
||||
"includeRelatedImages": false
|
||||
},
|
||||
"language": "en",
|
||||
"preferedReferential": "the-plant-list",
|
||||
"results": [
|
||||
{
|
||||
"score": 0.85432,
|
||||
"species": {
|
||||
"scientificNameWithoutAuthor": "Quercus robur",
|
||||
"scientificNameAuthorship": "L.",
|
||||
"scientificName": "Quercus robur L.",
|
||||
"genus": {
|
||||
"scientificNameWithoutAuthor": "Quercus",
|
||||
"scientificNameAuthorship": "",
|
||||
"scientificName": "Quercus"
|
||||
},
|
||||
"family": {
|
||||
"scientificNameWithoutAuthor": "Fagaceae",
|
||||
"scientificNameAuthorship": "",
|
||||
"scientificName": "Fagaceae"
|
||||
},
|
||||
"commonNames": ["English oak", "Pedunculate oak"]
|
||||
},
|
||||
"gbif": {
|
||||
"id": 2878688
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "2023-07-24",
|
||||
"remainingIdentificationRequests": 487
|
||||
}
|
||||
```
|
||||
953
Docs/Phase4_plan.md
Normal file
953
Docs/Phase4_plan.md
Normal file
@@ -0,0 +1,953 @@
|
||||
# Phase 4: Trefle API & Plant Care
|
||||
|
||||
**Goal:** Complete care information and scheduling with local notifications
|
||||
|
||||
**Prerequisites:** Phase 3 complete (hybrid identification working, API infrastructure established)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 4.1 Register for Trefle API Access
|
||||
- [ ] Navigate to [trefle.io](https://trefle.io)
|
||||
- [ ] Create developer account
|
||||
- [ ] Generate API token
|
||||
- [ ] Review API documentation and rate limits
|
||||
- [ ] Add `TREFLE_API_TOKEN` to `APIKeys.swift`:
|
||||
```swift
|
||||
enum APIKeys {
|
||||
// ... existing keys
|
||||
|
||||
static let trefleAPIToken: String = {
|
||||
guard let token = Bundle.main.object(forInfoDictionaryKey: "TREFLE_API_TOKEN") as? String else {
|
||||
fatalError("Trefle API token not configured")
|
||||
}
|
||||
return token
|
||||
}()
|
||||
}
|
||||
```
|
||||
- [ ] Add `TREFLE_API_TOKEN` to Info.plist via xcconfig
|
||||
- [ ] Update `.xcconfig` file with Trefle token (already in .gitignore)
|
||||
- [ ] Verify API access with test request
|
||||
|
||||
**Acceptance Criteria:** API token configured and accessible, test request returns valid data
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Create Trefle Endpoints
|
||||
- [ ] Create `Data/DataSources/Remote/TrefleAPI/TrefleEndpoints.swift`
|
||||
- [ ] Define endpoint configuration:
|
||||
```swift
|
||||
enum TrefleEndpoint: Endpoint {
|
||||
case searchPlants(query: String, page: Int)
|
||||
case getSpecies(slug: String)
|
||||
case getSpeciesById(id: Int)
|
||||
case getPlant(id: Int)
|
||||
|
||||
var baseURL: URL { URL(string: "https://trefle.io/api/v1")! }
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .searchPlants: return "/plants/search"
|
||||
case .getSpecies(let slug): return "/species/\(slug)"
|
||||
case .getSpeciesById(let id): return "/species/\(id)"
|
||||
case .getPlant(let id): return "/plants/\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
var method: HTTPMethod { .get }
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items = [URLQueryItem(name: "token", value: APIKeys.trefleAPIToken)]
|
||||
switch self {
|
||||
case .searchPlants(let query, let page):
|
||||
items.append(URLQueryItem(name: "q", value: query))
|
||||
items.append(URLQueryItem(name: "page", value: String(page)))
|
||||
default:
|
||||
break
|
||||
}
|
||||
return items
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Support pagination for search results
|
||||
- [ ] Add filter parameters (edible, vegetable, etc.)
|
||||
|
||||
**Acceptance Criteria:** Endpoints build correct URLs with token and query parameters
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Implement Trefle API Service
|
||||
- [ ] Create `Data/DataSources/Remote/TrefleAPI/TrefleAPIService.swift`
|
||||
- [ ] Define protocol:
|
||||
```swift
|
||||
protocol TrefleAPIServiceProtocol: Sendable {
|
||||
func searchPlants(query: String, page: Int) async throws -> TrefleSearchResponseDTO
|
||||
func getSpecies(slug: String) async throws -> TrefleSpeciesResponseDTO
|
||||
func getSpeciesById(id: Int) async throws -> TrefleSpeciesResponseDTO
|
||||
}
|
||||
```
|
||||
- [ ] Implement service using NetworkService:
|
||||
- Handle token-based authentication
|
||||
- Parse paginated responses
|
||||
- Handle 404 for unknown species
|
||||
- [ ] Implement retry logic (1 retry with exponential backoff)
|
||||
- [ ] Add request timeout (15 seconds)
|
||||
- [ ] Handle rate limiting (120 requests/minute)
|
||||
- [ ] Log request/response for debugging
|
||||
|
||||
**Acceptance Criteria:** Service retrieves species data and handles errors gracefully
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Create Trefle DTOs
|
||||
- [ ] Create `Data/DataSources/Remote/TrefleAPI/DTOs/TrefleDTOs.swift`
|
||||
- [ ] Define response DTOs:
|
||||
```swift
|
||||
struct TrefleSearchResponseDTO: Decodable {
|
||||
let data: [TreflePlantSummaryDTO]
|
||||
let links: TrefleLinksDTO
|
||||
let meta: TrefleMetaDTO
|
||||
}
|
||||
|
||||
struct TrefleSpeciesResponseDTO: Decodable {
|
||||
let data: TrefleSpeciesDTO
|
||||
let meta: TrefleMetaDTO
|
||||
}
|
||||
```
|
||||
- [ ] Create `TrefleSpeciesDTO`:
|
||||
```swift
|
||||
struct TrefleSpeciesDTO: Decodable {
|
||||
let id: Int
|
||||
let commonName: String?
|
||||
let slug: String
|
||||
let scientificName: String
|
||||
let year: Int?
|
||||
let bibliography: String?
|
||||
let author: String?
|
||||
let familyCommonName: String?
|
||||
let family: String?
|
||||
let genus: String?
|
||||
let genusId: Int?
|
||||
let imageUrl: String?
|
||||
let images: TrefleImagesDTO?
|
||||
let distribution: TrefleDistributionDTO?
|
||||
let specifications: TrefleSpecificationsDTO?
|
||||
let growth: TrefleGrowthDTO?
|
||||
let synonyms: [TrefleSynonymDTO]?
|
||||
let sources: [TrefleSourceDTO]?
|
||||
}
|
||||
```
|
||||
- [ ] Create `TrefleGrowthDTO`:
|
||||
```swift
|
||||
struct TrefleGrowthDTO: Decodable {
|
||||
let description: String?
|
||||
let sowing: String?
|
||||
let daysToHarvest: Int?
|
||||
let rowSpacing: TrefleMeasurementDTO?
|
||||
let spread: TrefleMeasurementDTO?
|
||||
let phMaximum: Double?
|
||||
let phMinimum: Double?
|
||||
let light: Int? // 0-10 scale
|
||||
let atmosphericHumidity: Int? // 0-10 scale
|
||||
let growthMonths: [String]?
|
||||
let bloomMonths: [String]?
|
||||
let fruitMonths: [String]?
|
||||
let minimumPrecipitation: TrefleMeasurementDTO?
|
||||
let maximumPrecipitation: TrefleMeasurementDTO?
|
||||
let minimumRootDepth: TrefleMeasurementDTO?
|
||||
let minimumTemperature: TrefleMeasurementDTO?
|
||||
let maximumTemperature: TrefleMeasurementDTO?
|
||||
let soilNutriments: Int? // 0-10 scale
|
||||
let soilSalinity: Int? // 0-10 scale
|
||||
let soilTexture: Int? // 0-10 scale
|
||||
let soilHumidity: Int? // 0-10 scale
|
||||
}
|
||||
```
|
||||
- [ ] Create supporting DTOs: `TrefleSpecificationsDTO`, `TrefleImagesDTO`, `TrefleMeasurementDTO`
|
||||
- [ ] Add CodingKeys for snake_case API responses
|
||||
- [ ] Write unit tests for DTO decoding
|
||||
|
||||
**Acceptance Criteria:** DTOs decode actual Trefle API responses without errors
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Build Trefle Mapper
|
||||
- [ ] Create `Data/Mappers/TrefleMapper.swift`
|
||||
- [ ] Implement mapping functions:
|
||||
```swift
|
||||
struct TrefleMapper {
|
||||
static func mapToPlantCareSchedule(
|
||||
from species: TrefleSpeciesDTO,
|
||||
plantID: UUID
|
||||
) -> PlantCareSchedule
|
||||
|
||||
static func mapToLightRequirement(
|
||||
from light: Int?
|
||||
) -> LightRequirement
|
||||
|
||||
static func mapToWateringSchedule(
|
||||
from growth: TrefleGrowthDTO?
|
||||
) -> WateringSchedule
|
||||
|
||||
static func mapToTemperatureRange(
|
||||
from growth: TrefleGrowthDTO?
|
||||
) -> TemperatureRange
|
||||
|
||||
static func mapToFertilizerSchedule(
|
||||
from growth: TrefleGrowthDTO?
|
||||
) -> FertilizerSchedule?
|
||||
|
||||
static func generateCareTasks(
|
||||
from schedule: PlantCareSchedule,
|
||||
startDate: Date
|
||||
) -> [CareTask]
|
||||
}
|
||||
```
|
||||
- [ ] Map Trefle light scale (0-10) to `LightRequirement`:
|
||||
```swift
|
||||
enum LightRequirement: String, Codable, Sendable {
|
||||
case fullShade // 0-2
|
||||
case partialShade // 3-4
|
||||
case partialSun // 5-6
|
||||
case fullSun // 7-10
|
||||
|
||||
var description: String { ... }
|
||||
var hoursOfLight: ClosedRange<Int> { ... }
|
||||
}
|
||||
```
|
||||
- [ ] Map humidity/precipitation to `WateringSchedule`:
|
||||
```swift
|
||||
struct WateringSchedule: Codable, Sendable {
|
||||
let frequency: WateringFrequency
|
||||
let amount: WateringAmount
|
||||
let seasonalAdjustments: [Season: WateringFrequency]?
|
||||
|
||||
enum WateringFrequency: String, Codable, Sendable {
|
||||
case daily, everyOtherDay, twiceWeekly, weekly, biweekly, monthly
|
||||
|
||||
var intervalDays: Int { ... }
|
||||
}
|
||||
|
||||
enum WateringAmount: String, Codable, Sendable {
|
||||
case light, moderate, thorough, soak
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Map temperature data to `TemperatureRange`:
|
||||
```swift
|
||||
struct TemperatureRange: Codable, Sendable {
|
||||
let minimum: Measurement<UnitTemperature>
|
||||
let maximum: Measurement<UnitTemperature>
|
||||
let optimal: Measurement<UnitTemperature>?
|
||||
let frostTolerant: Bool
|
||||
}
|
||||
```
|
||||
- [ ] Map soil nutrients to `FertilizerSchedule`:
|
||||
```swift
|
||||
struct FertilizerSchedule: Codable, Sendable {
|
||||
let frequency: FertilizerFrequency
|
||||
let type: FertilizerType
|
||||
let seasonalApplication: Bool
|
||||
let activeMonths: [Int]? // 1-12
|
||||
|
||||
enum FertilizerFrequency: String, Codable, Sendable {
|
||||
case weekly, biweekly, monthly, quarterly, biannually
|
||||
}
|
||||
|
||||
enum FertilizerType: String, Codable, Sendable {
|
||||
case balanced, highNitrogen, highPhosphorus, highPotassium, organic
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Handle missing data with sensible defaults
|
||||
- [ ] Unit test all mapping functions
|
||||
|
||||
**Acceptance Criteria:** Mapper produces valid care schedules from all Trefle response variations
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Implement Fetch Plant Care Use Case
|
||||
- [ ] Create `Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift`
|
||||
- [ ] Define protocol:
|
||||
```swift
|
||||
protocol FetchPlantCareUseCaseProtocol: Sendable {
|
||||
func execute(scientificName: String) async throws -> PlantCareInfo
|
||||
func execute(trefleId: Int) async throws -> PlantCareInfo
|
||||
}
|
||||
```
|
||||
- [ ] Define `PlantCareInfo` domain entity:
|
||||
```swift
|
||||
struct PlantCareInfo: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let scientificName: String
|
||||
let commonName: String?
|
||||
let lightRequirement: LightRequirement
|
||||
let wateringSchedule: WateringSchedule
|
||||
let temperatureRange: TemperatureRange
|
||||
let fertilizerSchedule: FertilizerSchedule?
|
||||
let soilType: SoilType?
|
||||
let humidity: HumidityLevel?
|
||||
let growthRate: GrowthRate?
|
||||
let bloomingSeason: [Season]?
|
||||
let additionalNotes: String?
|
||||
let sourceURL: URL?
|
||||
}
|
||||
```
|
||||
- [ ] Implement use case:
|
||||
- Search Trefle by scientific name
|
||||
- Fetch detailed species data
|
||||
- Map to domain entity
|
||||
- Cache results for offline access
|
||||
- [ ] Handle species not found in Trefle
|
||||
- [ ] Add fallback to generic care data for unknown species
|
||||
- [ ] Register in DIContainer
|
||||
|
||||
**Acceptance Criteria:** Use case retrieves care data, handles missing species gracefully
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Create Care Schedule Use Case
|
||||
- [ ] Create `Domain/UseCases/PlantCare/CreateCareScheduleUseCase.swift`
|
||||
- [ ] Define protocol:
|
||||
```swift
|
||||
protocol CreateCareScheduleUseCaseProtocol: Sendable {
|
||||
func execute(
|
||||
for plant: Plant,
|
||||
careInfo: PlantCareInfo,
|
||||
userPreferences: CarePreferences?
|
||||
) async throws -> PlantCareSchedule
|
||||
}
|
||||
```
|
||||
- [ ] Define `CarePreferences`:
|
||||
```swift
|
||||
struct CarePreferences: Codable, Sendable {
|
||||
let preferredWateringTime: DateComponents // e.g., 8:00 AM
|
||||
let reminderDaysBefore: Int // remind N days before task
|
||||
let groupWateringDays: Bool // water all plants same day
|
||||
let adjustForSeason: Bool
|
||||
let location: PlantLocation?
|
||||
|
||||
enum PlantLocation: String, Codable, Sendable {
|
||||
case indoor, outdoor, greenhouse, balcony
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Implement schedule generation:
|
||||
- Calculate next N watering dates (30 days ahead)
|
||||
- Calculate fertilizer dates based on schedule
|
||||
- Adjust for seasons if enabled
|
||||
- Create `CareTask` entities for each scheduled item
|
||||
- [ ] Define `CareTask` entity:
|
||||
```swift
|
||||
struct CareTask: Identifiable, Codable, Sendable {
|
||||
let id: UUID
|
||||
let plantID: UUID
|
||||
let type: CareTaskType
|
||||
let scheduledDate: Date
|
||||
let isCompleted: Bool
|
||||
let completedDate: Date?
|
||||
let notes: String?
|
||||
|
||||
enum CareTaskType: String, Codable, Sendable {
|
||||
case watering, fertilizing, pruning, repotting, pestControl, rotation
|
||||
|
||||
var icon: String { ... }
|
||||
var defaultReminderOffset: TimeInterval { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Persist schedule to Core Data
|
||||
- [ ] Register in DIContainer
|
||||
|
||||
**Acceptance Criteria:** Use case creates complete care schedule with future tasks
|
||||
|
||||
---
|
||||
|
||||
### 4.8 Build Plant Detail View
|
||||
- [ ] Create `Presentation/Scenes/PlantDetail/PlantDetailView.swift`
|
||||
- [ ] Create `PlantDetailViewModel`:
|
||||
```swift
|
||||
@Observable
|
||||
final class PlantDetailViewModel {
|
||||
private(set) var plant: Plant
|
||||
private(set) var careInfo: PlantCareInfo?
|
||||
private(set) var careSchedule: PlantCareSchedule?
|
||||
private(set) var isLoading: Bool = false
|
||||
private(set) var error: Error?
|
||||
|
||||
func loadCareInfo() async
|
||||
func createSchedule(preferences: CarePreferences?) async
|
||||
func markTaskComplete(_ task: CareTask) async
|
||||
}
|
||||
```
|
||||
- [ ] Implement view sections:
|
||||
```swift
|
||||
struct PlantDetailView: View {
|
||||
@State private var viewModel: PlantDetailViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
PlantHeaderSection(plant: viewModel.plant)
|
||||
IdentificationSection(plant: viewModel.plant)
|
||||
CareInformationSection(careInfo: viewModel.careInfo)
|
||||
UpcomingTasksSection(tasks: viewModel.upcomingTasks)
|
||||
CareScheduleSection(schedule: viewModel.careSchedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Create `CareInformationSection` component:
|
||||
```swift
|
||||
struct CareInformationSection: View {
|
||||
let careInfo: PlantCareInfo?
|
||||
|
||||
var body: some View {
|
||||
Section("Care Requirements") {
|
||||
LightRequirementRow(requirement: careInfo?.lightRequirement)
|
||||
WateringRow(schedule: careInfo?.wateringSchedule)
|
||||
TemperatureRow(range: careInfo?.temperatureRange)
|
||||
FertilizerRow(schedule: careInfo?.fertilizerSchedule)
|
||||
HumidityRow(level: careInfo?.humidity)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Create care info row components:
|
||||
- `LightRequirementRow` - sun icon, description, hours
|
||||
- `WateringRow` - drop icon, frequency, amount
|
||||
- `TemperatureRow` - thermometer, min/max/optimal
|
||||
- `FertilizerRow` - leaf icon, frequency, type
|
||||
- `HumidityRow` - humidity icon, level indicator
|
||||
- [ ] Add loading skeleton for care info
|
||||
- [ ] Handle "care data unavailable" state
|
||||
- [ ] Implement pull-to-refresh
|
||||
|
||||
**Acceptance Criteria:** Detail view displays all plant info with care requirements
|
||||
|
||||
---
|
||||
|
||||
### 4.9 Implement Care Schedule View
|
||||
- [ ] Create `Presentation/Scenes/CareSchedule/CareScheduleView.swift`
|
||||
- [ ] Create `CareScheduleViewModel`:
|
||||
```swift
|
||||
@Observable
|
||||
final class CareScheduleViewModel {
|
||||
private(set) var upcomingTasks: [CareTask] = []
|
||||
private(set) var tasksByDate: [Date: [CareTask]] = [:]
|
||||
private(set) var plants: [Plant] = []
|
||||
var selectedFilter: TaskFilter = .all
|
||||
|
||||
enum TaskFilter: CaseIterable {
|
||||
case all, watering, fertilizing, overdue, today
|
||||
}
|
||||
|
||||
func loadTasks() async
|
||||
func markComplete(_ task: CareTask) async
|
||||
func snoozeTask(_ task: CareTask, until: Date) async
|
||||
func skipTask(_ task: CareTask) async
|
||||
}
|
||||
```
|
||||
- [ ] Implement main schedule view:
|
||||
```swift
|
||||
struct CareScheduleView: View {
|
||||
@State private var viewModel: CareScheduleViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
OverdueTasksSection(tasks: viewModel.overdueTasks)
|
||||
TodayTasksSection(tasks: viewModel.todayTasks)
|
||||
UpcomingTasksSection(tasksByDate: viewModel.upcomingByDate)
|
||||
}
|
||||
.navigationTitle("Care Schedule")
|
||||
.toolbar {
|
||||
FilterMenu(selection: $viewModel.selectedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Create `CareTaskRow` component:
|
||||
```swift
|
||||
struct CareTaskRow: View {
|
||||
let task: CareTask
|
||||
let plant: Plant
|
||||
let onComplete: () -> Void
|
||||
let onSnooze: (Date) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
PlantThumbnail(plant: plant)
|
||||
VStack(alignment: .leading) {
|
||||
Text(plant.commonNames.first ?? plant.scientificName)
|
||||
Text(task.type.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
TaskActionButtons(...)
|
||||
}
|
||||
.swipeActions { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Implement calendar view option:
|
||||
```swift
|
||||
struct CareCalendarView: View {
|
||||
let tasksByDate: [Date: [CareTask]]
|
||||
@Binding var selectedDate: Date
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
CalendarGrid(tasksByDate: tasksByDate, selection: $selectedDate)
|
||||
TaskListForDate(tasks: tasksByDate[selectedDate] ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Add empty state for "no tasks scheduled"
|
||||
- [ ] Implement batch actions (complete all today's watering)
|
||||
- [ ] Add quick-add task functionality
|
||||
|
||||
**Acceptance Criteria:** Schedule view shows all upcoming tasks, supports filtering and completion
|
||||
|
||||
---
|
||||
|
||||
### 4.10 Add Local Notifications for Care Reminders
|
||||
- [ ] Create `Core/Services/NotificationService.swift`
|
||||
- [ ] Define protocol:
|
||||
```swift
|
||||
protocol NotificationServiceProtocol: Sendable {
|
||||
func requestAuthorization() async throws -> Bool
|
||||
func scheduleReminder(for task: CareTask, plant: Plant) async throws
|
||||
func cancelReminder(for task: CareTask) async
|
||||
func cancelAllReminders(for plantID: UUID) async
|
||||
func updateBadgeCount() async
|
||||
func getPendingNotifications() async -> [UNNotificationRequest]
|
||||
}
|
||||
```
|
||||
- [ ] Implement notification service:
|
||||
```swift
|
||||
final class NotificationService: NotificationServiceProtocol {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func scheduleReminder(for task: CareTask, plant: Plant) async throws {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Plant Care Reminder"
|
||||
content.body = "\(plant.commonNames.first ?? plant.scientificName) needs \(task.type.rawValue)"
|
||||
content.sound = .default
|
||||
content.badge = await calculateBadgeCount() as NSNumber
|
||||
content.userInfo = [
|
||||
"taskID": task.id.uuidString,
|
||||
"plantID": plant.id.uuidString,
|
||||
"taskType": task.type.rawValue
|
||||
]
|
||||
content.categoryIdentifier = "CARE_REMINDER"
|
||||
|
||||
let trigger = UNCalendarNotificationTrigger(
|
||||
dateMatching: Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute],
|
||||
from: task.scheduledDate
|
||||
),
|
||||
repeats: false
|
||||
)
|
||||
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "care-\(task.id.uuidString)",
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
try await center.add(request)
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Set up notification categories and actions:
|
||||
```swift
|
||||
func setupNotificationCategories() {
|
||||
let completeAction = UNNotificationAction(
|
||||
identifier: "COMPLETE",
|
||||
title: "Mark Complete",
|
||||
options: .foreground
|
||||
)
|
||||
|
||||
let snoozeAction = UNNotificationAction(
|
||||
identifier: "SNOOZE",
|
||||
title: "Snooze 1 Hour",
|
||||
options: []
|
||||
)
|
||||
|
||||
let category = UNNotificationCategory(
|
||||
identifier: "CARE_REMINDER",
|
||||
actions: [completeAction, snoozeAction],
|
||||
intentIdentifiers: [],
|
||||
options: .customDismissAction
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||
}
|
||||
```
|
||||
- [ ] Handle notification responses in app delegate/scene delegate
|
||||
- [ ] Create `ScheduleNotificationsUseCase`:
|
||||
```swift
|
||||
protocol ScheduleNotificationsUseCaseProtocol: Sendable {
|
||||
func scheduleAll(for schedule: PlantCareSchedule, plant: Plant) async throws
|
||||
func rescheduleAll() async throws // Call after task completion
|
||||
func syncWithSystem() async // Verify scheduled vs expected
|
||||
}
|
||||
```
|
||||
- [ ] Add notification settings UI:
|
||||
- Enable/disable reminders
|
||||
- Set default reminder time
|
||||
- Set advance notice period
|
||||
- Sound selection
|
||||
- [ ] Handle notification permission denied gracefully
|
||||
- [ ] Register in DIContainer
|
||||
|
||||
**Acceptance Criteria:** Notifications fire at scheduled times with actionable buttons
|
||||
|
||||
---
|
||||
|
||||
## End-of-Phase Validation
|
||||
|
||||
### Functional Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| API Token Configured | Build app | No crash on Trefle token access | [ ] |
|
||||
| Plant Search | Search "Monstera" | Returns matching species | [ ] |
|
||||
| Species Detail | Fetch species by slug | Returns complete growth data | [ ] |
|
||||
| Care Info Display | View identified plant | Care requirements shown | [ ] |
|
||||
| Schedule Creation | Add plant to collection | Care schedule generated | [ ] |
|
||||
| Task List | Open care schedule tab | Upcoming tasks displayed | [ ] |
|
||||
| Task Completion | Tap complete on task | Task marked done, removed from list | [ ] |
|
||||
| Task Snooze | Snooze task 1 hour | Task rescheduled, notification updated | [ ] |
|
||||
| Notification Permission | First launch | Permission dialog shown | [ ] |
|
||||
| Notification Delivery | Wait for scheduled time | Notification appears | [ ] |
|
||||
| Notification Action | Tap "Mark Complete" | App opens, task completed | [ ] |
|
||||
| Offline Care Data | Disable network | Cached care info displayed | [ ] |
|
||||
| Unknown Species | Search non-existent plant | Graceful "not found" message | [ ] |
|
||||
| Calendar View | Switch to calendar | Tasks shown on correct dates | [ ] |
|
||||
| Filter Tasks | Filter by "watering" | Only watering tasks shown | [ ] |
|
||||
|
||||
### Code Quality Verification
|
||||
|
||||
| Check | Criteria | Status |
|
||||
|-------|----------|--------|
|
||||
| Build | Project builds with zero warnings | [ ] |
|
||||
| Architecture | Trefle code isolated in Data/DataSources/Remote/TrefleAPI/ | [ ] |
|
||||
| Protocols | All services use protocols for testability | [ ] |
|
||||
| Sendable | All new types conform to Sendable | [ ] |
|
||||
| DTOs | DTOs decode sample Trefle responses correctly | [ ] |
|
||||
| Mapper | Mapper handles all optional fields with defaults | [ ] |
|
||||
| Use Cases | Business logic in use cases, not ViewModels | [ ] |
|
||||
| DI Container | New services registered in container | [ ] |
|
||||
| Error Types | Trefle-specific errors defined | [ ] |
|
||||
| Unit Tests | DTOs, mappers, and use cases have tests | [ ] |
|
||||
| Secrets | API token not in source control | [ ] |
|
||||
| Notifications | Permission handling follows Apple guidelines | [ ] |
|
||||
|
||||
### Performance Verification
|
||||
|
||||
| Metric | Target | Actual | Status |
|
||||
|--------|--------|--------|--------|
|
||||
| Trefle Search Response | < 2 seconds | | [ ] |
|
||||
| Species Detail Fetch | < 3 seconds | | [ ] |
|
||||
| Care Schedule Generation | < 100ms | | [ ] |
|
||||
| Plant Detail View Load | < 500ms | | [ ] |
|
||||
| Care Schedule View Load | < 300ms | | [ ] |
|
||||
| Notification Scheduling (batch) | < 1 second for 10 tasks | | [ ] |
|
||||
| Care Info Cache Lookup | < 50ms | | [ ] |
|
||||
| Calendar View Render | < 200ms | | [ ] |
|
||||
|
||||
### API Integration Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| Valid Species | Search "Quercus robur" | Returns oak species data | [ ] |
|
||||
| Growth Data Present | Fetch species with growth | Light, water, temp data present | [ ] |
|
||||
| Growth Data Missing | Fetch species without growth | Defaults used, no crash | [ ] |
|
||||
| Pagination | Search common term | Multiple pages available | [ ] |
|
||||
| Rate Limiting | Make rapid requests | 429 handled gracefully | [ ] |
|
||||
| Invalid Token | Use wrong token | Unauthorized error shown | [ ] |
|
||||
| Species Not Found | Search gibberish | Empty results, no error | [ ] |
|
||||
| Image URLs | Fetch species | Valid image URLs returned | [ ] |
|
||||
|
||||
### Care Schedule Verification
|
||||
|
||||
| Scenario | Input | Expected Output | Status |
|
||||
|----------|-------|-----------------|--------|
|
||||
| Daily Watering | High humidity plant | Tasks every day | [ ] |
|
||||
| Weekly Watering | Low humidity plant | Tasks every 7 days | [ ] |
|
||||
| Monthly Fertilizer | High nutrient need | Tasks every 30 days | [ ] |
|
||||
| No Fertilizer | Low nutrient need | No fertilizer tasks | [ ] |
|
||||
| Seasonal Adjustment | Outdoor plant in winter | Reduced watering frequency | [ ] |
|
||||
| User Preferred Time | Set 9:00 AM | All tasks at 9:00 AM | [ ] |
|
||||
| 30-Day Lookahead | Create schedule | Tasks for next 30 days | [ ] |
|
||||
| Task Completion | Complete watering | Next occurrence scheduled | [ ] |
|
||||
| Plant Deletion | Delete plant | All tasks removed | [ ] |
|
||||
|
||||
### Notification Verification
|
||||
|
||||
| Test | Steps | Expected Result | Status |
|
||||
|------|-------|-----------------|--------|
|
||||
| Permission Granted | Accept notification prompt | Reminders scheduled | [ ] |
|
||||
| Permission Denied | Deny notification prompt | Graceful fallback, in-app alerts | [ ] |
|
||||
| Notification Content | Receive notification | Correct plant name and task type | [ ] |
|
||||
| Complete Action | Tap "Mark Complete" | Task completed in app | [ ] |
|
||||
| Snooze Action | Tap "Snooze" | Notification rescheduled | [ ] |
|
||||
| Badge Count | Have 3 overdue tasks | Badge shows 3 | [ ] |
|
||||
| Badge Clear | Complete all tasks | Badge cleared | [ ] |
|
||||
| Background Delivery | App closed | Notification still fires | [ ] |
|
||||
| Notification Tap | Tap notification | Opens plant detail | [ ] |
|
||||
| Bulk Reschedule | Complete task | Future notifications updated | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 Completion Checklist
|
||||
|
||||
- [ ] All 10 tasks completed (core implementation)
|
||||
- [ ] All functional tests pass
|
||||
- [ ] All code quality checks pass
|
||||
- [ ] All performance targets met
|
||||
- [ ] Trefle API integration verified
|
||||
- [ ] Care schedule generation working
|
||||
- [ ] Task management (complete/snooze/skip) working
|
||||
- [ ] Notifications scheduling and firing correctly
|
||||
- [ ] Notification actions handled properly
|
||||
- [ ] Offline mode works (cached care data)
|
||||
- [ ] API token secured (not in git)
|
||||
- [ ] Unit tests for DTOs, mappers, and use cases
|
||||
- [ ] UI tests for critical flows (view plant, complete task)
|
||||
- [ ] Code committed with descriptive message
|
||||
- [ ] Ready for Phase 5 (Plant Collection & Persistence)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Trefle API Errors
|
||||
```swift
|
||||
enum TrefleAPIError: Error, LocalizedError {
|
||||
case invalidToken
|
||||
case rateLimitExceeded
|
||||
case speciesNotFound(query: String)
|
||||
case serverError(statusCode: Int)
|
||||
case networkUnavailable
|
||||
case timeout
|
||||
case invalidResponse
|
||||
case paginationExhausted
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidToken:
|
||||
return "Invalid API token. Please check configuration."
|
||||
case .rateLimitExceeded:
|
||||
return "Too many requests. Please wait a moment."
|
||||
case .speciesNotFound(let query):
|
||||
return "No species found matching '\(query)'."
|
||||
case .serverError(let code):
|
||||
return "Server error (\(code)). Please try again later."
|
||||
case .networkUnavailable:
|
||||
return "No network connection."
|
||||
case .timeout:
|
||||
return "Request timed out. Please try again."
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server."
|
||||
case .paginationExhausted:
|
||||
return "No more results available."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Care Schedule Errors
|
||||
```swift
|
||||
enum CareScheduleError: Error, LocalizedError {
|
||||
case noCareDataAvailable
|
||||
case schedulePersistenceFailed
|
||||
case invalidDateRange
|
||||
case plantNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCareDataAvailable:
|
||||
return "Care information not available for this plant."
|
||||
case .schedulePersistenceFailed:
|
||||
return "Failed to save care schedule."
|
||||
case .invalidDateRange:
|
||||
return "Invalid date range for schedule."
|
||||
case .plantNotFound:
|
||||
return "Plant not found in collection."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Notification Errors
|
||||
```swift
|
||||
enum NotificationError: Error, LocalizedError {
|
||||
case permissionDenied
|
||||
case schedulingFailed
|
||||
case invalidTriggerDate
|
||||
case categoryNotRegistered
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return "Notification permission denied. Enable in Settings."
|
||||
case .schedulingFailed:
|
||||
return "Failed to schedule reminder."
|
||||
case .invalidTriggerDate:
|
||||
return "Cannot schedule reminder for past date."
|
||||
case .categoryNotRegistered:
|
||||
return "Notification category not configured."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Trefle API has growth data for ~10% of species; implement graceful fallbacks
|
||||
- Cache Trefle responses aggressively (data rarely changes)
|
||||
- Notification limit: iOS allows ~64 pending local notifications
|
||||
- Schedule notifications in batches to stay under limit
|
||||
- Use background app refresh to reschedule notifications periodically
|
||||
- Consider user's timezone for notification scheduling
|
||||
- Trefle measurement units vary; normalize to metric internally, display in user's preference
|
||||
- Some plants need seasonal care adjustments (reduce watering in winter)
|
||||
- Badge count should only reflect overdue tasks, not all pending
|
||||
- Test notification actions with app in foreground, background, and terminated states
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Type | Notes |
|
||||
|------------|------|-------|
|
||||
| Trefle API | External API | 120 req/min rate limit |
|
||||
| UserNotifications | System | Local notifications |
|
||||
| URLSession | System | API requests |
|
||||
| Core Data | System | Schedule persistence |
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Trefle API token exposed | Use xcconfig, add to .gitignore |
|
||||
| Species not in Trefle | Provide generic care defaults |
|
||||
| Missing growth data | Use conservative defaults for watering/light |
|
||||
| Notification permission denied | In-app task list always available |
|
||||
| Too many notifications | Limit to 64, prioritize soonest tasks |
|
||||
| User ignores reminders | Badge count, overdue section in UI |
|
||||
| Trefle API downtime | Cache responses, retry with backoff |
|
||||
| Incorrect care recommendations | Add disclaimer, allow user overrides |
|
||||
| Timezone issues | Store all dates in UTC, convert for display |
|
||||
| App deleted with pending notifications | Notifications orphaned (OS handles cleanup) |
|
||||
|
||||
---
|
||||
|
||||
## Sample Trefle API Response
|
||||
|
||||
### Search Response
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 834,
|
||||
"common_name": "Swiss cheese plant",
|
||||
"slug": "monstera-deliciosa",
|
||||
"scientific_name": "Monstera deliciosa",
|
||||
"year": 1849,
|
||||
"bibliography": "Vidensk. Meddel. Naturhist. Foren. Kjøbenhavn 1849: 19 (1849)",
|
||||
"author": "Liebm.",
|
||||
"family_common_name": "Arum family",
|
||||
"genus_id": 1254,
|
||||
"image_url": "https://bs.plantnet.org/image/o/abc123",
|
||||
"genus": "Monstera",
|
||||
"family": "Araceae"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/plants/search?q=monstera",
|
||||
"first": "/api/v1/plants/search?page=1&q=monstera",
|
||||
"last": "/api/v1/plants/search?page=1&q=monstera"
|
||||
},
|
||||
"meta": {
|
||||
"total": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Species Detail Response
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 834,
|
||||
"common_name": "Swiss cheese plant",
|
||||
"slug": "monstera-deliciosa",
|
||||
"scientific_name": "Monstera deliciosa",
|
||||
"growth": {
|
||||
"light": 6,
|
||||
"atmospheric_humidity": 8,
|
||||
"minimum_temperature": {
|
||||
"deg_c": 15
|
||||
},
|
||||
"maximum_temperature": {
|
||||
"deg_c": 30
|
||||
},
|
||||
"soil_humidity": 7,
|
||||
"soil_nutriments": 5
|
||||
},
|
||||
"specifications": {
|
||||
"growth_rate": "moderate",
|
||||
"toxicity": "mild"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"last_modified": "2023-01-15T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Mockups (Conceptual)
|
||||
|
||||
### Plant Detail - Care Section
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ ☀️ Light: Partial Sun (5-6 hrs) │
|
||||
│ 💧 Water: Twice Weekly (Moderate) │
|
||||
│ 🌡️ Temp: 15-30°C (Optimal: 22°C) │
|
||||
│ 🌱 Fertilizer: Monthly (Balanced) │
|
||||
│ 💨 Humidity: High │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Care Schedule - Task List
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ OVERDUE (2) │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🪴 Monstera 💧 Water [✓] │ │
|
||||
│ │ 🪴 Pothos 💧 Water [✓] │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ TODAY │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🪴 Ficus 🌱 Fertilize [✓]│ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ TOMORROW │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🪴 Snake Plant 💧 Water [○] │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
1350
Docs/Phase5_plan.md
Normal file
1350
Docs/Phase5_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
2032
Docs/Phase6_plan.md
Normal file
2032
Docs/Phase6_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
15
Docs/ServerDownload.md
Normal file
15
Docs/ServerDownload.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 1. Extract
|
||||
unzip PlantGuide-Server.zip
|
||||
cd PlantGuide-Server
|
||||
|
||||
# 2. Install Python deps
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
# 3. Start downloading ALL 2,278 plants (runs in background)
|
||||
./start_downloads.sh --all
|
||||
|
||||
# 4. Check progress anytime
|
||||
./status.sh
|
||||
|
||||
# 5. Stop if needed
|
||||
./stop_downloads.sh
|
||||
185
Docs/auto-add-care-items-plan.md
Normal file
185
Docs/auto-add-care-items-plan.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Plant Detail Auto-Add Care Items with Notifications
|
||||
|
||||
## Summary
|
||||
Add an "Auto-Add Care Items" feature to the plant detail screen that:
|
||||
1. Creates recurring care tasks (watering, fertilizing) based on Trefle API data
|
||||
2. Allows per-task-type notification toggles
|
||||
3. Sends local notifications at user-configured time
|
||||
4. Adds "Notify Me Time" setting in Settings
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
**What Already Exists:**
|
||||
- `PlantDetailView` shows plant info, care requirements, and upcoming tasks
|
||||
- `PlantDetailViewModel` has `loadCareInfo()` and `createSchedule()` methods
|
||||
- `FetchPlantCareUseCase` fetches care info from Trefle API
|
||||
- `CreateCareScheduleUseCase` generates watering/fertilizer tasks for 30 days
|
||||
- `CoreDataCareScheduleStorage` persists care schedules to CoreData
|
||||
- `NotificationService` exists with notification scheduling capabilities
|
||||
- `CarePreferences` has `preferredWateringHour` and `preferredWateringMinute`
|
||||
|
||||
**Gap Analysis:**
|
||||
1. No UI to trigger schedule creation
|
||||
2. Schedules not persisted to CoreData
|
||||
3. No notification toggles per task type
|
||||
4. No "Notify Me Time" setting in Settings UI
|
||||
5. No local notification scheduling when tasks are created
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Notification Time Setting to Settings
|
||||
**Files:**
|
||||
- `PlantGuide/Presentation/Scenes/Settings/SettingsView.swift`
|
||||
- `PlantGuide/Presentation/Scenes/Settings/SettingsViewModel.swift`
|
||||
|
||||
Add:
|
||||
- "Notify Me Time" DatePicker (time only) in Settings
|
||||
- Store in UserDefaults: `settings_notification_time_hour`, `settings_notification_time_minute`
|
||||
- Default to 8:00 AM
|
||||
|
||||
### Step 2: Create CareNotificationPreferences Model
|
||||
**File (new):** `PlantGuide/Domain/Entities/CareNotificationPreferences.swift`
|
||||
|
||||
```swift
|
||||
struct CareNotificationPreferences: Codable, Sendable, Equatable {
|
||||
var wateringEnabled: Bool = true
|
||||
var fertilizingEnabled: Bool = true
|
||||
var repottingEnabled: Bool = false
|
||||
var pruningEnabled: Bool = false
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update PlantDetailViewModel
|
||||
**File:** `PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailViewModel.swift`
|
||||
|
||||
Add:
|
||||
- Dependency on `CareScheduleRepositoryProtocol`
|
||||
- Dependency on `NotificationService`
|
||||
- `notificationPreferences: CareNotificationPreferences` (per plant, stored in UserDefaults by plantID)
|
||||
- `hasExistingSchedule: Bool`
|
||||
- `isCreatingSchedule: Bool`
|
||||
- Load existing schedule on `loadCareInfo()`
|
||||
- `createSchedule()` → persist schedule + schedule notifications
|
||||
- `updateNotificationPreference(for:enabled:)` → update toggles + reschedule
|
||||
|
||||
### Step 4: Update PlantDetailView UI
|
||||
**File:** `PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift`
|
||||
|
||||
Add:
|
||||
- **"Auto-Add Care Items" button** (if no schedule exists)
|
||||
- **Notification Toggles Section** (if schedule exists):
|
||||
```
|
||||
Notifications
|
||||
├─ Watering reminders [Toggle]
|
||||
├─ Fertilizer reminders [Toggle]
|
||||
```
|
||||
- Success feedback when schedule created
|
||||
- Show task count when schedule exists
|
||||
|
||||
### Step 5: Update DIContainer
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
Update `makePlantDetailViewModel()` to inject:
|
||||
- `careScheduleRepository`
|
||||
- `notificationService`
|
||||
|
||||
### Step 6: Schedule Local Notifications
|
||||
**File:** `PlantGuide/Core/Services/NotificationService.swift`
|
||||
|
||||
Add/verify methods:
|
||||
- `scheduleCareTaskNotification(task:plantName:)` - schedules notification for task
|
||||
- `cancelCareTaskNotifications(for plantID:)` - cancels all for plant
|
||||
- `cancelCareTaskNotifications(for taskType:plantID:)` - cancels by type
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Action | Changes |
|
||||
|------|--------|---------|
|
||||
| `SettingsView.swift` | Modify | Add "Notify Me Time" picker |
|
||||
| `SettingsViewModel.swift` | Modify | Add notification time storage |
|
||||
| `CareNotificationPreferences.swift` | Create | New model for per-plant toggles |
|
||||
| `PlantDetailViewModel.swift` | Modify | Add repository, notifications, preferences |
|
||||
| `PlantDetailView.swift` | Modify | Add button + notification toggles |
|
||||
| `DIContainer.swift` | Modify | Update factory injection |
|
||||
| `NotificationService.swift` | Modify | Add care task notification methods |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
1. User configures "Notify Me Time" in Settings (e.g., 9:00 AM)
|
||||
↓
|
||||
2. User taps "Auto-Add Care Items" on plant detail
|
||||
↓
|
||||
3. CreateCareScheduleUseCase creates tasks
|
||||
↓
|
||||
4. CareScheduleRepository.save() → CoreData
|
||||
↓
|
||||
5. For each task type where notification enabled:
|
||||
NotificationService.scheduleCareTaskNotification()
|
||||
↓
|
||||
6. Notifications fire at configured time on scheduled dates
|
||||
```
|
||||
|
||||
## UI Design
|
||||
|
||||
### Settings Screen Addition
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ NOTIFICATIONS │
|
||||
├─────────────────────────────────────┤
|
||||
│ Notify Me Time [9:00 AM ▼] │
|
||||
│ Time to receive care reminders │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Plant Detail Screen
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Plant Header with Image] │
|
||||
├─────────────────────────────────────┤
|
||||
│ Care Information │
|
||||
│ • Light: Bright indirect │
|
||||
│ • Water: Every 7 days │
|
||||
│ • Temperature: 18-27°C │
|
||||
├─────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Auto-Add Care Items │ │ ← Button (if no schedule)
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ OR if schedule exists: │
|
||||
│ │
|
||||
│ Care Reminders │
|
||||
│ ├─ Watering [ON ────●] │
|
||||
│ └─ Fertilizer [ON ────●] │
|
||||
├─────────────────────────────────────┤
|
||||
│ Upcoming Tasks (8 total) │
|
||||
│ • Water tomorrow │
|
||||
│ • Fertilize in 14 days │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Settings:**
|
||||
- Open Settings → see "Notify Me Time" picker
|
||||
- Change time → value persists on restart
|
||||
|
||||
2. **Plant Detail - Create Schedule:**
|
||||
- Navigate to plant without schedule
|
||||
- See "Auto-Add Care Items" button
|
||||
- Tap → loading state → success with task count
|
||||
- Button replaced with notification toggles
|
||||
|
||||
3. **Notification Toggles:**
|
||||
- Toggle watering OFF → existing watering notifications cancelled
|
||||
- Toggle watering ON → notifications rescheduled
|
||||
|
||||
4. **Notifications Fire:**
|
||||
- Create schedule with watering in 1 minute (for testing)
|
||||
- Receive local notification at configured time
|
||||
|
||||
5. **Persistence:**
|
||||
- Close app, reopen → schedule still exists, toggles preserved
|
||||
|
||||
6. **Care Tab:**
|
||||
- Tasks appear in Care tab grouped by date
|
||||
482
Docs/phase-1-implementation-plan.md
Normal file
482
Docs/phase-1-implementation-plan.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Phase 1: Knowledge Base Creation - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Build structured plant knowledge from `data/houseplants_list.json`, enriching with taxonomy and characteristics.
|
||||
|
||||
**Input:** `data/houseplants_list.json` (2,278 plants, 11 categories, 50 families)
|
||||
|
||||
**Output:** Enriched plant knowledge base (JSON + SQLite) with ~500-2000 validated entries
|
||||
|
||||
---
|
||||
|
||||
## Current Data Assessment
|
||||
|
||||
| Attribute | Current State | Required Enhancement |
|
||||
|-----------|---------------|---------------------|
|
||||
| Total Plants | 2,278 | Validate, deduplicate |
|
||||
| Scientific Names | Present | Validate binomial nomenclature |
|
||||
| Common Names | Array per plant | Normalize, cross-reference |
|
||||
| Family | 50 families | Validate against taxonomy |
|
||||
| Category | 11 categories | Map to target types |
|
||||
| Physical Characteristics | **Missing** | **Must add** |
|
||||
| Regional/Seasonal Info | **Missing** | **Must add** |
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Task 1.1: Load and Validate Plant List
|
||||
|
||||
**Objective:** Parse JSON and validate data integrity
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create Python script `scripts/validate_plant_list.py`
|
||||
- [ ] Load `data/houseplants_list.json`
|
||||
- [ ] Validate JSON schema:
|
||||
- Each plant has `scientific_name` (required, string)
|
||||
- Each plant has `common_names` (required, array of strings)
|
||||
- Each plant has `family` (required, string)
|
||||
- Each plant has `category` (required, string)
|
||||
- [ ] Identify malformed entries (missing fields, wrong types)
|
||||
- [ ] Generate validation report: `output/validation_report.json`
|
||||
|
||||
**Validation Criteria:**
|
||||
- 0 malformed entries
|
||||
- All required fields present
|
||||
- No null/empty scientific names
|
||||
|
||||
**Output File:** `scripts/validate_plant_list.py`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Normalize and Standardize Plant Names
|
||||
|
||||
**Objective:** Ensure consistent naming conventions
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/normalize_names.py`
|
||||
- [ ] Scientific name normalization:
|
||||
- Capitalize genus, lowercase species (e.g., "Philodendron hederaceum")
|
||||
- Handle cultivar notation: 'Cultivar Name' in single quotes
|
||||
- Validate binomial/trinomial format
|
||||
- [ ] Common name normalization:
|
||||
- Title case standardization
|
||||
- Remove leading/trailing whitespace
|
||||
- Standardize punctuation
|
||||
- [ ] Handle hybrid notation (×) consistently
|
||||
- [ ] Flag names that don't match expected patterns
|
||||
|
||||
**Validation Criteria:**
|
||||
- 100% of scientific names follow binomial nomenclature pattern
|
||||
- No leading/trailing whitespace in any names
|
||||
- Consistent cultivar notation
|
||||
|
||||
**Output File:** `data/normalized_plants.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Create Deduplicated Master List
|
||||
|
||||
**Objective:** Remove duplicates while preserving unique cultivars
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/deduplicate_plants.py`
|
||||
- [ ] Define deduplication rules:
|
||||
- Exact scientific name match = duplicate
|
||||
- Different cultivars of same species = keep both
|
||||
- Same plant, different common names = merge common names
|
||||
- [ ] Identify potential duplicates using fuzzy matching on:
|
||||
- Scientific names (Levenshtein distance < 3)
|
||||
- Common names that are identical
|
||||
- [ ] Generate duplicate candidates report for manual review
|
||||
- [ ] Merge duplicates: combine common names arrays
|
||||
- [ ] Assign unique plant IDs (`plant_001`, `plant_002`, etc.)
|
||||
|
||||
**Validation Criteria:**
|
||||
- No exact scientific name duplicates
|
||||
- All plants have unique IDs
|
||||
- Merge log documenting all deduplication decisions
|
||||
|
||||
**Output Files:**
|
||||
- `data/master_plant_list.json`
|
||||
- `output/deduplication_report.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Enrich with Physical Characteristics
|
||||
|
||||
**Objective:** Add visual and physical attributes for each plant
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/enrich_characteristics.py`
|
||||
- [ ] Define characteristic schema:
|
||||
```json
|
||||
{
|
||||
"characteristics": {
|
||||
"leaf_shape": ["heart", "oval", "linear", "palmate", "lobed", "needle", "rosette"],
|
||||
"leaf_color": ["green", "variegated", "red", "purple", "silver", "yellow"],
|
||||
"leaf_texture": ["glossy", "matte", "fuzzy", "waxy", "smooth", "rough"],
|
||||
"growth_habit": ["upright", "trailing", "climbing", "rosette", "bushy", "tree-form"],
|
||||
"mature_height_cm": [0-500],
|
||||
"mature_width_cm": [0-300],
|
||||
"flowering": true/false,
|
||||
"flower_colors": ["white", "pink", "red", "yellow", "orange", "purple", "blue"],
|
||||
"bloom_season": ["spring", "summer", "fall", "winter", "year-round"]
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Source characteristics data:
|
||||
- **Primary:** Web scraping from botanical databases (RHS, Missouri Botanical Garden)
|
||||
- **Secondary:** Wikipedia API for plant descriptions
|
||||
- **Fallback:** Family/genus-level defaults
|
||||
- [ ] Implement web fetching with rate limiting
|
||||
- [ ] Parse and extract characteristics from HTML/JSON responses
|
||||
- [ ] Store enrichment sources for traceability
|
||||
|
||||
**Validation Criteria:**
|
||||
- ≥80% of plants have leaf_shape populated
|
||||
- ≥80% of plants have growth_habit populated
|
||||
- ≥60% of plants have height/width estimates
|
||||
- 100% of plants have flowering boolean
|
||||
|
||||
**Output Files:**
|
||||
- `data/enriched_plants.json`
|
||||
- `output/enrichment_coverage_report.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5: Categorize Plants by Type
|
||||
|
||||
**Objective:** Map existing categories to target classification system
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/categorize_plants.py`
|
||||
- [ ] Define target categories (per plan):
|
||||
```
|
||||
- Flowering Plant
|
||||
- Tree / Palm
|
||||
- Shrub / Bush
|
||||
- Succulent / Cactus
|
||||
- Fern
|
||||
- Vine / Trailing
|
||||
- Herb
|
||||
- Orchid
|
||||
- Bromeliad
|
||||
- Air Plant
|
||||
```
|
||||
- [ ] Create mapping from current 11 categories:
|
||||
```
|
||||
Current → Target
|
||||
─────────────────────────────
|
||||
Air Plant → Air Plant
|
||||
Bromeliad → Bromeliad
|
||||
Cactus → Succulent / Cactus
|
||||
Fern → Fern
|
||||
Flowering Houseplant → Flowering Plant
|
||||
Herb → Herb
|
||||
Orchid → Orchid
|
||||
Palm → Tree / Palm
|
||||
Succulent → Succulent / Cactus
|
||||
Trailing/Climbing → Vine / Trailing
|
||||
Tropical Foliage → [Requires secondary classification]
|
||||
```
|
||||
- [ ] Handle "Tropical Foliage" (largest category):
|
||||
- Use growth_habit from Task 1.4 to sub-classify
|
||||
- Cross-reference family for tree-form species (Ficus → Tree)
|
||||
- [ ] Add `primary_category` and `secondary_categories` fields
|
||||
|
||||
**Validation Criteria:**
|
||||
- 100% of plants have primary_category assigned
|
||||
- No plants remain as "Tropical Foliage" (all reclassified)
|
||||
- Category distribution documented
|
||||
|
||||
**Output File:** `data/categorized_plants.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.6: Map Common Names to Scientific Names
|
||||
|
||||
**Objective:** Create bidirectional lookup for name resolution
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/build_name_index.py`
|
||||
- [ ] Build scientific → common names map (already exists, validate)
|
||||
- [ ] Build common → scientific names map (reverse lookup)
|
||||
- [ ] Handle ambiguous common names (multiple plants share same common name):
|
||||
- Flag conflicts
|
||||
- Add disambiguation notes
|
||||
- [ ] Validate against external taxonomy:
|
||||
- World Flora Online (WFO) API
|
||||
- GBIF (Global Biodiversity Information Facility)
|
||||
- [ ] Add `verified` boolean for taxonomically confirmed names
|
||||
- [ ] Store alternative/deprecated scientific names as synonyms
|
||||
|
||||
**Validation Criteria:**
|
||||
- Reverse lookup resolves ≥95% of common names unambiguously
|
||||
- ≥70% of scientific names verified against WFO/GBIF
|
||||
- Synonym list for deprecated names
|
||||
|
||||
**Output Files:**
|
||||
- `data/name_index.json`
|
||||
- `output/name_ambiguity_report.json`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.7: Add Regional/Seasonal Information
|
||||
|
||||
**Objective:** Add native regions, hardiness zones, and seasonal behaviors
|
||||
|
||||
**Actions:**
|
||||
- [ ] Create `scripts/add_regional_data.py`
|
||||
- [ ] Define regional schema:
|
||||
```json
|
||||
{
|
||||
"regional_info": {
|
||||
"native_regions": ["South America", "Southeast Asia", "Africa", ...],
|
||||
"native_countries": ["Brazil", "Thailand", ...],
|
||||
"usda_hardiness_zones": ["9a", "9b", "10a", ...],
|
||||
"indoor_outdoor": "indoor_only" | "outdoor_temperate" | "outdoor_tropical",
|
||||
"seasonal_behavior": "evergreen" | "deciduous" | "dormant_winter"
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Source regional data:
|
||||
- USDA Plants Database API
|
||||
- Wikipedia (native range sections)
|
||||
- Existing botanical databases
|
||||
- [ ] Map families to typical native regions as fallback
|
||||
- [ ] Add care-relevant seasonality (dormancy periods, bloom times)
|
||||
|
||||
**Validation Criteria:**
|
||||
- ≥70% of plants have native_regions populated
|
||||
- ≥60% of plants have hardiness zones
|
||||
- 100% of plants have indoor_outdoor classification
|
||||
|
||||
**Output File:** `data/final_knowledge_base.json`
|
||||
|
||||
---
|
||||
|
||||
## Final Knowledge Base Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"generated_date": "YYYY-MM-DD",
|
||||
"total_plants": 2000,
|
||||
"plants": [
|
||||
{
|
||||
"id": "plant_001",
|
||||
"scientific_name": "Philodendron hederaceum",
|
||||
"common_names": ["Heartleaf Philodendron", "Sweetheart Plant"],
|
||||
"synonyms": [],
|
||||
"family": "Araceae",
|
||||
"genus": "Philodendron",
|
||||
"species": "hederaceum",
|
||||
"cultivar": null,
|
||||
"primary_category": "Vine / Trailing",
|
||||
"secondary_categories": ["Tropical Foliage"],
|
||||
"characteristics": {
|
||||
"leaf_shape": "heart",
|
||||
"leaf_color": ["green"],
|
||||
"leaf_texture": "glossy",
|
||||
"growth_habit": "trailing",
|
||||
"mature_height_cm": 120,
|
||||
"mature_width_cm": 60,
|
||||
"flowering": true,
|
||||
"flower_colors": ["white", "green"],
|
||||
"bloom_season": "rarely indoors"
|
||||
},
|
||||
"regional_info": {
|
||||
"native_regions": ["Central America", "South America"],
|
||||
"native_countries": ["Mexico", "Brazil"],
|
||||
"usda_hardiness_zones": ["10b", "11", "12"],
|
||||
"indoor_outdoor": "indoor_only",
|
||||
"seasonal_behavior": "evergreen"
|
||||
},
|
||||
"taxonomy_verified": true,
|
||||
"data_sources": ["RHS", "Missouri Botanical Garden"],
|
||||
"last_updated": "YYYY-MM-DD"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output File Structure
|
||||
|
||||
```
|
||||
PlantGuide/
|
||||
├── data/
|
||||
│ ├── houseplants_list.json # Original input (unchanged)
|
||||
│ ├── normalized_plants.json # Task 1.2 output
|
||||
│ ├── master_plant_list.json # Task 1.3 output
|
||||
│ ├── enriched_plants.json # Task 1.4 output
|
||||
│ ├── categorized_plants.json # Task 1.5 output
|
||||
│ ├── name_index.json # Task 1.6 output
|
||||
│ └── final_knowledge_base.json # Task 1.7 output (FINAL)
|
||||
├── scripts/
|
||||
│ ├── validate_plant_list.py # Task 1.1
|
||||
│ ├── normalize_names.py # Task 1.2
|
||||
│ ├── deduplicate_plants.py # Task 1.3
|
||||
│ ├── enrich_characteristics.py # Task 1.4
|
||||
│ ├── categorize_plants.py # Task 1.5
|
||||
│ ├── build_name_index.py # Task 1.6
|
||||
│ └── add_regional_data.py # Task 1.7
|
||||
├── output/
|
||||
│ ├── validation_report.json
|
||||
│ ├── deduplication_report.json
|
||||
│ ├── enrichment_coverage_report.json
|
||||
│ └── name_ambiguity_report.json
|
||||
└── knowledge_base/
|
||||
├── plants.db # SQLite database
|
||||
└── schema.sql # Database schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQLite Database Schema
|
||||
|
||||
```sql
|
||||
-- Task: Create SQLite database alongside JSON
|
||||
|
||||
CREATE TABLE plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
scientific_name TEXT NOT NULL UNIQUE,
|
||||
family TEXT NOT NULL,
|
||||
genus TEXT,
|
||||
species TEXT,
|
||||
cultivar TEXT,
|
||||
primary_category TEXT NOT NULL,
|
||||
taxonomy_verified BOOLEAN DEFAULT FALSE,
|
||||
last_updated DATE
|
||||
);
|
||||
|
||||
CREATE TABLE common_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plant_id TEXT REFERENCES plants(id),
|
||||
common_name TEXT NOT NULL,
|
||||
is_primary BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE characteristics (
|
||||
plant_id TEXT PRIMARY KEY REFERENCES plants(id),
|
||||
leaf_shape TEXT,
|
||||
leaf_color TEXT, -- JSON array
|
||||
leaf_texture TEXT,
|
||||
growth_habit TEXT,
|
||||
mature_height_cm INTEGER,
|
||||
mature_width_cm INTEGER,
|
||||
flowering BOOLEAN,
|
||||
flower_colors TEXT, -- JSON array
|
||||
bloom_season TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE regional_info (
|
||||
plant_id TEXT PRIMARY KEY REFERENCES plants(id),
|
||||
native_regions TEXT, -- JSON array
|
||||
native_countries TEXT, -- JSON array
|
||||
usda_hardiness_zones TEXT, -- JSON array
|
||||
indoor_outdoor TEXT,
|
||||
seasonal_behavior TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE synonyms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plant_id TEXT REFERENCES plants(id),
|
||||
synonym TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX idx_plants_family ON plants(family);
|
||||
CREATE INDEX idx_plants_category ON plants(primary_category);
|
||||
CREATE INDEX idx_common_names_name ON common_names(common_name);
|
||||
CREATE INDEX idx_characteristics_habit ON characteristics(growth_habit);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## End Phase Validation Checklist
|
||||
|
||||
### Data Quality Gates
|
||||
|
||||
| Metric | Target | Validation Method |
|
||||
|--------|--------|-------------------|
|
||||
| Total validated plants | ≥1,500 | Count after deduplication |
|
||||
| Schema compliance | 100% | JSON schema validation |
|
||||
| Scientific name format | 100% valid | Regex: `^[A-Z][a-z]+ [a-z]+` |
|
||||
| Plants with characteristics | ≥80% | Field coverage check |
|
||||
| Plants with regional data | ≥70% | Field coverage check |
|
||||
| Category coverage | 100% | No "Unknown" categories |
|
||||
| Name disambiguation | ≥95% | Ambiguity report review |
|
||||
| Taxonomy verification | ≥70% | WFO/GBIF cross-reference |
|
||||
|
||||
### Functional Validation
|
||||
|
||||
- [ ] **Query Test 1:** Lookup by scientific name returns full plant record
|
||||
- [ ] **Query Test 2:** Lookup by common name returns correct plant(s)
|
||||
- [ ] **Query Test 3:** Filter by category returns expected results
|
||||
- [ ] **Query Test 4:** Filter by characteristics (leaf_shape=heart) works
|
||||
- [ ] **Query Test 5:** Regional filter (hardiness_zone=10a) works
|
||||
|
||||
### Deliverable Checklist
|
||||
|
||||
- [ ] `data/final_knowledge_base.json` exists and passes schema validation
|
||||
- [ ] `knowledge_base/plants.db` SQLite database is populated
|
||||
- [ ] All scripts in `scripts/` directory are functional
|
||||
- [ ] All reports in `output/` directory are generated
|
||||
- [ ] Data coverage meets minimum thresholds
|
||||
- [ ] No critical validation errors in reports
|
||||
|
||||
### Phase Exit Criteria
|
||||
|
||||
**Phase 1 is COMPLETE when:**
|
||||
|
||||
1. ✅ Final knowledge base contains ≥1,500 validated plant entries
|
||||
2. ✅ ≥80% of plants have physical characteristics populated
|
||||
3. ✅ ≥70% of plants have regional information
|
||||
4. ✅ 100% of plants have valid categories (no "Unknown")
|
||||
5. ✅ SQLite database mirrors JSON knowledge base
|
||||
6. ✅ All validation tests pass
|
||||
7. ✅ Documentation updated with final counts and coverage metrics
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
```
|
||||
Task 1.1 (Validate)
|
||||
↓
|
||||
Task 1.2 (Normalize)
|
||||
↓
|
||||
Task 1.3 (Deduplicate)
|
||||
↓
|
||||
├─→ Task 1.4 (Characteristics) ─┐
|
||||
│ │
|
||||
└─→ Task 1.6 (Name Index) ──────┤
|
||||
│ │
|
||||
└─→ Task 1.7 (Regional) ────────┤
|
||||
↓
|
||||
Task 1.5 (Categorize)
|
||||
[Depends on 1.4 for Tropical Foliage]
|
||||
↓
|
||||
Final Assembly
|
||||
(JSON + SQLite)
|
||||
↓
|
||||
Validation Suite
|
||||
```
|
||||
|
||||
**Note:** Tasks 1.4, 1.6, and 1.7 can run in parallel after Task 1.3 completes. Task 1.5 depends on Task 1.4 output for sub-categorizing Tropical Foliage plants.
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| External API rate limits | Implement caching, request throttling |
|
||||
| Incomplete enrichment data | Use family-level defaults, document gaps |
|
||||
| Ambiguous common names | Flag for manual review, prioritize top plants |
|
||||
| Taxonomy database mismatches | Trust WFO as primary source |
|
||||
| Large dataset processing | Process in batches, checkpoint progress |
|
||||
383
Docs/phase2-implementation-plan.md
Normal file
383
Docs/phase2-implementation-plan.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Phase 2: Image Dataset Acquisition - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Gather labeled plant images matching our 2,064-plant knowledge base from Phase 1.
|
||||
|
||||
**Target Deliverable:** Labeled image dataset with 50,000-200,000 images across target plant classes, split into training (70%), validation (15%), and test (15%) sets.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Phase 1 complete: `data/final_knowledge_base.json` (2,064 plants)
|
||||
- [x] SQLite database: `knowledge_base/plants.db`
|
||||
- [ ] Python environment with required packages
|
||||
- [ ] API keys for image sources (iNaturalist, Flickr, etc.)
|
||||
- [ ] Storage space: ~50-100GB for raw images
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Task 2.1: Research Public Plant Image Datasets
|
||||
|
||||
**Objective:** Evaluate available datasets for compatibility with our plant list.
|
||||
|
||||
**Actions:**
|
||||
1. Research and document each dataset:
|
||||
- **PlantCLEF** - Download links, species coverage, image format, license
|
||||
- **iNaturalist** - API access, species coverage, observation quality filters
|
||||
- **PlantNet (Pl@ntNet)** - API documentation, rate limits, attribution requirements
|
||||
- **Oxford Flowers 102** - Direct download, category mapping
|
||||
- **Wikimedia Commons** - API access for botanical images
|
||||
|
||||
2. Create `scripts/phase2/research_datasets.py` to:
|
||||
- Query each API for available species counts
|
||||
- Document download procedures and authentication
|
||||
- Estimate total available images per source
|
||||
|
||||
**Output:** `output/dataset_research_report.json`
|
||||
|
||||
**Validation:**
|
||||
- [ ] Report contains at least 4 dataset sources
|
||||
- [ ] Each source has documented: URL, license, estimated image count, access method
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: Cross-Reference Datasets with Plant List
|
||||
|
||||
**Objective:** Identify which plants from our knowledge base have images in public datasets.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/cross_reference_plants.py` to:
|
||||
- Load plant list from `data/final_knowledge_base.json`
|
||||
- Query each dataset API for matching scientific names
|
||||
- Handle synonyms using `data/synonyms.json`
|
||||
- Track exact matches, synonym matches, and genus-level matches
|
||||
|
||||
2. Generate coverage matrix: plants × datasets
|
||||
|
||||
**Output:**
|
||||
- `output/dataset_coverage_matrix.json` - Per-plant availability
|
||||
- `output/cross_reference_report.json` - Summary statistics
|
||||
|
||||
**Validation:**
|
||||
- [ ] Coverage matrix includes all 2,064 plants
|
||||
- [ ] Report shows percentage coverage per dataset
|
||||
- [ ] Identified total unique plants with at least one dataset match
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: Download and Organize Images
|
||||
|
||||
**Objective:** Download images from selected sources and organize by species.
|
||||
|
||||
**Actions:**
|
||||
1. Create directory structure:
|
||||
```
|
||||
datasets/
|
||||
├── raw/
|
||||
│ ├── inaturalist/
|
||||
│ ├── plantclef/
|
||||
│ ├── wikimedia/
|
||||
│ └── flickr/
|
||||
└── organized/
|
||||
└── {scientific_name}/
|
||||
├── img_001.jpg
|
||||
└── metadata.json
|
||||
```
|
||||
|
||||
2. Create `scripts/phase2/download_inaturalist.py`:
|
||||
- Use iNaturalist API with research-grade filter
|
||||
- Download max 500 images per species
|
||||
- Include metadata (observer, date, location, license)
|
||||
- Handle rate limiting with exponential backoff
|
||||
|
||||
3. Create `scripts/phase2/download_plantclef.py`:
|
||||
- Download from PlantCLEF challenge archives
|
||||
- Extract and organize by species
|
||||
|
||||
4. Create `scripts/phase2/download_wikimedia.py`:
|
||||
- Query Wikimedia Commons API for botanical images
|
||||
- Filter by license (CC-BY, CC-BY-SA, public domain)
|
||||
|
||||
5. Create `scripts/phase2/organize_images.py`:
|
||||
- Consolidate images from all sources
|
||||
- Rename with consistent naming: `{plant_id}_{source}_{index}.jpg`
|
||||
- Generate per-species `metadata.json`
|
||||
|
||||
**Output:**
|
||||
- `datasets/organized/` - Organized image directory
|
||||
- `output/download_progress.json` - Download status per species
|
||||
|
||||
**Validation:**
|
||||
- [ ] Images organized in consistent directory structure
|
||||
- [ ] Each image has source attribution in metadata
|
||||
- [ ] Progress tracking shows download status for all plants
|
||||
|
||||
---
|
||||
|
||||
### Task 2.4: Establish Minimum Image Count per Class
|
||||
|
||||
**Objective:** Define and track image count thresholds.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/count_images.py` to:
|
||||
- Count images per species in `datasets/organized/`
|
||||
- Classify plants into coverage tiers:
|
||||
- **Excellent:** 200+ images
|
||||
- **Good:** 100-199 images (target minimum)
|
||||
- **Marginal:** 50-99 images
|
||||
- **Insufficient:** 10-49 images
|
||||
- **Critical:** <10 images
|
||||
|
||||
2. Generate coverage report with distribution histogram
|
||||
|
||||
**Output:**
|
||||
- `output/image_count_report.json`
|
||||
- `output/coverage_histogram.png`
|
||||
|
||||
**Validation:**
|
||||
- [ ] Target: At least 60% of plants have 100+ images
|
||||
- [ ] Report identifies all plants below minimum threshold
|
||||
- [ ] Total image count within target range (50K-200K)
|
||||
|
||||
---
|
||||
|
||||
### Task 2.5: Identify Gap Plants
|
||||
|
||||
**Objective:** Find plants needing supplementary images.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/identify_gaps.py` to:
|
||||
- List plants with <100 images
|
||||
- Prioritize gaps by:
|
||||
- Plant popularity/commonality
|
||||
- Category importance (user-facing plants first)
|
||||
- Ease of sourcing (common names available)
|
||||
|
||||
2. Generate prioritized gap list with recommended sources
|
||||
|
||||
**Output:**
|
||||
- `output/gap_plants.json` - Prioritized list with current counts
|
||||
- `output/gap_analysis_report.md` - Human-readable analysis
|
||||
|
||||
**Validation:**
|
||||
- [ ] Gap list includes all plants under 100-image threshold
|
||||
- [ ] Each gap plant has recommended supplementary sources
|
||||
- [ ] Priority scores assigned based on criteria
|
||||
|
||||
---
|
||||
|
||||
### Task 2.6: Source Supplementary Images
|
||||
|
||||
**Objective:** Fill gaps using additional image sources.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/download_flickr.py`:
|
||||
- Use Flickr API with botanical/plant tags
|
||||
- Filter by license (CC-BY, CC-BY-SA)
|
||||
- Search by scientific name AND common names
|
||||
|
||||
2. Create `scripts/phase2/download_google_images.py`:
|
||||
- Use Google Custom Search API (paid tier)
|
||||
- Apply strict botanical filters
|
||||
- Download only high-resolution images
|
||||
|
||||
3. Create `scripts/phase2/manual_curation_list.py`:
|
||||
- Generate list of gap plants requiring manual sourcing
|
||||
- Create curation checklist for human review
|
||||
|
||||
4. Update `organize_images.py` to incorporate supplementary sources
|
||||
|
||||
**Output:**
|
||||
- Updated `datasets/organized/` with supplementary images
|
||||
- `output/supplementary_download_report.json`
|
||||
- `output/manual_curation_checklist.md` (if needed)
|
||||
|
||||
**Validation:**
|
||||
- [ ] Gap plants have improved coverage
|
||||
- [ ] All supplementary images have proper licensing
|
||||
- [ ] Re-run Task 2.4 shows improved coverage metrics
|
||||
|
||||
---
|
||||
|
||||
### Task 2.7: Verify Image Quality and Labels
|
||||
|
||||
**Objective:** Remove mislabeled and low-quality images.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/quality_filter.py` to:
|
||||
- Detect corrupt/truncated images
|
||||
- Filter by minimum resolution (224x224 minimum)
|
||||
- Detect duplicates using perceptual hashing (pHash)
|
||||
- Flag images with text overlays/watermarks
|
||||
|
||||
2. Create `scripts/phase2/label_verification.py` to:
|
||||
- Use pretrained plant classifier for sanity check
|
||||
- Flag images where model confidence is very low
|
||||
- Generate review queue for human verification
|
||||
|
||||
3. Create `scripts/phase2/human_review_tool.py`:
|
||||
- Simple CLI tool for reviewing flagged images
|
||||
- Accept/reject/relabel options
|
||||
- Track reviewer decisions
|
||||
|
||||
**Output:**
|
||||
- `datasets/verified/` - Cleaned image directory
|
||||
- `output/quality_report.json` - Filtering statistics
|
||||
- `output/removed_images.json` - Log of removed images with reasons
|
||||
|
||||
**Validation:**
|
||||
- [ ] All images pass minimum resolution check
|
||||
- [ ] No duplicate images (within 95% perceptual similarity)
|
||||
- [ ] Flagged images reviewed and resolved
|
||||
- [ ] Removal rate documented (<20% expected)
|
||||
|
||||
---
|
||||
|
||||
### Task 2.8: Split Dataset
|
||||
|
||||
**Objective:** Create reproducible train/validation/test splits.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase2/split_dataset.py` to:
|
||||
- Stratified split maintaining class distribution
|
||||
- 70% training, 15% validation, 15% test
|
||||
- Ensure no data leakage (same plant photo in multiple splits)
|
||||
- Handle class imbalance (minimum samples per class in each split)
|
||||
|
||||
2. Create manifest files:
|
||||
```
|
||||
datasets/
|
||||
├── train/
|
||||
│ ├── images/
|
||||
│ └── manifest.csv (path, label, scientific_name, plant_id)
|
||||
├── val/
|
||||
│ ├── images/
|
||||
│ └── manifest.csv
|
||||
└── test/
|
||||
├── images/
|
||||
└── manifest.csv
|
||||
```
|
||||
|
||||
3. Generate split statistics report
|
||||
|
||||
**Output:**
|
||||
- `datasets/train/`, `datasets/val/`, `datasets/test/` directories
|
||||
- `output/split_statistics.json`
|
||||
- `output/class_distribution.png` (per-split histogram)
|
||||
|
||||
**Validation:**
|
||||
- [ ] Split ratios within 1% of target (70/15/15)
|
||||
- [ ] Each class has minimum 5 samples in val and test sets
|
||||
- [ ] No image appears in multiple splits
|
||||
- [ ] Manifest files are complete and valid
|
||||
|
||||
---
|
||||
|
||||
## End-Phase Validation Checklist
|
||||
|
||||
Run `scripts/phase2/validate_phase2.py` to verify:
|
||||
|
||||
| # | Validation Criterion | Target | Pass/Fail |
|
||||
|---|---------------------|--------|-----------|
|
||||
| 1 | Total image count | 50,000 - 200,000 | [ ] |
|
||||
| 2 | Plant coverage | ≥80% of 2,064 plants have images | [ ] |
|
||||
| 3 | Minimum images per included plant | ≥50 images (relaxed from 100 for rare plants) | [ ] |
|
||||
| 4 | Image quality | 100% pass resolution check | [ ] |
|
||||
| 5 | No duplicates | 0 exact duplicates, <1% near-duplicates | [ ] |
|
||||
| 6 | License compliance | 100% images have documented license | [ ] |
|
||||
| 7 | Train/val/test split exists | All three directories with manifests | [ ] |
|
||||
| 8 | Split ratio accuracy | Within 1% of 70/15/15 | [ ] |
|
||||
| 9 | Stratification verified | Chi-square test p > 0.05 | [ ] |
|
||||
| 10 | Metadata completeness | 100% images have source + license | [ ] |
|
||||
|
||||
**Phase 2 Complete When:** All 10 validation criteria pass.
|
||||
|
||||
---
|
||||
|
||||
## Scripts Summary
|
||||
|
||||
| Script | Task | Input | Output |
|
||||
|--------|------|-------|--------|
|
||||
| `research_datasets.py` | 2.1 | None | `dataset_research_report.json` |
|
||||
| `cross_reference_plants.py` | 2.2 | Knowledge base | `cross_reference_report.json` |
|
||||
| `download_inaturalist.py` | 2.3 | Plant list | Images + metadata |
|
||||
| `download_plantclef.py` | 2.3 | Plant list | Images + metadata |
|
||||
| `download_wikimedia.py` | 2.3 | Plant list | Images + metadata |
|
||||
| `organize_images.py` | 2.3 | Raw images | `datasets/organized/` |
|
||||
| `count_images.py` | 2.4 | Organized images | `image_count_report.json` |
|
||||
| `identify_gaps.py` | 2.5 | Image counts | `gap_plants.json` |
|
||||
| `download_flickr.py` | 2.6 | Gap plants | Supplementary images |
|
||||
| `quality_filter.py` | 2.7 | All images | `datasets/verified/` |
|
||||
| `label_verification.py` | 2.7 | Verified images | Review queue |
|
||||
| `split_dataset.py` | 2.8 | Verified images | Train/val/test splits |
|
||||
| `validate_phase2.py` | Final | All outputs | Validation report |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
# requirements-phase2.txt
|
||||
requests>=2.28.0
|
||||
Pillow>=9.0.0
|
||||
imagehash>=4.3.0
|
||||
pandas>=1.5.0
|
||||
tqdm>=4.64.0
|
||||
python-dotenv>=1.0.0
|
||||
matplotlib>=3.6.0
|
||||
scipy>=1.9.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
# .env.phase2
|
||||
INATURALIST_APP_ID=your_app_id
|
||||
INATURALIST_APP_SECRET=your_secret
|
||||
FLICKR_API_KEY=your_key
|
||||
FLICKR_API_SECRET=your_secret
|
||||
GOOGLE_CSE_API_KEY=your_key
|
||||
GOOGLE_CSE_CX=your_cx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Task | Effort | Notes |
|
||||
|------|--------|-------|
|
||||
| 2.1 Research | 1 day | Documentation and API testing |
|
||||
| 2.2 Cross-reference | 1 day | API queries, matching logic |
|
||||
| 2.3 Download | 3-5 days | Rate-limited by APIs |
|
||||
| 2.4 Count | 0.5 day | Quick analysis |
|
||||
| 2.5 Gap analysis | 0.5 day | Based on counts |
|
||||
| 2.6 Supplementary | 2-3 days | Depends on gap size |
|
||||
| 2.7 Quality verification | 2 days | Includes manual review |
|
||||
| 2.8 Split | 0.5 day | Automated |
|
||||
| Validation | 0.5 day | Final checks |
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| API rate limits | Implement backoff, cache responses, spread over time |
|
||||
| Low coverage for rare plants | Accept lower threshold (50 images) with augmentation in Phase 3 |
|
||||
| License issues | Track all sources, prefer CC-licensed content |
|
||||
| Storage limits | Implement progressive download, compress as needed |
|
||||
| Label noise | Use pretrained model for sanity check, human review queue |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Phase 2
|
||||
|
||||
1. Review `output/image_count_report.json` for Phase 3 augmentation priorities
|
||||
2. Ensure `datasets/train/manifest.csv` format is compatible with training framework
|
||||
3. Document any plants excluded due to insufficient images
|
||||
547
Docs/phase3-implementation-plan.md
Normal file
547
Docs/phase3-implementation-plan.md
Normal file
@@ -0,0 +1,547 @@
|
||||
# Phase 3: Dataset Preprocessing & Augmentation - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Prepare images for training with consistent formatting and augmentation pipeline.
|
||||
|
||||
**Prerequisites:** Phase 2 complete - `datasets/train/`, `datasets/val/`, `datasets/test/` directories with manifests
|
||||
|
||||
**Target Deliverable:** Training-ready dataset with standardized dimensions, normalized values, and augmentation pipeline
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Task 3.1: Standardize Image Dimensions
|
||||
|
||||
**Objective:** Resize all images to consistent dimensions for model input.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/standardize_dimensions.py` to:
|
||||
- Load images from train/val/test directories
|
||||
- Resize to target dimension (224x224 for MobileNetV3, 299x299 for EfficientNet)
|
||||
- Preserve aspect ratio with center crop or letterboxing
|
||||
- Save resized images to new directory structure
|
||||
|
||||
2. Support multiple output sizes:
|
||||
```python
|
||||
TARGET_SIZES = {
|
||||
"mobilenet": (224, 224),
|
||||
"efficientnet": (299, 299),
|
||||
"vit": (384, 384)
|
||||
}
|
||||
```
|
||||
|
||||
3. Implement resize strategies:
|
||||
- **center_crop:** Crop to square, then resize (preserves detail)
|
||||
- **letterbox:** Pad to square, then resize (preserves full image)
|
||||
- **stretch:** Direct resize (fastest, may distort)
|
||||
|
||||
4. Output directory structure:
|
||||
```
|
||||
datasets/
|
||||
├── processed/
|
||||
│ └── 224x224/
|
||||
│ ├── train/
|
||||
│ ├── val/
|
||||
│ └── test/
|
||||
```
|
||||
|
||||
**Output:**
|
||||
- `datasets/processed/{size}/` directories
|
||||
- `output/phase3/dimension_report.json` - Processing statistics
|
||||
|
||||
**Validation:**
|
||||
- [ ] All images in processed directory are exactly target dimensions
|
||||
- [ ] No corrupt images (all readable by PIL)
|
||||
- [ ] Image count matches source (no images lost)
|
||||
- [ ] Processing time logged for performance baseline
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: Normalize Color Channels
|
||||
|
||||
**Objective:** Standardize pixel values and handle format variations.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/normalize_images.py` to:
|
||||
- Convert all images to RGB (handle RGBA, grayscale, CMYK)
|
||||
- Apply ImageNet normalization (mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
|
||||
- Handle various input formats (JPEG, PNG, WebP, HEIC)
|
||||
- Save as consistent format (JPEG with quality 95, or PNG for lossless)
|
||||
|
||||
2. Implement color normalization:
|
||||
```python
|
||||
def normalize_image(image: np.ndarray) -> np.ndarray:
|
||||
"""Normalize image for model input."""
|
||||
image = image.astype(np.float32) / 255.0
|
||||
mean = np.array([0.485, 0.456, 0.406])
|
||||
std = np.array([0.229, 0.224, 0.225])
|
||||
return (image - mean) / std
|
||||
```
|
||||
|
||||
3. Create preprocessing pipeline class:
|
||||
```python
|
||||
class ImagePreprocessor:
|
||||
def __init__(self, target_size, normalize=True):
|
||||
self.target_size = target_size
|
||||
self.normalize = normalize
|
||||
|
||||
def __call__(self, image_path: str) -> np.ndarray:
|
||||
# Load, resize, convert, normalize
|
||||
pass
|
||||
```
|
||||
|
||||
4. Handle edge cases:
|
||||
- Grayscale → convert to RGB by duplicating channels
|
||||
- RGBA → remove alpha channel, composite on white
|
||||
- CMYK → convert to RGB color space
|
||||
- 16-bit images → convert to 8-bit
|
||||
|
||||
**Output:**
|
||||
- Updated processed images with consistent color handling
|
||||
- `output/phase3/color_conversion_log.json` - Format conversion statistics
|
||||
|
||||
**Validation:**
|
||||
- [ ] All images have exactly 3 color channels (RGB)
|
||||
- [ ] Pixel values in expected range after normalization
|
||||
- [ ] No format conversion errors
|
||||
- [ ] Color fidelity maintained (visual spot check on 50 random images)
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: Implement Data Augmentation Pipeline
|
||||
|
||||
**Objective:** Create augmentation transforms to increase training data variety.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/augmentation_pipeline.py` with transforms:
|
||||
|
||||
**Geometric Transforms:**
|
||||
- Random rotation: -30° to +30°
|
||||
- Random horizontal flip: 50% probability
|
||||
- Random vertical flip: 10% probability (some plants are naturally upside-down)
|
||||
- Random crop: 80-100% of image, then resize back
|
||||
- Random perspective: slight perspective distortion
|
||||
|
||||
**Color Transforms:**
|
||||
- Random brightness: ±20%
|
||||
- Random contrast: ±20%
|
||||
- Random saturation: ±30%
|
||||
- Random hue shift: ±10%
|
||||
- Color jitter (combined)
|
||||
|
||||
**Blur/Noise Transforms:**
|
||||
- Gaussian blur: kernel 3-7, 30% probability
|
||||
- Motion blur: 10% probability
|
||||
- Gaussian noise: σ=0.01-0.05, 20% probability
|
||||
|
||||
**Occlusion Transforms:**
|
||||
- Random erasing (cutout): 10-30% area, 20% probability
|
||||
- Grid dropout: 10% probability
|
||||
|
||||
2. Implement using PyTorch or Albumentations:
|
||||
```python
|
||||
import albumentations as A
|
||||
|
||||
train_transform = A.Compose([
|
||||
A.RandomResizedCrop(224, 224, scale=(0.8, 1.0)),
|
||||
A.HorizontalFlip(p=0.5),
|
||||
A.Rotate(limit=30, p=0.5),
|
||||
A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.3, hue=0.1),
|
||||
A.GaussianBlur(blur_limit=(3, 7), p=0.3),
|
||||
A.CoarseDropout(max_holes=8, max_height=16, max_width=16, p=0.2),
|
||||
A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||
ToTensorV2(),
|
||||
])
|
||||
|
||||
val_transform = A.Compose([
|
||||
A.Resize(256, 256),
|
||||
A.CenterCrop(224, 224),
|
||||
A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||
ToTensorV2(),
|
||||
])
|
||||
```
|
||||
|
||||
3. Create visualization tool for augmentation preview:
|
||||
```python
|
||||
def visualize_augmentations(image_path, transform, n_samples=9):
|
||||
"""Show grid of augmented versions of same image."""
|
||||
pass
|
||||
```
|
||||
|
||||
4. Save augmentation configuration to JSON for reproducibility
|
||||
|
||||
**Output:**
|
||||
- `scripts/phase3/augmentation_pipeline.py` - Reusable transform classes
|
||||
- `output/phase3/augmentation_config.json` - Transform parameters
|
||||
- `output/phase3/augmentation_samples/` - Visual examples
|
||||
|
||||
**Validation:**
|
||||
- [ ] All augmentations produce valid images (no NaN, no corruption)
|
||||
- [ ] Augmented images visually reasonable (not over-augmented)
|
||||
- [ ] Transforms are deterministic when seeded
|
||||
- [ ] Pipeline runs at >100 images/second on CPU
|
||||
|
||||
---
|
||||
|
||||
### Task 3.4: Balance Underrepresented Classes
|
||||
|
||||
**Objective:** Create augmented variants to address class imbalance.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/analyze_class_balance.py` to:
|
||||
- Count images per class in training set
|
||||
- Calculate imbalance ratio (max_class / min_class)
|
||||
- Identify underrepresented classes (below median - 1 std)
|
||||
- Visualize class distribution
|
||||
|
||||
2. Create `scripts/phase3/oversample_minority.py` to:
|
||||
- Define target samples per class (e.g., median count)
|
||||
- Generate augmented copies for minority classes
|
||||
- Apply stronger augmentation for synthetic samples
|
||||
- Track original vs augmented counts
|
||||
|
||||
3. Implement oversampling strategies:
|
||||
```python
|
||||
class BalancingStrategy:
|
||||
"""Strategies for handling class imbalance."""
|
||||
|
||||
@staticmethod
|
||||
def oversample_to_median(class_counts: dict) -> dict:
|
||||
"""Oversample minority classes to median count."""
|
||||
median = np.median(list(class_counts.values()))
|
||||
targets = {}
|
||||
for cls, count in class_counts.items():
|
||||
targets[cls] = max(int(median), count)
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
def oversample_to_max(class_counts: dict, cap_ratio=5) -> dict:
|
||||
"""Oversample to max, capped at ratio times original."""
|
||||
max_count = max(class_counts.values())
|
||||
targets = {}
|
||||
for cls, count in class_counts.items():
|
||||
targets[cls] = min(max_count, count * cap_ratio)
|
||||
return targets
|
||||
```
|
||||
|
||||
4. Generate balanced training manifest:
|
||||
- Include original images
|
||||
- Add paths to augmented copies
|
||||
- Mark augmented images in manifest (for analysis)
|
||||
|
||||
**Output:**
|
||||
- `datasets/processed/balanced/train/` - Balanced training set
|
||||
- `output/phase3/class_balance_before.json` - Original distribution
|
||||
- `output/phase3/class_balance_after.json` - Balanced distribution
|
||||
- `output/phase3/balance_histogram.png` - Visual comparison
|
||||
|
||||
**Validation:**
|
||||
- [ ] Imbalance ratio reduced to < 10:1 (max:min)
|
||||
- [ ] No class has fewer than 50 training samples
|
||||
- [ ] Augmented images are visually distinct from originals
|
||||
- [ ] Total training set size documented
|
||||
|
||||
---
|
||||
|
||||
### Task 3.5: Generate Image Manifest Files
|
||||
|
||||
**Objective:** Create mapping files for training pipeline.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/generate_manifests.py` to produce:
|
||||
|
||||
**CSV Format (PyTorch ImageFolder compatible):**
|
||||
```csv
|
||||
path,label,scientific_name,plant_id,source,is_augmented
|
||||
train/images/quercus_robur_001.jpg,42,Quercus robur,QR001,inaturalist,false
|
||||
train/images/quercus_robur_002_aug.jpg,42,Quercus robur,QR001,augmented,true
|
||||
```
|
||||
|
||||
**JSON Format (detailed metadata):**
|
||||
```json
|
||||
{
|
||||
"train": [
|
||||
{
|
||||
"path": "train/images/quercus_robur_001.jpg",
|
||||
"label": 42,
|
||||
"scientific_name": "Quercus robur",
|
||||
"common_name": "English Oak",
|
||||
"plant_id": "QR001",
|
||||
"source": "inaturalist",
|
||||
"is_augmented": false,
|
||||
"original_path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. Generate label mapping file:
|
||||
```json
|
||||
{
|
||||
"label_to_name": {
|
||||
"0": "Acer palmatum",
|
||||
"1": "Acer rubrum",
|
||||
...
|
||||
},
|
||||
"name_to_label": {
|
||||
"Acer palmatum": 0,
|
||||
"Acer rubrum": 1,
|
||||
...
|
||||
},
|
||||
"label_to_common": {
|
||||
"0": "Japanese Maple",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Create split statistics:
|
||||
- Total images per split
|
||||
- Classes per split
|
||||
- Images per class per split
|
||||
|
||||
**Output:**
|
||||
- `datasets/processed/train_manifest.csv`
|
||||
- `datasets/processed/val_manifest.csv`
|
||||
- `datasets/processed/test_manifest.csv`
|
||||
- `datasets/processed/label_mapping.json`
|
||||
- `output/phase3/manifest_statistics.json`
|
||||
|
||||
**Validation:**
|
||||
- [ ] All image paths in manifests exist on disk
|
||||
- [ ] Labels are consecutive integers starting from 0
|
||||
- [ ] No duplicate entries in manifests
|
||||
- [ ] Split sizes match expected counts
|
||||
- [ ] Label mapping covers all classes
|
||||
|
||||
---
|
||||
|
||||
### Task 3.6: Validate Dataset Integrity
|
||||
|
||||
**Objective:** Final verification of processed dataset.
|
||||
|
||||
**Actions:**
|
||||
1. Create `scripts/phase3/validate_dataset.py` to run comprehensive checks:
|
||||
|
||||
**File Integrity:**
|
||||
- All manifest paths exist
|
||||
- All images load without error
|
||||
- All images have correct dimensions
|
||||
- File permissions allow read access
|
||||
|
||||
**Label Consistency:**
|
||||
- Labels match between manifest and directory structure
|
||||
- All labels have corresponding class names
|
||||
- No orphaned images (in directory but not manifest)
|
||||
- No missing images (in manifest but not directory)
|
||||
|
||||
**Dataset Statistics:**
|
||||
- Per-class image counts
|
||||
- Train/val/test split ratios
|
||||
- Augmented vs original ratio
|
||||
- File size distribution
|
||||
|
||||
**Sample Verification:**
|
||||
- Random sample of 100 images per split
|
||||
- Verify image content matches label (using pretrained model)
|
||||
- Flag potential mislabels for review
|
||||
|
||||
2. Create `scripts/phase3/repair_dataset.py` for common fixes:
|
||||
- Remove entries with missing files
|
||||
- Fix incorrect labels (with confirmation)
|
||||
- Regenerate corrupted augmentations
|
||||
|
||||
**Output:**
|
||||
- `output/phase3/validation_report.json` - Full validation results
|
||||
- `output/phase3/validation_summary.md` - Human-readable summary
|
||||
- `output/phase3/flagged_for_review.json` - Potential issues
|
||||
|
||||
**Validation:**
|
||||
- [ ] 0 missing files
|
||||
- [ ] 0 corrupted images
|
||||
- [ ] 0 dimension mismatches
|
||||
- [ ] <1% potential mislabels flagged
|
||||
- [ ] All metadata fields populated
|
||||
|
||||
---
|
||||
|
||||
## End-of-Phase Validation Checklist
|
||||
|
||||
Run `scripts/phase3/validate_phase3.py` to verify all criteria:
|
||||
|
||||
### Image Processing Validation
|
||||
|
||||
| # | Criterion | Target | Status |
|
||||
|---|-----------|--------|--------|
|
||||
| 1 | All images standardized to target size | 100% at 224x224 (or configured size) | [ ] |
|
||||
| 2 | All images in RGB format | 100% RGB, 3 channels | [ ] |
|
||||
| 3 | No corrupted images | 0 unreadable files | [ ] |
|
||||
| 4 | Normalization applied correctly | Values in expected range | [ ] |
|
||||
|
||||
### Augmentation Validation
|
||||
|
||||
| # | Criterion | Target | Status |
|
||||
|---|-----------|--------|--------|
|
||||
| 5 | Augmentation pipeline functional | All transforms produce valid output | [ ] |
|
||||
| 6 | Augmentation reproducible | Same seed = same output | [ ] |
|
||||
| 7 | Augmentation performance | >100 images/sec on CPU | [ ] |
|
||||
| 8 | Visual quality | Spot check passes (50 random samples) | [ ] |
|
||||
|
||||
### Class Balance Validation
|
||||
|
||||
| # | Criterion | Target | Status |
|
||||
|---|-----------|--------|--------|
|
||||
| 9 | Class imbalance ratio | < 10:1 (max:min) | [ ] |
|
||||
| 10 | Minimum class size | ≥50 images per class in train | [ ] |
|
||||
| 11 | Augmentation ratio | Augmented ≤ 4x original per class | [ ] |
|
||||
|
||||
### Manifest Validation
|
||||
|
||||
| # | Criterion | Target | Status |
|
||||
|---|-----------|--------|--------|
|
||||
| 12 | Manifest completeness | 100% images have manifest entries | [ ] |
|
||||
| 13 | Path validity | 100% manifest paths exist | [ ] |
|
||||
| 14 | Label consistency | Labels match directory structure | [ ] |
|
||||
| 15 | No duplicates | 0 duplicate entries | [ ] |
|
||||
| 16 | Label mapping complete | All labels have names | [ ] |
|
||||
|
||||
### Dataset Statistics
|
||||
|
||||
| Metric | Expected | Actual | Status |
|
||||
|--------|----------|--------|--------|
|
||||
| Total processed images | 50,000 - 200,000 | | [ ] |
|
||||
| Training set size | ~70% of total | | [ ] |
|
||||
| Validation set size | ~15% of total | | [ ] |
|
||||
| Test set size | ~15% of total | | [ ] |
|
||||
| Number of classes | 200 - 500 | | [ ] |
|
||||
| Avg images per class (train) | 100 - 400 | | [ ] |
|
||||
| Image file size (avg) | 30-100 KB | | [ ] |
|
||||
| Total dataset size | 10-50 GB | | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 Completion Checklist
|
||||
|
||||
- [ ] Task 3.1: Images standardized to target dimensions
|
||||
- [ ] Task 3.2: Color channels normalized and formats unified
|
||||
- [ ] Task 3.3: Augmentation pipeline implemented and tested
|
||||
- [ ] Task 3.4: Class imbalance addressed through oversampling
|
||||
- [ ] Task 3.5: Manifest files generated for all splits
|
||||
- [ ] Task 3.6: Dataset integrity validated
|
||||
- [ ] All 16 validation criteria pass
|
||||
- [ ] Dataset statistics documented
|
||||
- [ ] Augmentation config saved for reproducibility
|
||||
- [ ] Ready for Phase 4 (Model Architecture Selection)
|
||||
|
||||
---
|
||||
|
||||
## Scripts Summary
|
||||
|
||||
| Script | Task | Input | Output |
|
||||
|--------|------|-------|--------|
|
||||
| `standardize_dimensions.py` | 3.1 | Raw images | Resized images |
|
||||
| `normalize_images.py` | 3.2 | Resized images | Normalized images |
|
||||
| `augmentation_pipeline.py` | 3.3 | Images | Transform classes |
|
||||
| `analyze_class_balance.py` | 3.4 | Train manifest | Balance report |
|
||||
| `oversample_minority.py` | 3.4 | Imbalanced set | Balanced set |
|
||||
| `generate_manifests.py` | 3.5 | Processed images | CSV/JSON manifests |
|
||||
| `validate_dataset.py` | 3.6 | Full dataset | Validation report |
|
||||
| `validate_phase3.py` | Final | All outputs | Pass/Fail report |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
# requirements-phase3.txt
|
||||
Pillow>=9.0.0
|
||||
numpy>=1.24.0
|
||||
albumentations>=1.3.0
|
||||
torch>=2.0.0
|
||||
torchvision>=0.15.0
|
||||
opencv-python>=4.7.0
|
||||
pandas>=2.0.0
|
||||
tqdm>=4.65.0
|
||||
matplotlib>=3.7.0
|
||||
scikit-learn>=1.2.0
|
||||
imagehash>=4.3.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure After Phase 3
|
||||
|
||||
```
|
||||
datasets/
|
||||
├── raw/ # Original downloaded images (Phase 2)
|
||||
├── organized/ # Organized by species (Phase 2)
|
||||
├── verified/ # Quality-checked (Phase 2)
|
||||
├── train/ # Train split (Phase 2)
|
||||
├── val/ # Validation split (Phase 2)
|
||||
├── test/ # Test split (Phase 2)
|
||||
└── processed/ # Phase 3 output
|
||||
├── 224x224/ # Standardized size
|
||||
│ ├── train/
|
||||
│ │ └── images/
|
||||
│ ├── val/
|
||||
│ │ └── images/
|
||||
│ └── test/
|
||||
│ └── images/
|
||||
├── balanced/ # Class-balanced training
|
||||
│ └── train/
|
||||
│ └── images/
|
||||
├── train_manifest.csv
|
||||
├── val_manifest.csv
|
||||
├── test_manifest.csv
|
||||
├── label_mapping.json
|
||||
└── augmentation_config.json
|
||||
|
||||
output/phase3/
|
||||
├── dimension_report.json
|
||||
├── color_conversion_log.json
|
||||
├── augmentation_config.json
|
||||
├── augmentation_samples/
|
||||
├── class_balance_before.json
|
||||
├── class_balance_after.json
|
||||
├── balance_histogram.png
|
||||
├── manifest_statistics.json
|
||||
├── validation_report.json
|
||||
├── validation_summary.md
|
||||
└── flagged_for_review.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Disk space exhaustion | Monitor disk usage, compress images, delete raw after processing |
|
||||
| Memory errors with large batches | Process in batches of 1000, use memory-mapped files |
|
||||
| Augmentation too aggressive | Visual review, conservative defaults, configurable parameters |
|
||||
| Class imbalance persists | Multiple oversampling strategies, weighted loss in training |
|
||||
| Slow processing | Multiprocessing, GPU acceleration for transforms |
|
||||
| Reproducibility issues | Save all configs, use fixed random seeds, version control |
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Tips
|
||||
|
||||
1. **Batch Processing:** Process images in parallel using multiprocessing
|
||||
2. **Memory Efficiency:** Use generators, don't load all images at once
|
||||
3. **Disk I/O:** Use SSD, batch writes, memory-mapped files
|
||||
4. **Image Loading:** Use PIL with SIMD, or opencv for speed
|
||||
5. **Augmentation:** Apply on-the-fly during training (save disk space)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Consider saving augmentation config separately from applying augmentations
|
||||
- On-the-fly augmentation during training is often preferred over pre-generating
|
||||
- Keep original unaugmented test set for fair evaluation
|
||||
- Document any images excluded and reasons
|
||||
- Save random seeds for all operations
|
||||
- Phase 4 will select model architecture based on processed dataset size
|
||||
231
Docs/plant-identification-model-plan.md
Normal file
231
Docs/plant-identification-model-plan.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Plant Identification Core ML Model - Development Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Build a plant knowledge base from a curated plant list, then source/create an image dataset to train the Core ML model for visual plant identification.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Knowledge Base Creation from Plant List
|
||||
|
||||
**Goal:** Build structured plant knowledge from a curated plant list (CSV/JSON), enriching with taxonomy and characteristics.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 1.1 | Load and validate plant list file (CSV/JSON) |
|
||||
| 1.2 | Normalize and standardize plant names |
|
||||
| 1.3 | Create a master plant list with deduplicated entries |
|
||||
| 1.4 | Enrich with physical characteristics (leaf shape, flower color, height, etc.) |
|
||||
| 1.5 | Categorize plants by type (flower, tree, shrub, vegetable, herb, succulent) |
|
||||
| 1.6 | Map common names to scientific names (binomial nomenclature) |
|
||||
| 1.7 | Add regional/seasonal information from external sources |
|
||||
|
||||
**Deliverable:** Structured plant knowledge base (JSON/SQLite) with ~500-2000 plant entries
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Image Dataset Acquisition
|
||||
|
||||
**Goal:** Gather labeled plant images matching our knowledge base.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 2.1 | Research public plant image datasets (PlantCLEF, iNaturalist, PlantNet, Pl@ntNet) |
|
||||
| 2.2 | Cross-reference available datasets with Phase 1 plant list |
|
||||
| 2.3 | Download and organize images by species/category |
|
||||
| 2.4 | Establish minimum image count per class (target: 100+ images per plant) |
|
||||
| 2.5 | Identify gaps - plants in our knowledge base without sufficient images |
|
||||
| 2.6 | Source supplementary images for gap plants (Flickr API, Wikimedia Commons) |
|
||||
| 2.7 | Verify image quality and label accuracy (remove mislabeled/low-quality) |
|
||||
| 2.8 | Split dataset: 70% training, 15% validation, 15% test |
|
||||
|
||||
**Deliverable:** Labeled image dataset with 50,000-200,000 images across target plant classes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Dataset Preprocessing & Augmentation
|
||||
|
||||
**Goal:** Prepare images for training with consistent formatting and augmentation.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 3.1 | Standardize image dimensions (e.g., 224x224 or 299x299) |
|
||||
| 3.2 | Normalize color channels and handle various image formats |
|
||||
| 3.3 | Implement data augmentation pipeline (rotation, flip, brightness, crop) |
|
||||
| 3.4 | Create augmented variants to balance underrepresented classes |
|
||||
| 3.5 | Generate image manifest files mapping paths to labels |
|
||||
| 3.6 | Validate dataset integrity (no corrupted files, correct labels) |
|
||||
|
||||
**Deliverable:** Training-ready dataset with augmentation pipeline
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Model Architecture Selection
|
||||
|
||||
**Goal:** Choose and configure the optimal model architecture for on-device inference.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 4.1 | Evaluate architectures: MobileNetV3, EfficientNet-Lite, ResNet50, Vision Transformer |
|
||||
| 4.2 | Benchmark model size vs accuracy tradeoffs for mobile deployment |
|
||||
| 4.3 | Select base architecture (recommend: MobileNetV3 or EfficientNet-Lite for iOS) |
|
||||
| 4.4 | Configure transfer learning from ImageNet pretrained weights |
|
||||
| 4.5 | Design classification head for our plant class count |
|
||||
| 4.6 | Define target metrics: accuracy >85%, model size <50MB, inference <100ms |
|
||||
|
||||
**Deliverable:** Model architecture specification document
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Initial Training Run
|
||||
|
||||
**Goal:** Train baseline model and establish performance benchmarks.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 5.1 | Set up training environment (PyTorch/TensorFlow with GPU) |
|
||||
| 5.2 | Implement training loop with learning rate scheduling |
|
||||
| 5.3 | Train baseline model for 50 epochs |
|
||||
| 5.4 | Log training/validation loss and accuracy curves |
|
||||
| 5.5 | Evaluate on test set - document per-class accuracy |
|
||||
| 5.6 | Identify problematic classes (low accuracy, high confusion) |
|
||||
| 5.7 | Generate confusion matrix to find commonly confused plant pairs |
|
||||
|
||||
**Deliverable:** Baseline model with documented accuracy metrics
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Model Refinement & Iteration
|
||||
|
||||
**Goal:** Improve model through iterative refinement cycles.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 6.1 | Address class imbalance with weighted loss or oversampling |
|
||||
| 6.2 | Fine-tune hyperparameters (learning rate, batch size, dropout) |
|
||||
| 6.3 | Experiment with different augmentation strategies |
|
||||
| 6.4 | Add more training data for underperforming classes |
|
||||
| 6.5 | Consider hierarchical classification (family -> genus -> species) |
|
||||
| 6.6 | Implement hard negative mining for confused pairs |
|
||||
| 6.7 | Re-train and evaluate until target accuracy achieved |
|
||||
| 6.8 | Perform k-fold cross-validation for robust metrics |
|
||||
|
||||
**Deliverable:** Refined model meeting accuracy targets (>85% top-1, >95% top-5)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Core ML Conversion & Optimization
|
||||
|
||||
**Goal:** Convert trained model to Core ML format optimized for iOS.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 7.1 | Export trained model to ONNX or saved model format |
|
||||
| 7.2 | Convert to Core ML using coremltools |
|
||||
| 7.3 | Apply quantization (Float16 or Int8) to reduce model size |
|
||||
| 7.4 | Configure model metadata (class labels, input/output specs) |
|
||||
| 7.5 | Test converted model accuracy matches original |
|
||||
| 7.6 | Optimize for Neural Engine execution |
|
||||
| 7.7 | Benchmark inference speed on target devices (iPhone 12+) |
|
||||
|
||||
**Deliverable:** Optimized `.mlmodel` or `.mlpackage` file
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: iOS Integration Testing
|
||||
|
||||
**Goal:** Validate model performance in real iOS environment.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 8.1 | Create test iOS app with camera capture |
|
||||
| 8.2 | Integrate Core ML model with Vision framework |
|
||||
| 8.3 | Test with real-world plant photos (not from training set) |
|
||||
| 8.4 | Measure on-device inference latency |
|
||||
| 8.5 | Test edge cases (partial plants, multiple plants, poor lighting) |
|
||||
| 8.6 | Gather user feedback on identification accuracy |
|
||||
| 8.7 | Document failure modes and edge cases |
|
||||
|
||||
**Deliverable:** Validated model with real-world accuracy report
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Knowledge Integration
|
||||
|
||||
**Goal:** Combine visual model with plant knowledge base for rich results.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 9.1 | Link model class predictions to Phase 1 knowledge base |
|
||||
| 9.2 | Design result payload (name, description, care tips, characteristics) |
|
||||
| 9.3 | Add confidence thresholds and "unknown plant" handling |
|
||||
| 9.4 | Implement top-N predictions with confidence scores |
|
||||
| 9.5 | Create fallback for low-confidence identifications |
|
||||
|
||||
**Deliverable:** Complete plant identification system with rich metadata
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Final Validation & Documentation
|
||||
|
||||
**Goal:** Comprehensive testing and production readiness.
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| 10.1 | Run full test suite across diverse plant images |
|
||||
| 10.2 | Document supported plant list with accuracy per species |
|
||||
| 10.3 | Create model card (training data, limitations, biases) |
|
||||
| 10.4 | Write iOS integration guide |
|
||||
| 10.5 | Package final `.mlmodel` with metadata and labels |
|
||||
| 10.6 | Establish model versioning and update strategy |
|
||||
|
||||
**Deliverable:** Production-ready Core ML model with documentation
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Focus | Key Deliverable |
|
||||
|-------|-------|-----------------|
|
||||
| 1 | Knowledge Base Creation | Plant knowledge base from plant list |
|
||||
| 2 | Image Acquisition | Labeled dataset (50K-200K images) |
|
||||
| 3 | Preprocessing | Training-ready augmented dataset |
|
||||
| 4 | Architecture | Model design specification |
|
||||
| 5 | Initial Training | Baseline model + benchmarks |
|
||||
| 6 | Refinement | Optimized model (>85% accuracy) |
|
||||
| 7 | Core ML Conversion | Quantized `.mlmodel` file |
|
||||
| 8 | iOS Testing | Real-world validation report |
|
||||
| 9 | Knowledge Integration | Rich identification results |
|
||||
| 10 | Final Validation | Production-ready package |
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
The plant list provides **structured plant data** (names, characteristics) but visual identification requires image training data. The plan combines the plant knowledge base with external image datasets to create a complete plant identification system.
|
||||
|
||||
## Target Specifications
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Plant Classes | 200-500 species |
|
||||
| Top-1 Accuracy | >85% |
|
||||
| Top-5 Accuracy | >95% |
|
||||
| Model Size | <50MB |
|
||||
| Inference Time | <100ms on iPhone 12+ |
|
||||
|
||||
## Recommended Datasets
|
||||
|
||||
- **PlantCLEF** - Annual plant identification challenge dataset
|
||||
- **iNaturalist** - Community-sourced plant observations
|
||||
- **PlantNet** - Botanical research dataset
|
||||
- **Oxford Flowers** - 102 flower categories
|
||||
- **Wikimedia Commons** - Supplementary images
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
**MobileNetV3-Large** or **EfficientNet-Lite** for optimal balance of:
|
||||
- On-device performance
|
||||
- Model size constraints
|
||||
- Classification accuracy
|
||||
- Neural Engine compatibility
|
||||
167
Docs/save_shit.md
Normal file
167
Docs/save_shit.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plan: Persist PlantCareInfo in Core Data
|
||||
|
||||
## Overview
|
||||
Cache Trefle API care info locally so API is only called once per plant. Preserves all timing info (watering frequency, fertilizer schedule) for proper notification scheduling.
|
||||
|
||||
## Current Problem
|
||||
- `PlantCareInfo` is fetched from Trefle API every time `PlantDetailView` appears
|
||||
- No local caching - unnecessary API calls and poor offline experience
|
||||
|
||||
## Solution
|
||||
Add `PlantCareInfoMO` Core Data entity with cache-first logic in `FetchPlantCareUseCase`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Add Value Transformers for Complex Types
|
||||
**File:** `PlantGuide/Core/Utilities/ValueTransformers.swift`
|
||||
|
||||
Add JSON-based transformers (following existing `IdentificationResultArrayTransformer` pattern):
|
||||
- `WateringScheduleTransformer` - encodes `WateringSchedule` struct
|
||||
- `TemperatureRangeTransformer` - encodes `TemperatureRange` struct
|
||||
- `FertilizerScheduleTransformer` - encodes `FertilizerSchedule` struct
|
||||
- `SeasonArrayTransformer` - encodes `[Season]` array
|
||||
|
||||
Register all transformers in `PlantGuideApp.swift` init.
|
||||
|
||||
### Step 2: Update Core Data Model
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/PlantGuideModel.xcdatamodeld`
|
||||
|
||||
Add new entity `PlantCareInfoMO`:
|
||||
| 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 |
|
||||
|
||||
**Relationships:**
|
||||
- `plant` → `PlantMO` (optional, one-to-one, inverse: `plantCareInfo`)
|
||||
|
||||
**Update PlantMO:**
|
||||
- Add relationship `plantCareInfo` → `PlantCareInfoMO` (optional, cascade delete)
|
||||
|
||||
### Step 3: Create PlantCareInfoMO Managed Object
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantCareInfoMO.swift` (NEW)
|
||||
|
||||
- Define `@NSManaged` properties
|
||||
- Add `toDomainModel() -> PlantCareInfo?` - decodes JSON data to domain structs
|
||||
- Add `static func fromDomainModel(_:context:) -> PlantCareInfoMO?` - encodes domain to MO
|
||||
- Add `func update(from:)` - updates existing MO
|
||||
|
||||
### Step 4: Create Repository Protocol and Implementation
|
||||
**File:** `PlantGuide/Domain/RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` (NEW)
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/CoreDataPlantCareInfoStorage.swift` (NEW)
|
||||
|
||||
Implement repository with Core Data queries.
|
||||
|
||||
### Step 5: Update FetchPlantCareUseCase with Cache-First Logic
|
||||
**File:** `PlantGuide/Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift`
|
||||
|
||||
Modify to:
|
||||
1. Inject `PlantCareInfoRepositoryProtocol`
|
||||
2. Check cache first before API call
|
||||
3. Validate cache freshness (7-day expiration)
|
||||
4. Save API response to cache after fetch
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update DIContainer
|
||||
**File:** `PlantGuide/Core/DI/DIContainer.swift`
|
||||
|
||||
- Add `_plantCareInfoStorage` lazy service
|
||||
- Add `plantCareInfoRepository` accessor
|
||||
- Update `_fetchPlantCareUseCase` to inject repository
|
||||
- Add to `resetAll()` method
|
||||
|
||||
### Step 7: Update PlantMO
|
||||
**File:** `PlantGuide/Data/DataSources/Local/CoreData/ManagedObjects/PlantMO.swift`
|
||||
|
||||
Add relationship property:
|
||||
```swift
|
||||
@NSManaged public var plantCareInfo: PlantCareInfoMO?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `Core/Utilities/ValueTransformers.swift` | Add 4 transformers |
|
||||
| `PlantGuideModel.xcdatamodel/contents` | Add PlantCareInfoMO entity |
|
||||
| `ManagedObjects/PlantCareInfoMO.swift` | **NEW** - managed object + mappers |
|
||||
| `RepositoryInterfaces/PlantCareInfoRepositoryProtocol.swift` | **NEW** - protocol |
|
||||
| `CoreData/CoreDataPlantCareInfoStorage.swift` | **NEW** - implementation |
|
||||
| `UseCases/PlantCare/FetchPlantCareUseCase.swift` | Add cache-first logic |
|
||||
| `DI/DIContainer.swift` | Register new dependencies |
|
||||
| `ManagedObjects/PlantMO.swift` | Add relationship |
|
||||
| `App/PlantGuideApp.swift` | Register new transformers |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Build verification:** `xcodebuild -scheme PlantGuide build`
|
||||
|
||||
2. **Test cache behavior:**
|
||||
- Add a new plant → view details (should call Trefle API)
|
||||
- Navigate away and back to details (should NOT call API - use cache)
|
||||
- Check console logs for API calls
|
||||
|
||||
3. **Test timing preservation:**
|
||||
- Verify watering frequency `intervalDays` property works after cache retrieval
|
||||
- Create care schedule from cached info → verify notifications scheduled correctly
|
||||
|
||||
4. **Test cache expiration:**
|
||||
- Manually set `fetchedAt` to 8 days ago
|
||||
- View plant details → should re-fetch from API
|
||||
|
||||
5. **Run existing tests:** `xcodebuild test -scheme PlantGuide -destination 'platform=iOS Simulator,name=iPhone 17'`
|
||||
269
Docs/save_shit_implementation.md
Normal file
269
Docs/save_shit_implementation.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user