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:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

341
Docs/Phase1_plan.md Normal file
View 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`