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:
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2032
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'`
|
||||
@@ -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
|
||||
@@ -0,0 +1,604 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
1C4B79FA2F21C37C00ED69CF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 1C4B79E02F21C37A00ED69CF /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1C4B79E72F21C37A00ED69CF;
|
||||
remoteInfo = PlantGuide;
|
||||
};
|
||||
1C4B7A042F21C37C00ED69CF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 1C4B79E02F21C37A00ED69CF /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1C4B79E72F21C37A00ED69CF;
|
||||
remoteInfo = PlantGuide;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1C4B79E82F21C37A00ED69CF /* PlantGuide.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PlantGuide.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1C4B79F92F21C37C00ED69CF /* PlantGuideTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlantGuideTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1C4B7A032F21C37C00ED69CF /* PlantGuideUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PlantGuideUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
1C4B7A0B2F21C37C00ED69CF /* Exceptions for "PlantGuide" folder in "PlantGuide" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 1C4B79E72F21C37A00ED69CF /* PlantGuide */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1C4B79EA2F21C37A00ED69CF /* PlantGuide */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
1C4B7A0B2F21C37C00ED69CF /* Exceptions for "PlantGuide" folder in "PlantGuide" target */,
|
||||
);
|
||||
path = PlantGuide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PlantGuideTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = PlantGuideUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1C4B79E52F21C37A00ED69CF /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B79F62F21C37C00ED69CF /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B7A002F21C37C00ED69CF /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1C4B79DF2F21C37A00ED69CF = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1C4B79EA2F21C37A00ED69CF /* PlantGuide */,
|
||||
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */,
|
||||
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */,
|
||||
1C4B79E92F21C37A00ED69CF /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1C4B79E92F21C37A00ED69CF /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1C4B79E82F21C37A00ED69CF /* PlantGuide.app */,
|
||||
1C4B79F92F21C37C00ED69CF /* PlantGuideTests.xctest */,
|
||||
1C4B7A032F21C37C00ED69CF /* PlantGuideUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1C4B79E72F21C37A00ED69CF /* PlantGuide */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1C4B7A0C2F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuide" */;
|
||||
buildPhases = (
|
||||
1C4B79E42F21C37A00ED69CF /* Sources */,
|
||||
1C4B79E52F21C37A00ED69CF /* Frameworks */,
|
||||
1C4B79E62F21C37A00ED69CF /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1C4B79EA2F21C37A00ED69CF /* PlantGuide */,
|
||||
);
|
||||
name = PlantGuide;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PlantGuide;
|
||||
productReference = 1C4B79E82F21C37A00ED69CF /* PlantGuide.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
1C4B79F82F21C37C00ED69CF /* PlantGuideTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1C4B7A112F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuideTests" */;
|
||||
buildPhases = (
|
||||
1C4B79F52F21C37C00ED69CF /* Sources */,
|
||||
1C4B79F62F21C37C00ED69CF /* Frameworks */,
|
||||
1C4B79F72F21C37C00ED69CF /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1C4B79FB2F21C37C00ED69CF /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1C4B79FC2F21C37C00ED69CF /* PlantGuideTests */,
|
||||
);
|
||||
name = PlantGuideTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PlantGuideTests;
|
||||
productReference = 1C4B79F92F21C37C00ED69CF /* PlantGuideTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
1C4B7A022F21C37C00ED69CF /* PlantGuideUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1C4B7A142F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuideUITests" */;
|
||||
buildPhases = (
|
||||
1C4B79FF2F21C37C00ED69CF /* Sources */,
|
||||
1C4B7A002F21C37C00ED69CF /* Frameworks */,
|
||||
1C4B7A012F21C37C00ED69CF /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1C4B7A052F21C37C00ED69CF /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1C4B7A062F21C37C00ED69CF /* PlantGuideUITests */,
|
||||
);
|
||||
name = PlantGuideUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = PlantGuideUITests;
|
||||
productReference = 1C4B7A032F21C37C00ED69CF /* PlantGuideUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1C4B79E02F21C37A00ED69CF /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
1C4B79E72F21C37A00ED69CF = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
1C4B79F82F21C37C00ED69CF = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 1C4B79E72F21C37A00ED69CF;
|
||||
};
|
||||
1C4B7A022F21C37C00ED69CF = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 1C4B79E72F21C37A00ED69CF;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1C4B79E32F21C37A00ED69CF /* Build configuration list for PBXProject "PlantGuide" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 1C4B79DF2F21C37A00ED69CF;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 1C4B79E92F21C37A00ED69CF /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1C4B79E72F21C37A00ED69CF /* PlantGuide */,
|
||||
1C4B79F82F21C37C00ED69CF /* PlantGuideTests */,
|
||||
1C4B7A022F21C37C00ED69CF /* PlantGuideUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1C4B79E62F21C37A00ED69CF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B79F72F21C37C00ED69CF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B7A012F21C37C00ED69CF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1C4B79E42F21C37A00ED69CF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B79F52F21C37C00ED69CF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1C4B79FF2F21C37C00ED69CF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
1C4B79FB2F21C37C00ED69CF /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1C4B79E72F21C37A00ED69CF /* PlantGuide */;
|
||||
targetProxy = 1C4B79FA2F21C37C00ED69CF /* PBXContainerItemProxy */;
|
||||
};
|
||||
1C4B7A052F21C37C00ED69CF /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1C4B79E72F21C37A00ED69CF /* PlantGuide */;
|
||||
targetProxy = 1C4B7A042F21C37C00ED69CF /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1C4B7A0D2F21C37C00ED69CF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = 1C4B79EA2F21C37A00ED69CF /* PlantGuide */;
|
||||
baseConfigurationReferenceRelativePath = Configuration/Debug.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PlantGuide/PlantGuide.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PlantGuide/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuide";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1C4B7A0E2F21C37C00ED69CF /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReferenceAnchor = 1C4B79EA2F21C37A00ED69CF /* PlantGuide */;
|
||||
baseConfigurationReferenceRelativePath = Configuration/Release.xcconfig;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = PlantGuide/PlantGuide.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = PlantGuide/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuide";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1C4B7A0F2F21C37C00ED69CF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1C4B7A102F21C37C00ED69CF /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1C4B7A122F21C37C00ED69CF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuideTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlantGuide.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PlantGuide";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1C4B7A132F21C37C00ED69CF /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuideTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PlantGuide.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PlantGuide";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1C4B7A152F21C37C00ED69CF /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuideUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = PlantGuide;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1C4B7A162F21C37C00ED69CF /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.PlantGuideUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = PlantGuide;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1C4B79E32F21C37A00ED69CF /* Build configuration list for PBXProject "PlantGuide" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1C4B7A0F2F21C37C00ED69CF /* Debug */,
|
||||
1C4B7A102F21C37C00ED69CF /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1C4B7A0C2F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuide" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1C4B7A0D2F21C37C00ED69CF /* Debug */,
|
||||
1C4B7A0E2F21C37C00ED69CF /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1C4B7A112F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuideTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1C4B7A122F21C37C00ED69CF /* Debug */,
|
||||
1C4B7A132F21C37C00ED69CF /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1C4B7A142F21C37C00ED69CF /* Build configuration list for PBXNativeTarget "PlantGuideUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1C4B7A152F21C37C00ED69CF /* Debug */,
|
||||
1C4B7A162F21C37C00ED69CF /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 1C4B79E02F21C37A00ED69CF /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1C4B79E72F21C37A00ED69CF"
|
||||
BuildableName = "PlantGuide.app"
|
||||
BlueprintName = "PlantGuide"
|
||||
ReferencedContainer = "container:PlantGuide.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1C4B79F82F21C37C00ED69CF"
|
||||
BuildableName = "PlantGuideTests.xctest"
|
||||
BlueprintName = "PlantGuideTests"
|
||||
ReferencedContainer = "container:PlantGuide.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1C4B7A022F21C37C00ED69CF"
|
||||
BuildableName = "PlantGuideUITests.xctest"
|
||||
BlueprintName = "PlantGuideUITests"
|
||||
ReferencedContainer = "container:PlantGuide.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1C4B79E72F21C37A00ED69CF"
|
||||
BuildableName = "PlantGuide.app"
|
||||
BlueprintName = "PlantGuide"
|
||||
ReferencedContainer = "container:PlantGuide.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1C4B79E72F21C37A00ED69CF"
|
||||
BuildableName = "PlantGuide.app"
|
||||
BlueprintName = "PlantGuide"
|
||||
ReferencedContainer = "container:PlantGuide.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// APIKeys.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Keys Configuration
|
||||
|
||||
/// Provides secure access to API keys loaded from build configuration.
|
||||
///
|
||||
/// API keys are loaded from Info.plist, which reads values set via xcconfig files.
|
||||
/// This approach keeps sensitive keys out of source control while allowing
|
||||
/// different configurations for Debug and Release builds.
|
||||
///
|
||||
/// Setup:
|
||||
/// 1. Add your API key to the appropriate xcconfig file (Debug.xcconfig or Release.xcconfig)
|
||||
/// 2. The key will be automatically available through this enum
|
||||
enum APIKeys: Sendable {
|
||||
|
||||
// MARK: - PlantNet API
|
||||
|
||||
/// The PlantNet API key for plant identification requests.
|
||||
///
|
||||
/// - Returns: The API key string if configured.
|
||||
/// - Note: In development, returns a placeholder message if not configured.
|
||||
static var plantNetAPIKey: String {
|
||||
guard let key = Bundle.main.infoDictionary?["PLANTNET_API_KEY"] as? String,
|
||||
!key.isEmpty,
|
||||
key != "your_api_key_here" else {
|
||||
#if DEBUG
|
||||
assertionFailure(
|
||||
"""
|
||||
PlantNet API key not configured.
|
||||
|
||||
To configure:
|
||||
1. Open PlantGuide/Configuration/Debug.xcconfig
|
||||
2. Replace 'your_api_key_here' with your actual PlantNet API key
|
||||
3. Get your API key from: https://my.plantnet.org/
|
||||
"""
|
||||
)
|
||||
#endif
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/// Checks whether the PlantNet API key is properly configured.
|
||||
static var isPlantNetKeyConfigured: Bool {
|
||||
guard let key = Bundle.main.infoDictionary?["PLANTNET_API_KEY"] as? String,
|
||||
!key.isEmpty,
|
||||
key != "your_api_key_here" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Trefle API
|
||||
|
||||
/// The Trefle API token for plant data requests.
|
||||
///
|
||||
/// - Returns: The API token string if configured.
|
||||
/// - Note: In development, returns a placeholder message if not configured.
|
||||
static var trefleAPIToken: String {
|
||||
guard let token = Bundle.main.infoDictionary?["TREFLE_API_TOKEN"] as? String,
|
||||
!token.isEmpty,
|
||||
token != "your_api_token_here" else {
|
||||
#if DEBUG
|
||||
assertionFailure(
|
||||
"""
|
||||
Trefle API token not configured.
|
||||
|
||||
To configure:
|
||||
1. Open PlantGuide/Configuration/Debug.xcconfig
|
||||
2. Replace 'your_api_token_here' with your actual Trefle API token
|
||||
3. Get your API token from: https://trefle.io/
|
||||
"""
|
||||
)
|
||||
#endif
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// Checks whether the Trefle API token is properly configured.
|
||||
static var isTrefleTokenConfigured: Bool {
|
||||
guard let token = Bundle.main.infoDictionary?["TREFLE_API_TOKEN"] as? String,
|
||||
!token.isEmpty,
|
||||
token != "your_api_token_here" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Debug.xcconfig
|
||||
// PlantGuide
|
||||
//
|
||||
// Configuration for Debug builds.
|
||||
// API keys and environment-specific settings go here.
|
||||
//
|
||||
// IMPORTANT: Do not commit real API keys to source control.
|
||||
// Add this file to .gitignore or use a separate secrets.xcconfig.
|
||||
//
|
||||
|
||||
// MARK: - API Keys
|
||||
|
||||
// PlantNet API key for plant identification
|
||||
// Get your key from: https://my.plantnet.org/
|
||||
PLANTNET_API_KEY = 2b10NEGntgT5U4NYWukK63Lu
|
||||
|
||||
// Trefle API token for plant care data
|
||||
// Get your token from: https://trefle.io/
|
||||
TREFLE_API_TOKEN = usr-AfrMS_o4qJ3ZBYML9upiz8UQ8Uv4cJ_tQgkXsK4xt_E
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Debug.xcconfig.example
|
||||
// PlantGuide
|
||||
//
|
||||
// Configuration template for Debug builds.
|
||||
// Copy this file to Debug.xcconfig and fill in your API keys.
|
||||
//
|
||||
// SETUP:
|
||||
// 1. Copy this file: cp Debug.xcconfig.example Debug.xcconfig
|
||||
// 2. Replace placeholder values with your actual API keys
|
||||
// 3. Never commit Debug.xcconfig to source control
|
||||
//
|
||||
|
||||
// MARK: - API Keys
|
||||
|
||||
// PlantNet API key for plant identification
|
||||
// Get your key from: https://my.plantnet.org/
|
||||
PLANTNET_API_KEY = your_plantnet_api_key_here
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Release.xcconfig
|
||||
// PlantGuide
|
||||
//
|
||||
// Configuration for Release builds.
|
||||
// API keys and environment-specific settings go here.
|
||||
//
|
||||
// IMPORTANT: Do not commit real API keys to source control.
|
||||
// For CI/CD, inject keys via environment variables or secure secrets management.
|
||||
//
|
||||
|
||||
// MARK: - API Keys
|
||||
|
||||
// PlantNet API key for plant identification
|
||||
// In production, this should be injected by your CI/CD pipeline
|
||||
PLANTNET_API_KEY = your_api_key_here
|
||||
@@ -0,0 +1,661 @@
|
||||
//
|
||||
// DIContainer.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 1/21/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
// MARK: - DI Container Protocol
|
||||
|
||||
/// Protocol defining the contract for dependency injection container
|
||||
@MainActor
|
||||
protocol DIContainerProtocol: AnyObject, Sendable {
|
||||
// MARK: - ViewModels Factory
|
||||
func makeCameraViewModel() -> CameraViewModel
|
||||
func makeIdentificationViewModel(image: UIImage) -> IdentificationViewModel
|
||||
func makePlantDetailViewModel(plant: Plant) -> PlantDetailViewModel
|
||||
func makeCareScheduleViewModel() -> CareScheduleViewModel
|
||||
func makeCollectionViewModel() -> CollectionViewModel
|
||||
func makeSettingsViewModel() -> SettingsViewModel
|
||||
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel
|
||||
|
||||
// MARK: - Registration
|
||||
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T)
|
||||
func resolve<T>(type: T.Type) -> T?
|
||||
}
|
||||
|
||||
// MARK: - Lazy Service Wrapper
|
||||
|
||||
// MARK: - Thread Safety
|
||||
// LazyService is @unchecked Sendable because:
|
||||
// - All mutable state (_value) is protected by NSLock
|
||||
// - The factory closure is only called once under lock protection
|
||||
// - Once initialized, _value is read-only (never mutated except under lock)
|
||||
|
||||
/// Thread-safe lazy initialization wrapper for services
|
||||
@MainActor
|
||||
final class LazyService<T>: @unchecked Sendable {
|
||||
private var _value: T?
|
||||
private let factory: @MainActor () -> T
|
||||
private let lock = NSLock()
|
||||
|
||||
init(factory: @escaping @MainActor () -> T) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
var value: T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if let existing = _value {
|
||||
return existing
|
||||
}
|
||||
|
||||
let newValue = factory()
|
||||
_value = newValue
|
||||
return newValue
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_value = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DI Container Implementation
|
||||
|
||||
/// Main dependency injection container for the PlantGuide app
|
||||
/// Uses lazy initialization for efficient memory usage and proper lifecycle management
|
||||
@MainActor
|
||||
final class DIContainer: DIContainerProtocol, ObservableObject {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = DIContainer()
|
||||
|
||||
// MARK: - Custom Registration Storage
|
||||
|
||||
private var factories: [String: @MainActor () -> Any] = [:]
|
||||
private var resolvedInstances: [String: Any] = [:]
|
||||
|
||||
// MARK: - Lazy Service Containers
|
||||
|
||||
private lazy var _cameraService: LazyService<CameraService> = {
|
||||
LazyService {
|
||||
CameraService()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _imagePreprocessor: LazyService<ImagePreprocessor> = {
|
||||
LazyService {
|
||||
ImagePreprocessor()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _plantClassificationService: LazyService<PlantClassificationService> = {
|
||||
LazyService {
|
||||
PlantClassificationService()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _plantLabelService: LazyService<PlantLabelService> = {
|
||||
LazyService {
|
||||
PlantLabelService()
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Phase 3 Services
|
||||
|
||||
private lazy var _networkMonitor: LazyService<NetworkMonitor> = {
|
||||
LazyService {
|
||||
NetworkMonitor()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _rateLimitTracker: LazyService<RateLimitTracker> = {
|
||||
LazyService {
|
||||
RateLimitTracker()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _plantNetAPIService: LazyService<PlantNetAPIService> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return PlantNetAPIService.configured(
|
||||
apiKey: APIKeys.plantNetAPIKey,
|
||||
rateLimitTracker: self.rateLimitTracker
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _identificationCache: LazyService<IdentificationCache> = {
|
||||
LazyService {
|
||||
IdentificationCache()
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Phase 4 Services
|
||||
|
||||
private lazy var _trefleAPIService: LazyService<TrefleAPIService> = {
|
||||
LazyService {
|
||||
TrefleAPIService.configured()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _notificationService: LazyService<NotificationService> = {
|
||||
LazyService {
|
||||
NotificationService()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _fetchPlantCareUseCase: LazyService<FetchPlantCareUseCase> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return FetchPlantCareUseCase(
|
||||
trefleAPIService: self.trefleAPIService,
|
||||
cacheRepository: self.plantCareInfoRepository
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _createCareScheduleUseCase: LazyService<CreateCareScheduleUseCase> = {
|
||||
LazyService {
|
||||
CreateCareScheduleUseCase()
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Phase 5 Services
|
||||
|
||||
private lazy var _imageCache: LazyService<ImageCache> = {
|
||||
LazyService {
|
||||
ImageCache()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _localImageStorage: LazyService<LocalImageStorage> = {
|
||||
LazyService {
|
||||
LocalImageStorage()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _coreDataPlantStorage: LazyService<CoreDataPlantStorage> = {
|
||||
LazyService {
|
||||
CoreDataPlantStorage(coreDataStack: CoreDataStack.shared)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _coreDataCareScheduleStorage: LazyService<CoreDataCareScheduleStorage> = {
|
||||
LazyService {
|
||||
CoreDataCareScheduleStorage(coreDataStack: CoreDataStack.shared)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _coreDataPlantCareInfoStorage: LazyService<CoreDataPlantCareInfoStorage> = {
|
||||
LazyService {
|
||||
CoreDataPlantCareInfoStorage(coreDataStack: CoreDataStack.shared)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Local Plant Database Services
|
||||
|
||||
private lazy var _plantDatabaseService: LazyService<PlantDatabaseService> = {
|
||||
LazyService {
|
||||
PlantDatabaseService()
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _lookupPlantUseCase: LazyService<LookupPlantUseCase> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return LookupPlantUseCase(databaseService: self.plantDatabaseService)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Identification Use Cases
|
||||
|
||||
private lazy var _identifyPlantOnDeviceUseCase: LazyService<IdentifyPlantOnDeviceUseCase> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return IdentifyPlantOnDeviceUseCase(
|
||||
imagePreprocessor: self.imagePreprocessor,
|
||||
classificationService: self.plantClassificationService
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
private lazy var _identifyPlantOnlineUseCase: LazyService<IdentifyPlantOnlineUseCase> = {
|
||||
LazyService { [weak self] in
|
||||
guard let self else {
|
||||
fatalError("DIContainer deallocated unexpectedly")
|
||||
}
|
||||
return IdentifyPlantOnlineUseCase(apiService: self.plantNetAPIService)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Repositories
|
||||
|
||||
var careScheduleRepository: CareScheduleRepositoryProtocol {
|
||||
_coreDataCareScheduleStorage.value
|
||||
}
|
||||
|
||||
var plantRepository: PlantRepositoryProtocol {
|
||||
_coreDataPlantStorage.value
|
||||
}
|
||||
|
||||
/// Plant collection repository backed by Core Data
|
||||
var plantCollectionRepository: PlantCollectionRepositoryProtocol {
|
||||
_coreDataPlantStorage.value
|
||||
}
|
||||
|
||||
/// Favorite plant repository backed by Core Data
|
||||
var favoritePlantRepository: FavoritePlantRepositoryProtocol {
|
||||
_coreDataPlantStorage.value
|
||||
}
|
||||
|
||||
/// Plant care info repository backed by Core Data (for caching Trefle API responses)
|
||||
var plantCareInfoRepository: PlantCareInfoRepositoryProtocol {
|
||||
_coreDataPlantCareInfoStorage.value
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
var cameraService: CameraService {
|
||||
_cameraService.value
|
||||
}
|
||||
|
||||
var imagePreprocessor: ImagePreprocessor {
|
||||
_imagePreprocessor.value
|
||||
}
|
||||
|
||||
var plantClassificationService: PlantClassificationService {
|
||||
_plantClassificationService.value
|
||||
}
|
||||
|
||||
var plantLabelService: PlantLabelService {
|
||||
_plantLabelService.value
|
||||
}
|
||||
|
||||
// MARK: - Phase 3 Services Accessors
|
||||
|
||||
var networkMonitor: NetworkMonitor {
|
||||
_networkMonitor.value
|
||||
}
|
||||
|
||||
var rateLimitTracker: RateLimitTracker {
|
||||
_rateLimitTracker.value
|
||||
}
|
||||
|
||||
var plantNetAPIService: PlantNetAPIService {
|
||||
_plantNetAPIService.value
|
||||
}
|
||||
|
||||
var identificationCache: IdentificationCache {
|
||||
_identificationCache.value
|
||||
}
|
||||
|
||||
// MARK: - Phase 4 Services Accessors
|
||||
|
||||
var trefleAPIService: TrefleAPIService {
|
||||
_trefleAPIService.value
|
||||
}
|
||||
|
||||
var notificationService: NotificationService {
|
||||
_notificationService.value
|
||||
}
|
||||
|
||||
var fetchPlantCareUseCase: FetchPlantCareUseCase {
|
||||
_fetchPlantCareUseCase.value
|
||||
}
|
||||
|
||||
var createCareScheduleUseCase: CreateCareScheduleUseCase {
|
||||
_createCareScheduleUseCase.value
|
||||
}
|
||||
|
||||
// MARK: - Phase 5 Services Accessors
|
||||
|
||||
/// Thread-safe image cache with memory and disk storage
|
||||
var imageCache: ImageCacheProtocol {
|
||||
_imageCache.value
|
||||
}
|
||||
|
||||
/// Local image storage for user-captured plant photos
|
||||
var imageStorage: ImageStorageProtocol {
|
||||
_localImageStorage.value
|
||||
}
|
||||
|
||||
/// Core Data-backed plant storage
|
||||
var coreDataPlantStorage: CoreDataPlantStorage {
|
||||
_coreDataPlantStorage.value
|
||||
}
|
||||
|
||||
/// Core Data-backed care schedule storage
|
||||
var coreDataCareScheduleStorage: CoreDataCareScheduleStorage {
|
||||
_coreDataCareScheduleStorage.value
|
||||
}
|
||||
|
||||
// MARK: - Local Plant Database Accessors
|
||||
|
||||
/// Local plant database service (singleton)
|
||||
var plantDatabaseService: PlantDatabaseService {
|
||||
_plantDatabaseService.value
|
||||
}
|
||||
|
||||
/// Use case for looking up plants in the local database
|
||||
var lookupPlantUseCase: LookupPlantUseCaseProtocol {
|
||||
_lookupPlantUseCase.value
|
||||
}
|
||||
|
||||
// MARK: - Identification Use Cases Accessors
|
||||
|
||||
/// Use case for on-device plant identification
|
||||
var identifyPlantOnDeviceUseCase: IdentifyPlantUseCaseProtocol {
|
||||
_identifyPlantOnDeviceUseCase.value
|
||||
}
|
||||
|
||||
/// Use case for online plant identification via PlantNet API
|
||||
var identifyPlantOnlineUseCase: IdentifyPlantOnlineUseCaseProtocol {
|
||||
_identifyPlantOnlineUseCase.value
|
||||
}
|
||||
|
||||
/// Creates a hybrid identification use case with the specified strategy
|
||||
func makeHybridIdentificationUseCase() -> HybridIdentificationUseCase {
|
||||
HybridIdentificationUseCase(
|
||||
onDeviceUseCase: identifyPlantOnDeviceUseCase,
|
||||
onlineUseCase: identifyPlantOnlineUseCase,
|
||||
networkMonitor: networkMonitor
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Phase 5 Use Cases
|
||||
|
||||
/// Factory property for FetchCollectionUseCase
|
||||
var fetchCollectionUseCase: FetchCollectionUseCaseProtocol {
|
||||
FetchCollectionUseCase(
|
||||
plantRepository: plantCollectionRepository,
|
||||
careScheduleRepository: careScheduleRepository
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory property for SavePlantUseCase
|
||||
var savePlantUseCase: SavePlantUseCaseProtocol {
|
||||
SavePlantUseCase(
|
||||
plantRepository: plantCollectionRepository,
|
||||
imageStorage: imageStorage,
|
||||
notificationService: notificationService,
|
||||
createCareScheduleUseCase: createCareScheduleUseCase,
|
||||
careScheduleRepository: careScheduleRepository
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory property for DeletePlantUseCase
|
||||
var deletePlantUseCase: DeletePlantUseCaseProtocol {
|
||||
DeletePlantUseCase(
|
||||
plantRepository: plantCollectionRepository,
|
||||
imageStorage: imageStorage,
|
||||
notificationService: notificationService,
|
||||
careScheduleRepository: careScheduleRepository
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory property for ToggleFavoriteUseCase
|
||||
var toggleFavoriteUseCase: ToggleFavoriteUseCaseProtocol {
|
||||
ToggleFavoriteUseCase(
|
||||
plantRepository: favoritePlantRepository
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods - ViewModels
|
||||
|
||||
func makeCameraViewModel() -> CameraViewModel {
|
||||
CameraViewModel(cameraService: cameraService)
|
||||
}
|
||||
|
||||
func makeIdentificationViewModel(image: UIImage) -> IdentificationViewModel {
|
||||
// Get the user's preferred identification method from settings
|
||||
let methodString = UserDefaults.standard.string(forKey: "settings_preferred_identification_method")
|
||||
let method = methodString.flatMap { IdentificationMethod(rawValue: $0) } ?? .hybrid
|
||||
|
||||
// Select the appropriate use case based on settings
|
||||
let identifyUseCase: IdentifyPlantUseCaseProtocol
|
||||
switch method {
|
||||
case .onDevice:
|
||||
identifyUseCase = identifyPlantOnDeviceUseCase
|
||||
case .apiOnly:
|
||||
identifyUseCase = _identifyPlantOnlineUseCase.value
|
||||
case .hybrid:
|
||||
// Wrap hybrid use case to conform to IdentifyPlantUseCaseProtocol
|
||||
identifyUseCase = HybridIdentificationUseCaseWrapper(
|
||||
hybridUseCase: makeHybridIdentificationUseCase(),
|
||||
strategy: .onDeviceFirst(apiThreshold: 0.7)
|
||||
)
|
||||
}
|
||||
|
||||
return IdentificationViewModel(
|
||||
image: image,
|
||||
identifyPlantUseCase: identifyUseCase,
|
||||
lookupPlantUseCase: lookupPlantUseCase,
|
||||
savePlantUseCase: savePlantUseCase
|
||||
)
|
||||
}
|
||||
|
||||
func makePlantDetailViewModel(plant: Plant) -> PlantDetailViewModel {
|
||||
PlantDetailViewModel(
|
||||
plant: plant,
|
||||
fetchPlantCareUseCase: fetchPlantCareUseCase,
|
||||
createCareScheduleUseCase: createCareScheduleUseCase,
|
||||
careScheduleRepository: careScheduleRepository,
|
||||
notificationService: notificationService
|
||||
)
|
||||
}
|
||||
|
||||
func makeCareScheduleViewModel() -> CareScheduleViewModel {
|
||||
CareScheduleViewModel(
|
||||
careScheduleRepository: careScheduleRepository,
|
||||
plantRepository: plantRepository
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory method for CollectionViewModel
|
||||
func makeCollectionViewModel() -> CollectionViewModel {
|
||||
CollectionViewModel(
|
||||
fetchCollectionUseCase: fetchCollectionUseCase,
|
||||
toggleFavoriteUseCase: toggleFavoriteUseCase
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory method for SettingsViewModel
|
||||
func makeSettingsViewModel() -> SettingsViewModel {
|
||||
SettingsViewModel(
|
||||
networkMonitor: networkMonitor,
|
||||
imageCache: imageCache,
|
||||
identificationCache: identificationCache,
|
||||
rateLimitTracker: rateLimitTracker,
|
||||
coreDataStack: CoreDataStack.shared,
|
||||
imageStorage: _localImageStorage.value
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory method for BrowsePlantsViewModel
|
||||
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel {
|
||||
BrowsePlantsViewModel(databaseService: plantDatabaseService)
|
||||
}
|
||||
|
||||
// MARK: - Custom Registration
|
||||
|
||||
/// Register a custom factory for a type
|
||||
/// - Parameters:
|
||||
/// - type: The type to register
|
||||
/// - factory: The factory closure to create instances
|
||||
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
|
||||
let key = String(describing: type)
|
||||
factories[key] = factory
|
||||
resolvedInstances.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Resolve a registered type
|
||||
/// - Parameter type: The type to resolve
|
||||
/// - Returns: An instance of the type if registered, nil otherwise
|
||||
func resolve<T>(type: T.Type) -> T? {
|
||||
let key = String(describing: type)
|
||||
|
||||
if let existing = resolvedInstances[key] as? T {
|
||||
return existing
|
||||
}
|
||||
|
||||
if let factory = factories[key] {
|
||||
let instance = factory() as? T
|
||||
if let instance {
|
||||
resolvedInstances[key] = instance
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Reset (for testing)
|
||||
|
||||
/// Reset all lazy services - useful for testing
|
||||
func resetAll() {
|
||||
_cameraService.reset()
|
||||
_imagePreprocessor.reset()
|
||||
_plantClassificationService.reset()
|
||||
_plantLabelService.reset()
|
||||
_networkMonitor.reset()
|
||||
_rateLimitTracker.reset()
|
||||
_plantNetAPIService.reset()
|
||||
_identificationCache.reset()
|
||||
_trefleAPIService.reset()
|
||||
_notificationService.reset()
|
||||
_fetchPlantCareUseCase.reset()
|
||||
_createCareScheduleUseCase.reset()
|
||||
// Phase 5 services
|
||||
_imageCache.reset()
|
||||
_localImageStorage.reset()
|
||||
_coreDataPlantStorage.reset()
|
||||
_coreDataCareScheduleStorage.reset()
|
||||
_coreDataPlantCareInfoStorage.reset()
|
||||
// Local plant database services
|
||||
_plantDatabaseService.reset()
|
||||
_lookupPlantUseCase.reset()
|
||||
// Identification use cases
|
||||
_identifyPlantOnDeviceUseCase.reset()
|
||||
_identifyPlantOnlineUseCase.reset()
|
||||
factories.removeAll()
|
||||
resolvedInstances.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Environment Integration
|
||||
|
||||
/// Environment key for the DI container
|
||||
private struct DIContainerKey: EnvironmentKey {
|
||||
@MainActor
|
||||
static let defaultValue: DIContainerProtocol = DIContainer.shared
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var diContainer: DIContainerProtocol {
|
||||
get { self[DIContainerKey.self] }
|
||||
set { self[DIContainerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Inject a custom DI container into the view hierarchy
|
||||
func diContainer(_ container: DIContainerProtocol) -> some View {
|
||||
environment(\.diContainer, container)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationUseCaseWrapper
|
||||
|
||||
/// Wrapper that adapts HybridIdentificationUseCase to IdentifyPlantUseCaseProtocol
|
||||
struct HybridIdentificationUseCaseWrapper: IdentifyPlantUseCaseProtocol {
|
||||
private let hybridUseCase: HybridIdentificationUseCase
|
||||
private let strategy: HybridStrategy
|
||||
|
||||
init(hybridUseCase: HybridIdentificationUseCase, strategy: HybridStrategy) {
|
||||
self.hybridUseCase = hybridUseCase
|
||||
self.strategy = strategy
|
||||
}
|
||||
|
||||
func execute(image: UIImage) async throws -> [ViewPlantPrediction] {
|
||||
let result = try await hybridUseCase.execute(image: image, strategy: strategy)
|
||||
return result.predictions
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Container for Testing/Previews
|
||||
|
||||
/// Mock DI container for testing and SwiftUI previews
|
||||
@MainActor
|
||||
final class MockDIContainer: DIContainerProtocol {
|
||||
|
||||
private var factories: [String: @MainActor () -> Any] = [:]
|
||||
private var instances: [String: Any] = [:]
|
||||
|
||||
func makeCameraViewModel() -> CameraViewModel {
|
||||
CameraViewModel()
|
||||
}
|
||||
|
||||
func makeIdentificationViewModel(image: UIImage) -> IdentificationViewModel {
|
||||
IdentificationViewModel(image: image)
|
||||
}
|
||||
|
||||
func makePlantDetailViewModel(plant: Plant) -> PlantDetailViewModel {
|
||||
PlantDetailViewModel(plant: plant)
|
||||
}
|
||||
|
||||
func makeCareScheduleViewModel() -> CareScheduleViewModel {
|
||||
CareScheduleViewModel(
|
||||
careScheduleRepository: InMemoryCareScheduleRepository.shared,
|
||||
plantRepository: InMemoryPlantRepository.shared
|
||||
)
|
||||
}
|
||||
|
||||
func makeCollectionViewModel() -> CollectionViewModel {
|
||||
CollectionViewModel()
|
||||
}
|
||||
|
||||
func makeSettingsViewModel() -> SettingsViewModel {
|
||||
SettingsViewModel()
|
||||
}
|
||||
|
||||
func makeBrowsePlantsViewModel() -> BrowsePlantsViewModel {
|
||||
BrowsePlantsViewModel(databaseService: PlantDatabaseService())
|
||||
}
|
||||
|
||||
func register<T>(type: T.Type, factory: @escaping @MainActor () -> T) {
|
||||
factories[String(describing: type)] = factory
|
||||
}
|
||||
|
||||
func resolve<T>(type: T.Type) -> T? {
|
||||
let key = String(describing: type)
|
||||
if let existing = instances[key] as? T {
|
||||
return existing
|
||||
}
|
||||
if let factory = factories[key], let instance = factory() as? T {
|
||||
instances[key] = instance
|
||||
return instance
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//
|
||||
// AppError.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AppError
|
||||
|
||||
/// Unified error type for the PlantGuide application.
|
||||
///
|
||||
/// Provides comprehensive error handling across all app domains including
|
||||
/// network operations, plant identification, API interactions, persistence,
|
||||
/// and care management.
|
||||
///
|
||||
/// Each error case includes user-friendly messaging and recovery suggestions
|
||||
/// to help users understand and resolve issues.
|
||||
public enum AppError: Error, Sendable, Equatable {
|
||||
// MARK: - Network Errors
|
||||
|
||||
/// The device has no network connectivity.
|
||||
case networkUnavailable
|
||||
|
||||
/// The network request timed out.
|
||||
case networkTimeout
|
||||
|
||||
/// The server returned an error response.
|
||||
case serverError(statusCode: Int)
|
||||
|
||||
/// The server returned an invalid or unexpected response.
|
||||
case invalidResponse
|
||||
|
||||
// MARK: - Identification Errors
|
||||
|
||||
/// Camera access was denied by the user.
|
||||
case cameraAccessDenied
|
||||
|
||||
/// Photo library access was denied by the user.
|
||||
case photoLibraryAccessDenied
|
||||
|
||||
/// The plant identification process failed.
|
||||
case identificationFailed(reason: String)
|
||||
|
||||
/// No plant was detected in the provided image.
|
||||
case noPlantDetected
|
||||
|
||||
/// The identification confidence level is too low to provide reliable results.
|
||||
case lowConfidence
|
||||
|
||||
/// The ML model failed to load or is not available.
|
||||
case modelNotLoaded
|
||||
|
||||
// MARK: - API Errors
|
||||
|
||||
/// The required API key is missing or not configured.
|
||||
case apiKeyMissing
|
||||
|
||||
/// The API rate limit has been exceeded.
|
||||
case rateLimitExceeded
|
||||
|
||||
/// The API quota has been exhausted.
|
||||
case quotaExhausted
|
||||
|
||||
/// The API service is currently unavailable.
|
||||
case apiUnavailable
|
||||
|
||||
// MARK: - Persistence Errors
|
||||
|
||||
/// Failed to save data to storage.
|
||||
case saveFailed(reason: String)
|
||||
|
||||
/// Failed to fetch data from storage.
|
||||
case fetchFailed(reason: String)
|
||||
|
||||
/// Failed to delete data from storage.
|
||||
case deleteFailed(reason: String)
|
||||
|
||||
/// The stored data is corrupted or in an unexpected format.
|
||||
case dataCorrupted
|
||||
|
||||
// MARK: - Care Errors
|
||||
|
||||
/// Notification permission was denied by the user.
|
||||
case notificationPermissionDenied
|
||||
|
||||
/// Failed to create a care schedule.
|
||||
case scheduleCreationFailed(reason: String)
|
||||
|
||||
/// Care data is not available for the plant.
|
||||
case careDataUnavailable
|
||||
|
||||
// MARK: - Unknown Error
|
||||
|
||||
/// An unknown or unexpected error occurred.
|
||||
case unknown(message: String)
|
||||
}
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
extension AppError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
message
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
switch self {
|
||||
case .networkUnavailable:
|
||||
return "No internet connection detected."
|
||||
case .networkTimeout:
|
||||
return "The request took too long to complete."
|
||||
case .serverError(let statusCode):
|
||||
return "Server responded with error code \(statusCode)."
|
||||
case .invalidResponse:
|
||||
return "The server response could not be processed."
|
||||
case .cameraAccessDenied:
|
||||
return "The app does not have permission to access the camera."
|
||||
case .photoLibraryAccessDenied:
|
||||
return "The app does not have permission to access the photo library."
|
||||
case .identificationFailed(let reason):
|
||||
return reason
|
||||
case .noPlantDetected:
|
||||
return "No recognizable plant was found in the image."
|
||||
case .lowConfidence:
|
||||
return "The identification confidence is below the acceptable threshold."
|
||||
case .modelNotLoaded:
|
||||
return "The plant identification model could not be initialized."
|
||||
case .apiKeyMissing:
|
||||
return "A required API key is not configured."
|
||||
case .rateLimitExceeded:
|
||||
return "Too many requests in a short period."
|
||||
case .quotaExhausted:
|
||||
return "The monthly usage limit has been reached."
|
||||
case .apiUnavailable:
|
||||
return "The external service is not responding."
|
||||
case .saveFailed(let reason):
|
||||
return reason
|
||||
case .fetchFailed(let reason):
|
||||
return reason
|
||||
case .deleteFailed(let reason):
|
||||
return reason
|
||||
case .dataCorrupted:
|
||||
return "The stored data could not be read correctly."
|
||||
case .notificationPermissionDenied:
|
||||
return "Notification permissions have not been granted."
|
||||
case .scheduleCreationFailed(let reason):
|
||||
return reason
|
||||
case .careDataUnavailable:
|
||||
return "Care information is not available for this plant."
|
||||
case .unknown(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User-Facing Properties
|
||||
|
||||
extension AppError {
|
||||
/// A short, user-friendly title for the error.
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .networkUnavailable:
|
||||
return "No Connection"
|
||||
case .networkTimeout:
|
||||
return "Request Timeout"
|
||||
case .serverError:
|
||||
return "Server Error"
|
||||
case .invalidResponse:
|
||||
return "Invalid Response"
|
||||
case .cameraAccessDenied:
|
||||
return "Camera Access Required"
|
||||
case .photoLibraryAccessDenied:
|
||||
return "Photo Access Required"
|
||||
case .identificationFailed:
|
||||
return "Identification Failed"
|
||||
case .noPlantDetected:
|
||||
return "No Plant Found"
|
||||
case .lowConfidence:
|
||||
return "Uncertain Result"
|
||||
case .modelNotLoaded:
|
||||
return "Model Unavailable"
|
||||
case .apiKeyMissing:
|
||||
return "Configuration Error"
|
||||
case .rateLimitExceeded:
|
||||
return "Rate Limited"
|
||||
case .quotaExhausted:
|
||||
return "Quota Exceeded"
|
||||
case .apiUnavailable:
|
||||
return "Service Unavailable"
|
||||
case .saveFailed:
|
||||
return "Save Failed"
|
||||
case .fetchFailed:
|
||||
return "Load Failed"
|
||||
case .deleteFailed:
|
||||
return "Delete Failed"
|
||||
case .dataCorrupted:
|
||||
return "Data Error"
|
||||
case .notificationPermissionDenied:
|
||||
return "Notifications Disabled"
|
||||
case .scheduleCreationFailed:
|
||||
return "Schedule Error"
|
||||
case .careDataUnavailable:
|
||||
return "Care Info Unavailable"
|
||||
case .unknown:
|
||||
return "Something Went Wrong"
|
||||
}
|
||||
}
|
||||
|
||||
/// A detailed, user-friendly message describing the error.
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .networkUnavailable:
|
||||
return "Please check your internet connection and try again."
|
||||
case .networkTimeout:
|
||||
return "The request took too long. Please check your connection and try again."
|
||||
case .serverError(let statusCode):
|
||||
return "The server encountered an error (code: \(statusCode)). Please try again later."
|
||||
case .invalidResponse:
|
||||
return "We received an unexpected response from the server."
|
||||
case .cameraAccessDenied:
|
||||
return "Camera access is needed to take photos of plants for identification."
|
||||
case .photoLibraryAccessDenied:
|
||||
return "Photo library access is needed to select plant photos for identification."
|
||||
case .identificationFailed(let reason):
|
||||
return "Unable to identify the plant. \(reason)"
|
||||
case .noPlantDetected:
|
||||
return "We couldn't find a plant in this image. Try taking a clearer photo of the plant."
|
||||
case .lowConfidence:
|
||||
return "We're not confident about this identification. Try taking a clearer photo."
|
||||
case .modelNotLoaded:
|
||||
return "The identification system is not ready. Please restart the app."
|
||||
case .apiKeyMissing:
|
||||
return "The app is not properly configured. Please contact support."
|
||||
case .rateLimitExceeded:
|
||||
return "Too many requests. Please wait a moment before trying again."
|
||||
case .quotaExhausted:
|
||||
return "You've reached the maximum number of identifications for this period."
|
||||
case .apiUnavailable:
|
||||
return "The identification service is temporarily unavailable. Please try again later."
|
||||
case .saveFailed(let reason):
|
||||
return "Unable to save your data. \(reason)"
|
||||
case .fetchFailed(let reason):
|
||||
return "Unable to load your data. \(reason)"
|
||||
case .deleteFailed(let reason):
|
||||
return "Unable to delete the item. \(reason)"
|
||||
case .dataCorrupted:
|
||||
return "Some of your data appears to be corrupted. Please try again."
|
||||
case .notificationPermissionDenied:
|
||||
return "Enable notifications to receive care reminders for your plants."
|
||||
case .scheduleCreationFailed(let reason):
|
||||
return "Unable to create the care schedule. \(reason)"
|
||||
case .careDataUnavailable:
|
||||
return "Care information is not available for this plant species."
|
||||
case .unknown(let message):
|
||||
return message.isEmpty ? "An unexpected error occurred. Please try again." : message
|
||||
}
|
||||
}
|
||||
|
||||
/// A suggestion for how the user can recover from this error.
|
||||
public var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .networkUnavailable:
|
||||
return "Check your Wi-Fi or cellular connection, then try again."
|
||||
case .networkTimeout:
|
||||
return "Move to an area with better connectivity and retry."
|
||||
case .serverError:
|
||||
return "Our servers may be experiencing issues. Please try again in a few minutes."
|
||||
case .invalidResponse:
|
||||
return "Try updating the app to the latest version."
|
||||
case .cameraAccessDenied:
|
||||
return "Go to Settings > PlantGuide > Camera and enable access."
|
||||
case .photoLibraryAccessDenied:
|
||||
return "Go to Settings > PlantGuide > Photos and enable access."
|
||||
case .identificationFailed:
|
||||
return "Ensure the plant is well-lit and clearly visible, then try again."
|
||||
case .noPlantDetected:
|
||||
return "Make sure the plant fills most of the frame and is in focus."
|
||||
case .lowConfidence:
|
||||
return "Try photographing the plant from a different angle or in better lighting."
|
||||
case .modelNotLoaded:
|
||||
return "Force quit the app and reopen it. If the issue persists, reinstall the app."
|
||||
case .apiKeyMissing:
|
||||
return nil
|
||||
case .rateLimitExceeded:
|
||||
return "Wait about 30 seconds before making another request."
|
||||
case .quotaExhausted:
|
||||
return "Your quota will reset at the beginning of the next billing period."
|
||||
case .apiUnavailable:
|
||||
return "Check our status page or try again in a few minutes."
|
||||
case .saveFailed:
|
||||
return "Ensure you have enough storage space and try again."
|
||||
case .fetchFailed:
|
||||
return "Try closing and reopening the app."
|
||||
case .deleteFailed:
|
||||
return "Try again. If the issue persists, restart the app."
|
||||
case .dataCorrupted:
|
||||
return "Try reinstalling the app. Your cloud-synced data will be restored."
|
||||
case .notificationPermissionDenied:
|
||||
return "Go to Settings > PlantGuide > Notifications and enable notifications."
|
||||
case .scheduleCreationFailed:
|
||||
return "Try creating the schedule again with different settings."
|
||||
case .careDataUnavailable:
|
||||
return "You can still add custom care reminders for this plant."
|
||||
case .unknown:
|
||||
return "If this issue persists, please contact support."
|
||||
}
|
||||
}
|
||||
|
||||
/// The SF Symbol name for the error icon.
|
||||
public var iconName: String {
|
||||
switch self {
|
||||
case .networkUnavailable, .networkTimeout:
|
||||
return "wifi.slash"
|
||||
case .serverError, .invalidResponse:
|
||||
return "exclamationmark.icloud"
|
||||
case .cameraAccessDenied:
|
||||
return "camera.fill"
|
||||
case .photoLibraryAccessDenied:
|
||||
return "photo.on.rectangle.angled"
|
||||
case .identificationFailed, .noPlantDetected, .lowConfidence:
|
||||
return "leaf.fill"
|
||||
case .modelNotLoaded:
|
||||
return "cpu"
|
||||
case .apiKeyMissing, .apiUnavailable:
|
||||
return "gear.badge.xmark"
|
||||
case .rateLimitExceeded, .quotaExhausted:
|
||||
return "clock.badge.exclamationmark"
|
||||
case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted:
|
||||
return "externaldrive.badge.exclamationmark"
|
||||
case .notificationPermissionDenied:
|
||||
return "bell.slash.fill"
|
||||
case .scheduleCreationFailed, .careDataUnavailable:
|
||||
return "calendar.badge.exclamationmark"
|
||||
case .unknown:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// The background color for the error icon.
|
||||
public var iconBackgroundColor: Color {
|
||||
switch self {
|
||||
case .networkUnavailable, .networkTimeout:
|
||||
return .orange
|
||||
case .serverError, .invalidResponse:
|
||||
return .red
|
||||
case .cameraAccessDenied, .photoLibraryAccessDenied:
|
||||
return .blue
|
||||
case .identificationFailed, .noPlantDetected, .lowConfidence:
|
||||
return .green
|
||||
case .modelNotLoaded:
|
||||
return .purple
|
||||
case .apiKeyMissing, .apiUnavailable:
|
||||
return .gray
|
||||
case .rateLimitExceeded, .quotaExhausted:
|
||||
return .yellow
|
||||
case .saveFailed, .fetchFailed, .deleteFailed, .dataCorrupted:
|
||||
return .red
|
||||
case .notificationPermissionDenied:
|
||||
return .orange
|
||||
case .scheduleCreationFailed, .careDataUnavailable:
|
||||
return .teal
|
||||
case .unknown:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
/// The title for the retry button, if applicable.
|
||||
public var retryButtonTitle: String? {
|
||||
guard isRetryable else { return nil }
|
||||
|
||||
switch self {
|
||||
case .cameraAccessDenied, .photoLibraryAccessDenied, .notificationPermissionDenied:
|
||||
return "Open Settings"
|
||||
default:
|
||||
return "Try Again"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the error is potentially recoverable by retrying the operation.
|
||||
public var isRetryable: Bool {
|
||||
switch self {
|
||||
case .networkUnavailable,
|
||||
.networkTimeout,
|
||||
.serverError,
|
||||
.invalidResponse,
|
||||
.identificationFailed,
|
||||
.noPlantDetected,
|
||||
.lowConfidence,
|
||||
.rateLimitExceeded,
|
||||
.apiUnavailable,
|
||||
.saveFailed,
|
||||
.fetchFailed,
|
||||
.deleteFailed,
|
||||
.scheduleCreationFailed:
|
||||
return true
|
||||
case .cameraAccessDenied,
|
||||
.photoLibraryAccessDenied,
|
||||
.notificationPermissionDenied:
|
||||
// These require settings navigation, not a simple retry
|
||||
return true
|
||||
case .modelNotLoaded,
|
||||
.apiKeyMissing,
|
||||
.quotaExhausted,
|
||||
.dataCorrupted,
|
||||
.careDataUnavailable,
|
||||
.unknown:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion from Other Errors
|
||||
|
||||
extension AppError {
|
||||
/// Creates an AppError from a NetworkError.
|
||||
public static func from(_ networkError: NetworkError) -> AppError {
|
||||
switch networkError {
|
||||
case .invalidURL:
|
||||
return .invalidResponse
|
||||
case .requestFailed:
|
||||
return .networkUnavailable
|
||||
case .invalidResponse:
|
||||
return .invalidResponse
|
||||
case .decodingFailed:
|
||||
return .invalidResponse
|
||||
case .serverError(let statusCode):
|
||||
return .serverError(statusCode: statusCode)
|
||||
case .noData:
|
||||
return .invalidResponse
|
||||
case .unauthorized:
|
||||
return .apiKeyMissing
|
||||
case .rateLimited:
|
||||
return .rateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an AppError from any Error, with optional context.
|
||||
public static func from(_ error: Error, context: String? = nil) -> AppError {
|
||||
if let appError = error as? AppError {
|
||||
return appError
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
return from(networkError)
|
||||
}
|
||||
|
||||
if let classificationError = error as? PlantClassificationError {
|
||||
return from(classificationError)
|
||||
}
|
||||
|
||||
let message = context ?? error.localizedDescription
|
||||
return .unknown(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
extension AppError {
|
||||
public static func == (lhs: AppError, rhs: AppError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.networkUnavailable, .networkUnavailable):
|
||||
return true
|
||||
case (.networkTimeout, .networkTimeout):
|
||||
return true
|
||||
case (.serverError(let lhsCode), .serverError(let rhsCode)):
|
||||
return lhsCode == rhsCode
|
||||
case (.invalidResponse, .invalidResponse):
|
||||
return true
|
||||
case (.cameraAccessDenied, .cameraAccessDenied):
|
||||
return true
|
||||
case (.photoLibraryAccessDenied, .photoLibraryAccessDenied):
|
||||
return true
|
||||
case (.identificationFailed(let lhsReason), .identificationFailed(let rhsReason)):
|
||||
return lhsReason == rhsReason
|
||||
case (.noPlantDetected, .noPlantDetected):
|
||||
return true
|
||||
case (.lowConfidence, .lowConfidence):
|
||||
return true
|
||||
case (.modelNotLoaded, .modelNotLoaded):
|
||||
return true
|
||||
case (.apiKeyMissing, .apiKeyMissing):
|
||||
return true
|
||||
case (.rateLimitExceeded, .rateLimitExceeded):
|
||||
return true
|
||||
case (.quotaExhausted, .quotaExhausted):
|
||||
return true
|
||||
case (.apiUnavailable, .apiUnavailable):
|
||||
return true
|
||||
case (.saveFailed(let lhsReason), .saveFailed(let rhsReason)):
|
||||
return lhsReason == rhsReason
|
||||
case (.fetchFailed(let lhsReason), .fetchFailed(let rhsReason)):
|
||||
return lhsReason == rhsReason
|
||||
case (.deleteFailed(let lhsReason), .deleteFailed(let rhsReason)):
|
||||
return lhsReason == rhsReason
|
||||
case (.dataCorrupted, .dataCorrupted):
|
||||
return true
|
||||
case (.notificationPermissionDenied, .notificationPermissionDenied):
|
||||
return true
|
||||
case (.scheduleCreationFailed(let lhsReason), .scheduleCreationFailed(let rhsReason)):
|
||||
return lhsReason == rhsReason
|
||||
case (.careDataUnavailable, .careDataUnavailable):
|
||||
return true
|
||||
case (.unknown(let lhsMessage), .unknown(let rhsMessage)):
|
||||
return lhsMessage == rhsMessage
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// String+SHA256.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Computes the SHA256 hash of the string.
|
||||
///
|
||||
/// This is useful for generating consistent cache keys from URLs or other identifiers.
|
||||
///
|
||||
/// - Returns: A 64-character lowercase hexadecimal string representing the SHA256 hash.
|
||||
nonisolated var sha256Hash: String {
|
||||
let data = Data(self.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//
|
||||
// CameraService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created by Trey Tartt on 1/21/26.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - Camera Service Error
|
||||
|
||||
enum CameraServiceError: LocalizedError {
|
||||
case deviceNotAvailable
|
||||
case inputConfigurationFailed
|
||||
case outputConfigurationFailed
|
||||
case sessionNotRunning
|
||||
case captureInProgress
|
||||
case permissionDenied
|
||||
case unknown(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .deviceNotAvailable:
|
||||
return "Camera device is not available on this device."
|
||||
case .inputConfigurationFailed:
|
||||
return "Failed to configure camera input."
|
||||
case .outputConfigurationFailed:
|
||||
return "Failed to configure photo output."
|
||||
case .sessionNotRunning:
|
||||
return "Camera session is not running."
|
||||
case .captureInProgress:
|
||||
return "A photo capture is already in progress."
|
||||
case .permissionDenied:
|
||||
return "Camera access has been denied. Please enable it in Settings."
|
||||
case .unknown(let error):
|
||||
return "An unexpected error occurred: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Camera Permission Status
|
||||
|
||||
enum CameraPermissionStatus: Sendable {
|
||||
case notDetermined
|
||||
case authorized
|
||||
case denied
|
||||
case restricted
|
||||
|
||||
init(from avStatus: AVAuthorizationStatus) {
|
||||
switch avStatus {
|
||||
case .notDetermined:
|
||||
self = .notDetermined
|
||||
case .authorized:
|
||||
self = .authorized
|
||||
case .denied:
|
||||
self = .denied
|
||||
case .restricted:
|
||||
self = .restricted
|
||||
@unknown default:
|
||||
self = .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Capture Delegate
|
||||
|
||||
final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate, @unchecked Sendable {
|
||||
private let continuation: CheckedContinuation<UIImage, Error>
|
||||
private let orientation: UIDeviceOrientation
|
||||
|
||||
init(continuation: CheckedContinuation<UIImage, Error>, orientation: UIDeviceOrientation) {
|
||||
self.continuation = continuation
|
||||
self.orientation = orientation
|
||||
super.init()
|
||||
}
|
||||
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
if let error = error {
|
||||
continuation.resume(throwing: CameraServiceError.unknown(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let imageData = photo.fileDataRepresentation(),
|
||||
let image = UIImage(data: imageData) else {
|
||||
continuation.resume(throwing: CameraServiceError.unknown(
|
||||
NSError(domain: "CameraService", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to process captured photo data"
|
||||
])
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Apply correct orientation based on device orientation
|
||||
let correctedImage = correctImageOrientation(image)
|
||||
continuation.resume(returning: correctedImage)
|
||||
}
|
||||
|
||||
private func correctImageOrientation(_ image: UIImage) -> UIImage {
|
||||
let targetOrientation: UIImage.Orientation
|
||||
|
||||
switch orientation {
|
||||
case .portrait:
|
||||
targetOrientation = .right
|
||||
case .portraitUpsideDown:
|
||||
targetOrientation = .left
|
||||
case .landscapeLeft:
|
||||
targetOrientation = .up
|
||||
case .landscapeRight:
|
||||
targetOrientation = .down
|
||||
default:
|
||||
targetOrientation = .right
|
||||
}
|
||||
|
||||
guard let cgImage = image.cgImage else { return image }
|
||||
return UIImage(cgImage: cgImage, scale: image.scale, orientation: targetOrientation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Camera Service Actor
|
||||
|
||||
actor CameraService {
|
||||
// MARK: - Properties
|
||||
|
||||
private let captureSession: AVCaptureSession
|
||||
private var photoOutput: AVCapturePhotoOutput?
|
||||
private var videoDeviceInput: AVCaptureDeviceInput?
|
||||
private var isConfigured = false
|
||||
private var isCaptureInProgress = false
|
||||
private var currentOrientation: UIDeviceOrientation = .portrait
|
||||
|
||||
// Keep strong reference to delegate during capture
|
||||
private var captureDelegate: PhotoCaptureDelegate?
|
||||
|
||||
// Session queue for off-main-thread operations
|
||||
private let sessionQueue = DispatchQueue(label: "com.plantguide.camera.session", qos: .userInitiated)
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.captureSession = AVCaptureSession()
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Returns the capture session for preview layer binding
|
||||
nonisolated var session: AVCaptureSession {
|
||||
captureSession
|
||||
}
|
||||
|
||||
/// Check current camera permission status
|
||||
nonisolated func checkPermissionStatus() -> CameraPermissionStatus {
|
||||
CameraPermissionStatus(from: AVCaptureDevice.authorizationStatus(for: .video))
|
||||
}
|
||||
|
||||
/// Request camera permission
|
||||
func requestPermission() async -> CameraPermissionStatus {
|
||||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||
return granted ? .authorized : .denied
|
||||
}
|
||||
|
||||
/// Configure the capture session
|
||||
func configureSession() async throws {
|
||||
guard !isConfigured else { return }
|
||||
|
||||
let permissionStatus = checkPermissionStatus()
|
||||
guard permissionStatus == .authorized else {
|
||||
throw CameraServiceError.permissionDenied
|
||||
}
|
||||
|
||||
// Configure session on session queue
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume(throwing: CameraServiceError.unknown(
|
||||
NSError(domain: "CameraService", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Service was deallocated"
|
||||
])
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try self.configureSessionSync()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured = true
|
||||
}
|
||||
|
||||
/// Start the capture session
|
||||
func startSession() async {
|
||||
guard isConfigured else { return }
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
|
||||
if !self.captureSession.isRunning {
|
||||
self.captureSession.startRunning()
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the capture session
|
||||
func stopSession() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
|
||||
if self.captureSession.isRunning {
|
||||
self.captureSession.stopRunning()
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update device orientation for photo capture
|
||||
func updateOrientation(_ orientation: UIDeviceOrientation) {
|
||||
guard orientation.isValidInterfaceOrientation else { return }
|
||||
currentOrientation = orientation
|
||||
}
|
||||
|
||||
/// Capture a photo
|
||||
func capturePhoto() async throws -> UIImage {
|
||||
guard isConfigured else {
|
||||
throw CameraServiceError.sessionNotRunning
|
||||
}
|
||||
|
||||
guard !isCaptureInProgress else {
|
||||
throw CameraServiceError.captureInProgress
|
||||
}
|
||||
|
||||
guard let photoOutput = photoOutput else {
|
||||
throw CameraServiceError.outputConfigurationFailed
|
||||
}
|
||||
|
||||
isCaptureInProgress = true
|
||||
defer { isCaptureInProgress = false }
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let settings = AVCapturePhotoSettings()
|
||||
|
||||
// Configure photo settings
|
||||
if photoOutput.availablePhotoCodecTypes.contains(.hevc) {
|
||||
settings.photoQualityPrioritization = .balanced
|
||||
}
|
||||
|
||||
// Enable flash if available
|
||||
if let device = videoDeviceInput?.device, device.hasFlash {
|
||||
settings.flashMode = .auto
|
||||
}
|
||||
|
||||
let delegate = PhotoCaptureDelegate(
|
||||
continuation: continuation,
|
||||
orientation: currentOrientation
|
||||
)
|
||||
|
||||
// Store delegate to keep it alive
|
||||
self.captureDelegate = delegate
|
||||
|
||||
sessionQueue.async {
|
||||
photoOutput.capturePhoto(with: settings, delegate: delegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle session interruption (e.g., phone call)
|
||||
func handleInterruption(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let reasonValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? Int,
|
||||
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch reason {
|
||||
case .videoDeviceNotAvailableInBackground:
|
||||
// Session will be automatically resumed when app returns to foreground
|
||||
break
|
||||
case .audioDeviceInUseByAnotherClient, .videoDeviceInUseByAnotherClient:
|
||||
// Another app is using the camera
|
||||
break
|
||||
case .videoDeviceNotAvailableWithMultipleForegroundApps:
|
||||
// Multitasking scenario
|
||||
break
|
||||
case .videoDeviceNotAvailableDueToSystemPressure:
|
||||
// System pressure (thermal, etc.)
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle session interruption ended
|
||||
func handleInterruptionEnded() async {
|
||||
if isConfigured && !captureSession.isRunning {
|
||||
await startSession()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private nonisolated func configureSessionSync() throws {
|
||||
captureSession.beginConfiguration()
|
||||
defer { captureSession.commitConfiguration() }
|
||||
|
||||
// Set session preset for high-quality photos
|
||||
captureSession.sessionPreset = .photo
|
||||
|
||||
// Configure video input
|
||||
guard let videoDevice = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera,
|
||||
for: .video,
|
||||
position: .back
|
||||
) else {
|
||||
throw CameraServiceError.deviceNotAvailable
|
||||
}
|
||||
|
||||
let videoInput: AVCaptureDeviceInput
|
||||
do {
|
||||
videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
} catch {
|
||||
throw CameraServiceError.inputConfigurationFailed
|
||||
}
|
||||
|
||||
guard captureSession.canAddInput(videoInput) else {
|
||||
throw CameraServiceError.inputConfigurationFailed
|
||||
}
|
||||
|
||||
captureSession.addInput(videoInput)
|
||||
|
||||
// Configure photo output
|
||||
let output = AVCapturePhotoOutput()
|
||||
output.isHighResolutionCaptureEnabled = true
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
guard captureSession.canAddOutput(output) else {
|
||||
throw CameraServiceError.outputConfigurationFailed
|
||||
}
|
||||
|
||||
captureSession.addOutput(output)
|
||||
|
||||
// Store references - need to dispatch back to actor
|
||||
// Since this is synchronous, we'll store locally and update actor state after
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.updateSessionComponents(input: videoInput, output: output)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSessionComponents(input: AVCaptureDeviceInput, output: AVCapturePhotoOutput) {
|
||||
self.videoDeviceInput = input
|
||||
self.photoOutput = output
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIDeviceOrientation Extension
|
||||
|
||||
private extension UIDeviceOrientation {
|
||||
var isValidInterfaceOrientation: Bool {
|
||||
switch self {
|
||||
case .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//
|
||||
// ErrorLoggingService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
// MARK: - Error Logging Level
|
||||
|
||||
/// Represents the severity level of a logged error.
|
||||
public enum ErrorLogLevel: String, Sendable {
|
||||
case debug = "DEBUG"
|
||||
case info = "INFO"
|
||||
case warning = "WARNING"
|
||||
case error = "ERROR"
|
||||
case critical = "CRITICAL"
|
||||
|
||||
/// The corresponding OSLogType for system logging.
|
||||
var osLogType: OSLogType {
|
||||
switch self {
|
||||
case .debug:
|
||||
return .debug
|
||||
case .info:
|
||||
return .info
|
||||
case .warning:
|
||||
return .default
|
||||
case .error:
|
||||
return .error
|
||||
case .critical:
|
||||
return .fault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Context
|
||||
|
||||
/// Additional context information for error logging.
|
||||
public struct ErrorContext: Sendable {
|
||||
/// The file where the error occurred.
|
||||
public let file: String
|
||||
|
||||
/// The function where the error occurred.
|
||||
public let function: String
|
||||
|
||||
/// The line number where the error occurred.
|
||||
public let line: Int
|
||||
|
||||
/// Additional metadata about the error.
|
||||
public let metadata: [String: String]
|
||||
|
||||
/// The timestamp when the error was logged.
|
||||
public let timestamp: Date
|
||||
|
||||
public init(
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
self.file = (file as NSString).lastPathComponent
|
||||
self.function = function
|
||||
self.line = line
|
||||
self.metadata = metadata
|
||||
self.timestamp = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Logging Protocol
|
||||
|
||||
/// Protocol defining the interface for error logging services.
|
||||
public protocol ErrorLoggingServiceProtocol: Sendable {
|
||||
/// Logs an AppError with the specified level and context.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - error: The error to log.
|
||||
/// - level: The severity level of the error.
|
||||
/// - context: Additional context about where the error occurred.
|
||||
func log(
|
||||
_ error: AppError,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
)
|
||||
|
||||
/// Logs a generic Error with the specified level and context.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - error: The error to log.
|
||||
/// - level: The severity level of the error.
|
||||
/// - context: Additional context about where the error occurred.
|
||||
func log(
|
||||
_ error: Error,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
)
|
||||
|
||||
/// Logs a message with the specified level and context.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - message: The message to log.
|
||||
/// - level: The severity level.
|
||||
/// - context: Additional context.
|
||||
func log(
|
||||
message: String,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Default Implementation
|
||||
|
||||
public extension ErrorLoggingServiceProtocol {
|
||||
/// Convenience method to log an error with default context.
|
||||
func log(
|
||||
_ error: AppError,
|
||||
level: ErrorLogLevel = .error,
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
let context = ErrorContext(
|
||||
file: file,
|
||||
function: function,
|
||||
line: line,
|
||||
metadata: metadata
|
||||
)
|
||||
log(error, level: level, context: context)
|
||||
}
|
||||
|
||||
/// Convenience method to log a generic error with default context.
|
||||
func log(
|
||||
_ error: Error,
|
||||
level: ErrorLogLevel = .error,
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
let context = ErrorContext(
|
||||
file: file,
|
||||
function: function,
|
||||
line: line,
|
||||
metadata: metadata
|
||||
)
|
||||
log(error, level: level, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Logging Service Implementation
|
||||
|
||||
/// Default implementation of the error logging service.
|
||||
///
|
||||
/// In debug mode, logs errors to the console with detailed information.
|
||||
/// In production, this implementation is ready to integrate with
|
||||
/// crash reporting services like Firebase Crashlytics, Sentry, or Bugsnag.
|
||||
public final class ErrorLoggingService: ErrorLoggingServiceProtocol, @unchecked Sendable {
|
||||
// MARK: - Singleton
|
||||
|
||||
/// Shared instance of the error logging service.
|
||||
public static let shared = ErrorLoggingService()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let logger: Logger
|
||||
private let isDebugMode: Bool
|
||||
private let dateFormatter: ISO8601DateFormatter
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Optional handler for production crash reporting integration.
|
||||
/// Set this to forward errors to your crash reporting service.
|
||||
public var crashReportingHandler: ((Error, ErrorLogLevel, ErrorContext) -> Void)?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
subsystem: String = Bundle.main.bundleIdentifier ?? "com.plantguide",
|
||||
category: String = "errors"
|
||||
) {
|
||||
self.logger = Logger(subsystem: subsystem, category: category)
|
||||
#if DEBUG
|
||||
self.isDebugMode = true
|
||||
#else
|
||||
self.isDebugMode = false
|
||||
#endif
|
||||
self.dateFormatter = ISO8601DateFormatter()
|
||||
self.dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
}
|
||||
|
||||
// MARK: - Logging Methods
|
||||
|
||||
public func log(
|
||||
_ error: AppError,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if isDebugMode {
|
||||
logToConsole(error: error, level: level, context: context)
|
||||
}
|
||||
|
||||
// Forward to crash reporting service in production
|
||||
crashReportingHandler?(error, level, context)
|
||||
|
||||
// Log to unified logging system
|
||||
logToSystem(error: error, level: level, context: context)
|
||||
}
|
||||
|
||||
public func log(
|
||||
_ error: Error,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
if let appError = error as? AppError {
|
||||
log(appError, level: level, context: context)
|
||||
} else {
|
||||
let appError = AppError.from(error)
|
||||
log(appError, level: level, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
public func log(
|
||||
message: String,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if isDebugMode {
|
||||
logMessageToConsole(message: message, level: level, context: context)
|
||||
}
|
||||
|
||||
logMessageToSystem(message: message, level: level, context: context)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func logToConsole(
|
||||
error: AppError,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
let timestamp = dateFormatter.string(from: context.timestamp)
|
||||
let location = "\(context.file):\(context.line) \(context.function)"
|
||||
|
||||
var output = """
|
||||
|
||||
========================================
|
||||
[\(level.rawValue)] \(timestamp)
|
||||
----------------------------------------
|
||||
Error: \(error.title)
|
||||
Message: \(error.message)
|
||||
"""
|
||||
|
||||
if let suggestion = error.recoverySuggestion {
|
||||
output += "\nRecovery: \(suggestion)"
|
||||
}
|
||||
|
||||
output += "\nRetryable: \(error.isRetryable)"
|
||||
output += "\nLocation: \(location)"
|
||||
|
||||
if !context.metadata.isEmpty {
|
||||
output += "\nMetadata:"
|
||||
for (key, value) in context.metadata {
|
||||
output += "\n \(key): \(value)"
|
||||
}
|
||||
}
|
||||
|
||||
output += "\n========================================\n"
|
||||
|
||||
print(output)
|
||||
}
|
||||
|
||||
private func logMessageToConsole(
|
||||
message: String,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
let timestamp = dateFormatter.string(from: context.timestamp)
|
||||
let location = "\(context.file):\(context.line)"
|
||||
|
||||
print("[\(level.rawValue)] \(timestamp) \(location): \(message)")
|
||||
}
|
||||
|
||||
private func logToSystem(
|
||||
error: AppError,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
let metadata = context.metadata.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
|
||||
let metadataString = metadata.isEmpty ? "" : " [\(metadata)]"
|
||||
|
||||
let message = """
|
||||
\(error.title): \(error.message) \
|
||||
at \(context.file):\(context.line)\(metadataString)
|
||||
"""
|
||||
|
||||
switch level {
|
||||
case .debug:
|
||||
logger.debug("\(message, privacy: .public)")
|
||||
case .info:
|
||||
logger.info("\(message, privacy: .public)")
|
||||
case .warning:
|
||||
logger.warning("\(message, privacy: .public)")
|
||||
case .error:
|
||||
logger.error("\(message, privacy: .public)")
|
||||
case .critical:
|
||||
logger.critical("\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func logMessageToSystem(
|
||||
message: String,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
let fullMessage = "\(message) at \(context.file):\(context.line)"
|
||||
|
||||
switch level {
|
||||
case .debug:
|
||||
logger.debug("\(fullMessage, privacy: .public)")
|
||||
case .info:
|
||||
logger.info("\(fullMessage, privacy: .public)")
|
||||
case .warning:
|
||||
logger.warning("\(fullMessage, privacy: .public)")
|
||||
case .error:
|
||||
logger.error("\(fullMessage, privacy: .public)")
|
||||
case .critical:
|
||||
logger.critical("\(fullMessage, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Functions
|
||||
|
||||
/// Global convenience function for logging errors.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// do {
|
||||
/// try await someOperation()
|
||||
/// } catch {
|
||||
/// logError(error)
|
||||
/// }
|
||||
/// ```
|
||||
public func logError(
|
||||
_ error: Error,
|
||||
level: ErrorLogLevel = .error,
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
ErrorLoggingService.shared.log(
|
||||
error,
|
||||
level: level,
|
||||
file: file,
|
||||
function: function,
|
||||
line: line,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
|
||||
/// Global convenience function for logging AppErrors.
|
||||
public func logAppError(
|
||||
_ error: AppError,
|
||||
level: ErrorLogLevel = .error,
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
ErrorLoggingService.shared.log(
|
||||
error,
|
||||
level: level,
|
||||
file: file,
|
||||
function: function,
|
||||
line: line,
|
||||
metadata: metadata
|
||||
)
|
||||
}
|
||||
|
||||
/// Global convenience function for logging messages.
|
||||
public func logMessage(
|
||||
_ message: String,
|
||||
level: ErrorLogLevel = .info,
|
||||
file: String = #file,
|
||||
function: String = #function,
|
||||
line: Int = #line,
|
||||
metadata: [String: String] = [:]
|
||||
) {
|
||||
let context = ErrorContext(
|
||||
file: file,
|
||||
function: function,
|
||||
line: line,
|
||||
metadata: metadata
|
||||
)
|
||||
ErrorLoggingService.shared.log(message: message, level: level, context: context)
|
||||
}
|
||||
|
||||
// MARK: - Mock Implementation for Testing
|
||||
|
||||
/// Mock error logging service for unit tests.
|
||||
public final class MockErrorLoggingService: ErrorLoggingServiceProtocol, @unchecked Sendable {
|
||||
// MARK: - Properties
|
||||
|
||||
private let lock = NSLock()
|
||||
private var _loggedErrors: [(AppError, ErrorLogLevel, ErrorContext)] = []
|
||||
private var _loggedMessages: [(String, ErrorLogLevel, ErrorContext)] = []
|
||||
|
||||
/// All errors that have been logged.
|
||||
public var loggedErrors: [(AppError, ErrorLogLevel, ErrorContext)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _loggedErrors
|
||||
}
|
||||
|
||||
/// All messages that have been logged.
|
||||
public var loggedMessages: [(String, ErrorLogLevel, ErrorContext)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _loggedMessages
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Protocol Methods
|
||||
|
||||
public func log(
|
||||
_ error: AppError,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_loggedErrors.append((error, level, context))
|
||||
}
|
||||
|
||||
public func log(
|
||||
_ error: Error,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
let appError = AppError.from(error)
|
||||
log(appError, level: level, context: context)
|
||||
}
|
||||
|
||||
public func log(
|
||||
message: String,
|
||||
level: ErrorLogLevel,
|
||||
context: ErrorContext
|
||||
) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_loggedMessages.append((message, level, context))
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Clears all logged errors and messages.
|
||||
public func reset() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_loggedErrors.removeAll()
|
||||
_loggedMessages.removeAll()
|
||||
}
|
||||
|
||||
/// Returns the most recently logged error, if any.
|
||||
public var lastLoggedError: (AppError, ErrorLogLevel, ErrorContext)? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _loggedErrors.last
|
||||
}
|
||||
|
||||
/// Returns the count of logged errors.
|
||||
public var errorCount: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _loggedErrors.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// NotificationService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created by Trey Tartt on 1/21/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - Notification Service Error
|
||||
|
||||
/// Errors that can occur during notification operations
|
||||
enum NotificationError: Error, LocalizedError {
|
||||
/// The user denied notification permissions
|
||||
case permissionDenied
|
||||
/// Failed to schedule a notification
|
||||
case schedulingFailed(Error)
|
||||
/// The trigger date is invalid (e.g., in the past)
|
||||
case invalidTriggerDate
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return "Notification permission has been denied. Please enable notifications in Settings."
|
||||
case .schedulingFailed(let error):
|
||||
return "Failed to schedule notification: \(error.localizedDescription)"
|
||||
case .invalidTriggerDate:
|
||||
return "The scheduled date must be in the future."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Service Protocol
|
||||
|
||||
/// Protocol defining the notification service interface for plant care reminders
|
||||
protocol NotificationServiceProtocol: Sendable {
|
||||
/// Request authorization to send notifications
|
||||
/// - Returns: `true` if authorization was granted, `false` otherwise
|
||||
func requestAuthorization() async throws -> Bool
|
||||
|
||||
/// Schedule a reminder notification for a care task
|
||||
/// - Parameters:
|
||||
/// - task: The care task to schedule a reminder for
|
||||
/// - plantName: The name of the plant associated with the task
|
||||
/// - plantID: The unique identifier of the plant
|
||||
func scheduleReminder(for task: CareTask, plantName: String, plantID: UUID) async throws
|
||||
|
||||
/// Cancel a scheduled reminder for a specific task
|
||||
/// - Parameter taskID: The unique identifier of the task to cancel
|
||||
func cancelReminder(for taskID: UUID) async
|
||||
|
||||
/// Cancel all reminders associated with a specific plant
|
||||
/// - Parameter plantID: The unique identifier of the plant
|
||||
func cancelAllReminders(for plantID: UUID) async
|
||||
|
||||
/// Cancel all reminders for a specific task type and plant
|
||||
/// - Parameters:
|
||||
/// - taskType: The type of care task to cancel reminders for
|
||||
/// - plantID: The unique identifier of the plant
|
||||
func cancelReminders(for taskType: CareTaskType, plantID: UUID) async
|
||||
|
||||
/// Update the app badge count
|
||||
/// - Parameter count: The number to display on the app badge
|
||||
func updateBadgeCount(_ count: Int) async
|
||||
|
||||
/// Get all pending notification requests
|
||||
/// - Returns: An array of pending notification requests
|
||||
func getPendingNotifications() async -> [UNNotificationRequest]
|
||||
|
||||
/// Remove all delivered notifications from the notification center
|
||||
func removeAllDeliveredNotifications() async
|
||||
}
|
||||
|
||||
// MARK: - Notification Action Identifiers
|
||||
|
||||
/// Constants for notification action and category identifiers
|
||||
private enum NotificationConstants {
|
||||
/// Category identifier for care reminder notifications
|
||||
static let careReminderCategory = "CARE_REMINDER"
|
||||
|
||||
/// Action identifier for marking a task as complete
|
||||
static let completeAction = "COMPLETE"
|
||||
|
||||
/// Action identifier for snoozing the reminder
|
||||
static let snoozeAction = "SNOOZE"
|
||||
|
||||
/// Prefix for notification identifiers
|
||||
static let notificationPrefix = "care-"
|
||||
|
||||
/// User info key for task ID
|
||||
static let taskIDKey = "taskID"
|
||||
|
||||
/// User info key for plant ID
|
||||
static let plantIDKey = "plantID"
|
||||
|
||||
/// User info key for task type
|
||||
static let taskTypeKey = "taskType"
|
||||
|
||||
/// Snooze duration in seconds (1 hour)
|
||||
static let snoozeDuration: TimeInterval = 3600
|
||||
}
|
||||
|
||||
// MARK: - Notification Service
|
||||
|
||||
/// Service responsible for managing plant care reminder notifications
|
||||
actor NotificationService: NotificationServiceProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
/// The notification center instance
|
||||
private let notificationCenter: UNUserNotificationCenter
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new notification service instance
|
||||
init() {
|
||||
self.notificationCenter = UNUserNotificationCenter.current()
|
||||
}
|
||||
|
||||
// MARK: - Static Methods
|
||||
|
||||
/// Sets up notification categories and actions for the app
|
||||
/// Call this method during app launch (e.g., in AppDelegate or App init)
|
||||
static func setupNotificationCategories() {
|
||||
let completeAction = UNNotificationAction(
|
||||
identifier: NotificationConstants.completeAction,
|
||||
title: "Mark Complete",
|
||||
options: [.foreground]
|
||||
)
|
||||
|
||||
let snoozeAction = UNNotificationAction(
|
||||
identifier: NotificationConstants.snoozeAction,
|
||||
title: "Snooze 1 Hour",
|
||||
options: []
|
||||
)
|
||||
|
||||
let careReminderCategory = UNNotificationCategory(
|
||||
identifier: NotificationConstants.careReminderCategory,
|
||||
actions: [completeAction, snoozeAction],
|
||||
intentIdentifiers: [],
|
||||
options: [.customDismissAction]
|
||||
)
|
||||
|
||||
UNUserNotificationCenter.current().setNotificationCategories([careReminderCategory])
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Request authorization to send notifications
|
||||
/// - Returns: `true` if authorization was granted
|
||||
/// - Throws: `NotificationError.permissionDenied` if the user denies permission
|
||||
func requestAuthorization() async throws -> Bool {
|
||||
do {
|
||||
let granted = try await notificationCenter.requestAuthorization(
|
||||
options: [.alert, .badge, .sound]
|
||||
)
|
||||
|
||||
if !granted {
|
||||
throw NotificationError.permissionDenied
|
||||
}
|
||||
|
||||
return granted
|
||||
} catch let error as NotificationError {
|
||||
throw error
|
||||
} catch {
|
||||
throw NotificationError.permissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a reminder notification for a care task
|
||||
/// - Parameters:
|
||||
/// - task: The care task to schedule a reminder for
|
||||
/// - plantName: The name of the plant associated with the task
|
||||
/// - plantID: The unique identifier of the plant
|
||||
/// - Throws: `NotificationError.invalidTriggerDate` if the scheduled date is in the past
|
||||
/// - Throws: `NotificationError.schedulingFailed` if the notification cannot be scheduled
|
||||
func scheduleReminder(for task: CareTask, plantName: String, plantID: UUID) async throws {
|
||||
// Validate that the scheduled date is in the future
|
||||
guard task.scheduledDate > Date() else {
|
||||
throw NotificationError.invalidTriggerDate
|
||||
}
|
||||
|
||||
// Create notification content
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Plant Care Reminder"
|
||||
content.body = "\(plantName) needs \(task.type.displayName)"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = NotificationConstants.careReminderCategory
|
||||
|
||||
// Store task and plant information in userInfo
|
||||
content.userInfo = [
|
||||
NotificationConstants.taskIDKey: task.id.uuidString,
|
||||
NotificationConstants.plantIDKey: plantID.uuidString,
|
||||
NotificationConstants.taskTypeKey: task.type.rawValue
|
||||
]
|
||||
|
||||
// Create calendar-based trigger from scheduled date
|
||||
let dateComponents = Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute],
|
||||
from: task.scheduledDate
|
||||
)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
|
||||
|
||||
// Create notification request with unique identifier
|
||||
let identifier = notificationIdentifier(for: task.id)
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
// Schedule the notification
|
||||
do {
|
||||
try await notificationCenter.add(request)
|
||||
} catch {
|
||||
throw NotificationError.schedulingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a scheduled reminder for a specific task
|
||||
/// - Parameter taskID: The unique identifier of the task to cancel
|
||||
func cancelReminder(for taskID: UUID) async {
|
||||
let identifier = notificationIdentifier(for: taskID)
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
|
||||
}
|
||||
|
||||
/// Cancel all reminders associated with a specific plant
|
||||
/// - Parameter plantID: The unique identifier of the plant
|
||||
func cancelAllReminders(for plantID: UUID) async {
|
||||
let pendingRequests = await notificationCenter.pendingNotificationRequests()
|
||||
|
||||
let identifiersToRemove = pendingRequests
|
||||
.filter { request in
|
||||
guard let storedPlantID = request.content.userInfo[NotificationConstants.plantIDKey] as? String else {
|
||||
return false
|
||||
}
|
||||
return storedPlantID == plantID.uuidString
|
||||
}
|
||||
.map { $0.identifier }
|
||||
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove)
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove)
|
||||
}
|
||||
|
||||
/// Cancel all reminders for a specific task type and plant
|
||||
/// - Parameters:
|
||||
/// - taskType: The type of care task to cancel reminders for
|
||||
/// - plantID: The unique identifier of the plant
|
||||
func cancelReminders(for taskType: CareTaskType, plantID: UUID) async {
|
||||
let pendingRequests = await notificationCenter.pendingNotificationRequests()
|
||||
|
||||
let identifiersToRemove = pendingRequests
|
||||
.filter { request in
|
||||
guard let storedPlantID = request.content.userInfo[NotificationConstants.plantIDKey] as? String,
|
||||
let storedTaskType = request.content.userInfo[NotificationConstants.taskTypeKey] as? String else {
|
||||
return false
|
||||
}
|
||||
return storedPlantID == plantID.uuidString && storedTaskType == taskType.rawValue
|
||||
}
|
||||
.map { $0.identifier }
|
||||
|
||||
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToRemove)
|
||||
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove)
|
||||
}
|
||||
|
||||
/// Update the app badge count
|
||||
/// - Parameter count: The number to display on the app badge (0 clears the badge)
|
||||
func updateBadgeCount(_ count: Int) async {
|
||||
do {
|
||||
try await notificationCenter.setBadgeCount(count)
|
||||
} catch {
|
||||
// Badge update failures are non-critical, log if needed
|
||||
print("Failed to update badge count: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pending notification requests
|
||||
/// - Returns: An array of pending notification requests
|
||||
func getPendingNotifications() async -> [UNNotificationRequest] {
|
||||
await notificationCenter.pendingNotificationRequests()
|
||||
}
|
||||
|
||||
/// Remove all delivered notifications from the notification center
|
||||
func removeAllDeliveredNotifications() async {
|
||||
notificationCenter.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Generate a consistent notification identifier for a task
|
||||
/// - Parameter taskID: The unique identifier of the task
|
||||
/// - Returns: A string identifier in the format "care-{taskID}"
|
||||
private func notificationIdentifier(for taskID: UUID) -> String {
|
||||
"\(NotificationConstants.notificationPrefix)\(taskID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CareTaskType Display Extension
|
||||
|
||||
extension CareTaskType {
|
||||
/// Human-readable display name for the care task type
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .watering:
|
||||
return "watering"
|
||||
case .fertilizing:
|
||||
return "fertilizing"
|
||||
case .repotting:
|
||||
return "repotting"
|
||||
case .pruning:
|
||||
return "pruning"
|
||||
case .pestControl:
|
||||
return "pest control"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//
|
||||
// AccessibilityAnnouncer.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: - AccessibilityAnnouncer
|
||||
|
||||
/// Utility for posting VoiceOver announcements and notifications.
|
||||
///
|
||||
/// This class provides convenient methods for announcing content changes to
|
||||
/// VoiceOver users. It handles the underlying UIAccessibility notification system
|
||||
/// and ensures announcements are made appropriately.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// // Simple announcement
|
||||
/// AccessibilityAnnouncer.announce("Plant identified as Monstera deliciosa")
|
||||
///
|
||||
/// // Screen change announcement
|
||||
/// AccessibilityAnnouncer.announceScreenChange("Plant details loaded")
|
||||
///
|
||||
/// // Layout change announcement
|
||||
/// AccessibilityAnnouncer.announceLayoutChange("Filter applied, showing 5 plants")
|
||||
/// ```
|
||||
///
|
||||
/// ## Best Practices
|
||||
/// - Keep announcements concise and informative
|
||||
/// - Use `announceScreenChange` when navigating to a new screen or loading significant content
|
||||
/// - Use `announceLayoutChange` when the layout changes but the screen remains the same
|
||||
/// - Avoid announcing every minor UI update to prevent overwhelming VoiceOver users
|
||||
enum AccessibilityAnnouncer {
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Posts a VoiceOver announcement.
|
||||
///
|
||||
/// Use this for important status updates, completion notifications, or
|
||||
/// error messages that VoiceOver users should hear immediately.
|
||||
///
|
||||
/// - Parameter message: The message to announce to VoiceOver users.
|
||||
static func announce(_ message: String) {
|
||||
guard !message.isEmpty else { return }
|
||||
|
||||
// Post the announcement after a brief delay to ensure it's not interrupted
|
||||
// by other accessibility events
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
UIAccessibility.post(
|
||||
notification: .announcement,
|
||||
argument: message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts an announcement and returns immediately.
|
||||
///
|
||||
/// Use this variant when you need to announce something without the small delay.
|
||||
///
|
||||
/// - Parameter message: The message to announce to VoiceOver users.
|
||||
static func announceImmediately(_ message: String) {
|
||||
guard !message.isEmpty else { return }
|
||||
|
||||
UIAccessibility.post(
|
||||
notification: .announcement,
|
||||
argument: message
|
||||
)
|
||||
}
|
||||
|
||||
/// Posts a screen change notification with an optional announcement.
|
||||
///
|
||||
/// Use this when navigating to a new screen or when significant content
|
||||
/// has loaded. VoiceOver will move focus to the first accessible element
|
||||
/// on the screen after the announcement.
|
||||
///
|
||||
/// - Parameter message: Optional message to announce. If nil, VoiceOver will
|
||||
/// announce the new screen's title or first element.
|
||||
static func announceScreenChange(_ message: String? = nil) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
UIAccessibility.post(
|
||||
notification: .screenChanged,
|
||||
argument: message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts a layout change notification.
|
||||
///
|
||||
/// Use this when the layout has changed significantly (e.g., after filtering,
|
||||
/// sorting, or when new content appears) but the user is still on the same
|
||||
/// screen. VoiceOver will move focus to the specified element or announce
|
||||
/// the message.
|
||||
///
|
||||
/// - Parameter message: Optional message to announce or element to focus on.
|
||||
static func announceLayoutChange(_ message: String? = nil) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
UIAccessibility.post(
|
||||
notification: .layoutChanged,
|
||||
argument: message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts a layout change notification and moves focus to a specific element.
|
||||
///
|
||||
/// Use this when you want to direct VoiceOver focus to a specific UI element
|
||||
/// after a layout change.
|
||||
///
|
||||
/// - Parameter element: The accessibility element to move focus to.
|
||||
static func announceLayoutChange(focusElement element: Any?) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
UIAccessibility.post(
|
||||
notification: .layoutChanged,
|
||||
argument: element
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts a page scroll notification.
|
||||
///
|
||||
/// Use this when content has scrolled (e.g., pagination, carousel movement).
|
||||
/// VoiceOver will announce "Page X of Y" style information.
|
||||
///
|
||||
/// - Parameter message: Optional message describing the scroll position.
|
||||
static func announcePageScroll(_ message: String? = nil) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
UIAccessibility.post(
|
||||
notification: .pageScrolled,
|
||||
argument: message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Methods
|
||||
|
||||
/// Announces the completion of an async operation.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - operation: A short description of the operation (e.g., "Loading", "Saving").
|
||||
/// - success: Whether the operation succeeded.
|
||||
/// - detail: Optional additional detail to include in the announcement.
|
||||
static func announceCompletion(
|
||||
of operation: String,
|
||||
success: Bool,
|
||||
detail: String? = nil
|
||||
) {
|
||||
var message: String
|
||||
if success {
|
||||
if let detail = detail {
|
||||
message = "\(operation) complete. \(detail)"
|
||||
} else {
|
||||
message = "\(operation) complete"
|
||||
}
|
||||
} else {
|
||||
if let detail = detail {
|
||||
message = "\(operation) failed. \(detail)"
|
||||
} else {
|
||||
message = "\(operation) failed"
|
||||
}
|
||||
}
|
||||
announce(message)
|
||||
}
|
||||
|
||||
/// Announces the count of items after a filter or search operation.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - count: The number of items found.
|
||||
/// - itemType: The type of items (e.g., "plants", "tasks"). Will be pluralized automatically.
|
||||
static func announceResultsCount(_ count: Int, itemType: String) {
|
||||
let message: String
|
||||
switch count {
|
||||
case 0:
|
||||
message = "No \(itemType) found"
|
||||
case 1:
|
||||
// Remove trailing 's' if present for singular
|
||||
let singular = itemType.hasSuffix("s") ? String(itemType.dropLast()) : itemType
|
||||
message = "1 \(singular) found"
|
||||
default:
|
||||
message = "\(count) \(itemType) found"
|
||||
}
|
||||
announce(message)
|
||||
}
|
||||
|
||||
/// Announces an error with an optional recovery suggestion.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - error: A description of the error.
|
||||
/// - recovery: Optional suggestion for how to recover from the error.
|
||||
static func announceError(_ error: String, recovery: String? = nil) {
|
||||
var message = "Error: \(error)"
|
||||
if let recovery = recovery {
|
||||
message += ". \(recovery)"
|
||||
}
|
||||
announce(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VoiceOver State
|
||||
|
||||
extension AccessibilityAnnouncer {
|
||||
/// Returns whether VoiceOver is currently running.
|
||||
///
|
||||
/// Use this to conditionally perform accessibility-specific logic.
|
||||
static var isVoiceOverRunning: Bool {
|
||||
UIAccessibility.isVoiceOverRunning
|
||||
}
|
||||
|
||||
/// Returns whether the user has enabled Reduce Motion.
|
||||
static var isReduceMotionEnabled: Bool {
|
||||
UIAccessibility.isReduceMotionEnabled
|
||||
}
|
||||
|
||||
/// Returns whether the user has enabled Reduce Transparency.
|
||||
static var isReduceTransparencyEnabled: Bool {
|
||||
UIAccessibility.isReduceTransparencyEnabled
|
||||
}
|
||||
|
||||
/// Returns whether the user prefers bold text.
|
||||
static var isBoldTextEnabled: Bool {
|
||||
UIAccessibility.isBoldTextEnabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//
|
||||
// AccessibilityIdentifiers.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - AccessibilityIdentifiers
|
||||
|
||||
/// Centralized accessibility identifiers for UI testing.
|
||||
///
|
||||
/// This enum provides static constants for all accessibility identifiers used
|
||||
/// throughout the app, organized by feature area. Using centralized identifiers
|
||||
/// ensures consistency between the app code and UI tests.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// Button("Capture") {
|
||||
/// capturePhoto()
|
||||
/// }
|
||||
/// .accessibilityIdentifier(AccessibilityIdentifiers.Camera.captureButton)
|
||||
/// ```
|
||||
enum AccessibilityIdentifiers {
|
||||
|
||||
// MARK: - Camera
|
||||
|
||||
/// Accessibility identifiers for the camera/capture screen
|
||||
enum Camera {
|
||||
/// The main camera capture button
|
||||
static let captureButton = "camera_capture_button"
|
||||
/// The camera preview view
|
||||
static let previewView = "camera_preview_view"
|
||||
/// Button to switch between front and back cameras
|
||||
static let switchCameraButton = "camera_switch_button"
|
||||
/// Button to toggle flash mode
|
||||
static let flashToggleButton = "camera_flash_toggle_button"
|
||||
/// Button to open photo library
|
||||
static let photoLibraryButton = "camera_photo_library_button"
|
||||
/// Button to close camera view
|
||||
static let closeButton = "camera_close_button"
|
||||
/// The camera permission denied view
|
||||
static let permissionDeniedView = "camera_permission_denied_view"
|
||||
/// Button to open settings for camera permission
|
||||
static let openSettingsButton = "camera_open_settings_button"
|
||||
}
|
||||
|
||||
// MARK: - Collection
|
||||
|
||||
/// Accessibility identifiers for the plant collection screen
|
||||
enum Collection {
|
||||
/// The main collection view
|
||||
static let collectionView = "collection_view"
|
||||
/// The search field
|
||||
static let searchField = "collection_search_field"
|
||||
/// Button to toggle between grid and list view
|
||||
static let viewModeToggle = "collection_view_mode_toggle"
|
||||
/// Button to open filter sheet
|
||||
static let filterButton = "collection_filter_button"
|
||||
/// The grid layout container
|
||||
static let gridView = "collection_grid_view"
|
||||
/// The list layout container
|
||||
static let listView = "collection_list_view"
|
||||
/// Empty state view when no plants
|
||||
static let emptyStateView = "collection_empty_state_view"
|
||||
/// Loading indicator
|
||||
static let loadingIndicator = "collection_loading_indicator"
|
||||
/// A single plant grid item (append plant ID for unique identifier)
|
||||
static let plantGridItem = "collection_plant_grid_item"
|
||||
/// A single plant list row (append plant ID for unique identifier)
|
||||
static let plantListRow = "collection_plant_list_row"
|
||||
/// Favorite button on a plant item
|
||||
static let favoriteButton = "collection_favorite_button"
|
||||
/// Delete action on a plant item
|
||||
static let deleteAction = "collection_delete_action"
|
||||
|
||||
/// Creates a unique identifier for a plant grid item
|
||||
static func plantGridItemID(_ plantID: UUID) -> String {
|
||||
"\(plantGridItem)_\(plantID.uuidString)"
|
||||
}
|
||||
|
||||
/// Creates a unique identifier for a plant list row
|
||||
static func plantListRowID(_ plantID: UUID) -> String {
|
||||
"\(plantListRow)_\(plantID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identification
|
||||
|
||||
/// Accessibility identifiers for the identification screen
|
||||
enum Identification {
|
||||
/// The main identification view
|
||||
static let identificationView = "identification_view"
|
||||
/// The captured image preview
|
||||
static let imagePreview = "identification_image_preview"
|
||||
/// Loading indicator during identification
|
||||
static let loadingIndicator = "identification_loading_indicator"
|
||||
/// The results list container
|
||||
static let resultsContainer = "identification_results_container"
|
||||
/// A single prediction result row (append index for unique identifier)
|
||||
static let predictionRow = "identification_prediction_row"
|
||||
/// The confidence indicator
|
||||
static let confidenceIndicator = "identification_confidence_indicator"
|
||||
/// Button to retry identification
|
||||
static let retryButton = "identification_retry_button"
|
||||
/// Button to return to camera
|
||||
static let returnToCameraButton = "identification_return_to_camera_button"
|
||||
/// Button to save to collection
|
||||
static let saveToCollectionButton = "identification_save_to_collection_button"
|
||||
/// Button to identify again
|
||||
static let identifyAgainButton = "identification_identify_again_button"
|
||||
/// Close/dismiss button
|
||||
static let closeButton = "identification_close_button"
|
||||
/// Error state view
|
||||
static let errorView = "identification_error_view"
|
||||
|
||||
/// Creates a unique identifier for a prediction row
|
||||
static func predictionRowID(_ index: Int) -> String {
|
||||
"\(predictionRow)_\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlantDetail
|
||||
|
||||
/// Accessibility identifiers for the plant detail screen
|
||||
enum PlantDetail {
|
||||
/// The main plant detail view
|
||||
static let detailView = "plant_detail_view"
|
||||
/// The plant header section
|
||||
static let headerSection = "plant_detail_header_section"
|
||||
/// The plant image
|
||||
static let plantImage = "plant_detail_plant_image"
|
||||
/// The plant name label
|
||||
static let plantName = "plant_detail_plant_name"
|
||||
/// The scientific name label
|
||||
static let scientificName = "plant_detail_scientific_name"
|
||||
/// The family label
|
||||
static let familyName = "plant_detail_family_name"
|
||||
/// Favorite toggle button
|
||||
static let favoriteButton = "plant_detail_favorite_button"
|
||||
/// Care information section
|
||||
static let careSection = "plant_detail_care_section"
|
||||
/// Watering information
|
||||
static let wateringInfo = "plant_detail_watering_info"
|
||||
/// Light requirements information
|
||||
static let lightInfo = "plant_detail_light_info"
|
||||
/// Humidity information
|
||||
static let humidityInfo = "plant_detail_humidity_info"
|
||||
/// Upcoming tasks section
|
||||
static let tasksSection = "plant_detail_tasks_section"
|
||||
/// Edit button
|
||||
static let editButton = "plant_detail_edit_button"
|
||||
/// Delete button
|
||||
static let deleteButton = "plant_detail_delete_button"
|
||||
}
|
||||
|
||||
// MARK: - CareSchedule
|
||||
|
||||
/// Accessibility identifiers for the care schedule screen
|
||||
enum CareSchedule {
|
||||
/// The main care schedule view
|
||||
static let scheduleView = "care_schedule_view"
|
||||
/// The today section
|
||||
static let todaySection = "care_schedule_today_section"
|
||||
/// The upcoming section
|
||||
static let upcomingSection = "care_schedule_upcoming_section"
|
||||
/// The overdue section
|
||||
static let overdueSection = "care_schedule_overdue_section"
|
||||
/// A single care task row (append task ID for unique identifier)
|
||||
static let taskRow = "care_schedule_task_row"
|
||||
/// Complete task action
|
||||
static let completeAction = "care_schedule_complete_action"
|
||||
/// Snooze task action
|
||||
static let snoozeAction = "care_schedule_snooze_action"
|
||||
/// Add task button
|
||||
static let addTaskButton = "care_schedule_add_task_button"
|
||||
/// Empty state view
|
||||
static let emptyStateView = "care_schedule_empty_state_view"
|
||||
|
||||
/// Creates a unique identifier for a task row
|
||||
static func taskRowID(_ taskID: UUID) -> String {
|
||||
"\(taskRow)_\(taskID.uuidString)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
/// Accessibility identifiers for the settings screen
|
||||
enum Settings {
|
||||
/// The main settings view
|
||||
static let settingsView = "settings_view"
|
||||
/// Notifications section
|
||||
static let notificationsSection = "settings_notifications_section"
|
||||
/// Notifications toggle
|
||||
static let notificationsToggle = "settings_notifications_toggle"
|
||||
/// Reminder time picker
|
||||
static let reminderTimePicker = "settings_reminder_time_picker"
|
||||
/// Appearance section
|
||||
static let appearanceSection = "settings_appearance_section"
|
||||
/// Theme picker
|
||||
static let themePicker = "settings_theme_picker"
|
||||
/// Data section
|
||||
static let dataSection = "settings_data_section"
|
||||
/// Export data button
|
||||
static let exportDataButton = "settings_export_data_button"
|
||||
/// Import data button
|
||||
static let importDataButton = "settings_import_data_button"
|
||||
/// Clear cache button
|
||||
static let clearCacheButton = "settings_clear_cache_button"
|
||||
/// About section
|
||||
static let aboutSection = "settings_about_section"
|
||||
/// Version info label
|
||||
static let versionInfo = "settings_version_info"
|
||||
/// Privacy policy button
|
||||
static let privacyPolicyButton = "settings_privacy_policy_button"
|
||||
/// Terms of service button
|
||||
static let termsButton = "settings_terms_button"
|
||||
}
|
||||
|
||||
// MARK: - TabBar
|
||||
|
||||
/// Accessibility identifiers for the main tab bar
|
||||
enum TabBar {
|
||||
/// The main tab bar
|
||||
static let tabBar = "main_tab_bar"
|
||||
/// Collection tab
|
||||
static let collectionTab = "tab_bar_collection_tab"
|
||||
/// Camera tab
|
||||
static let cameraTab = "tab_bar_camera_tab"
|
||||
/// Care schedule tab
|
||||
static let careTab = "tab_bar_care_tab"
|
||||
/// Settings tab
|
||||
static let settingsTab = "tab_bar_settings_tab"
|
||||
}
|
||||
|
||||
// MARK: - Common
|
||||
|
||||
/// Common accessibility identifiers used across multiple screens
|
||||
enum Common {
|
||||
/// Generic loading indicator
|
||||
static let loadingIndicator = "common_loading_indicator"
|
||||
/// Generic error view
|
||||
static let errorView = "common_error_view"
|
||||
/// Generic retry button
|
||||
static let retryButton = "common_retry_button"
|
||||
/// Generic close button
|
||||
static let closeButton = "common_close_button"
|
||||
/// Generic back button
|
||||
static let backButton = "common_back_button"
|
||||
/// Generic done button
|
||||
static let doneButton = "common_done_button"
|
||||
/// Generic cancel button
|
||||
static let cancelButton = "common_cancel_button"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// NetworkMonitor.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 1/21/26.
|
||||
//
|
||||
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Connection Type
|
||||
|
||||
/// Represents the type of network connection currently available
|
||||
enum ConnectionType: String, Sendable {
|
||||
case wifi
|
||||
case cellular
|
||||
case ethernet
|
||||
case unknown
|
||||
}
|
||||
|
||||
// MARK: - Network Monitor
|
||||
|
||||
/// Monitors network connectivity status using NWPathMonitor
|
||||
/// Uses @Observable for SwiftUI integration (iOS 17+)
|
||||
@Observable
|
||||
final class NetworkMonitor: @unchecked Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Current network connectivity status
|
||||
private(set) var isConnected: Bool = false
|
||||
|
||||
/// Current connection type (wifi, cellular, ethernet, or unknown)
|
||||
private(set) var connectionType: ConnectionType = .unknown
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private let monitor: NWPathMonitor
|
||||
private let monitorQueue: DispatchQueue
|
||||
private var isMonitoring = false
|
||||
private let lock = NSLock()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new NetworkMonitor and automatically starts monitoring
|
||||
init() {
|
||||
self.monitor = NWPathMonitor()
|
||||
self.monitorQueue = DispatchQueue(
|
||||
label: "com.plantguide.networkmonitor",
|
||||
qos: .utility
|
||||
)
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopMonitoring()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Begins observing network changes
|
||||
/// Called automatically on initialization
|
||||
func startMonitoring() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard !isMonitoring else { return }
|
||||
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
|
||||
// Update on main thread for SwiftUI observation
|
||||
DispatchQueue.main.async {
|
||||
self.updateConnectionStatus(from: path)
|
||||
}
|
||||
}
|
||||
|
||||
monitor.start(queue: monitorQueue)
|
||||
isMonitoring = true
|
||||
}
|
||||
|
||||
/// Stops observing network changes
|
||||
func stopMonitoring() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard isMonitoring else { return }
|
||||
|
||||
monitor.cancel()
|
||||
isMonitoring = false
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func updateConnectionStatus(from path: NWPath) {
|
||||
isConnected = path.status == .satisfied
|
||||
connectionType = determineConnectionType(from: path)
|
||||
}
|
||||
|
||||
private func determineConnectionType(from path: NWPath) -> ConnectionType {
|
||||
guard path.status == .satisfied else {
|
||||
return .unknown
|
||||
}
|
||||
|
||||
// Check interface types in order of preference
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
return .wifi
|
||||
} else if path.usesInterfaceType(.cellular) {
|
||||
return .cellular
|
||||
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||
return .ethernet
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Environment Integration
|
||||
|
||||
/// Environment key for accessing the network monitor
|
||||
private struct NetworkMonitorKey: EnvironmentKey {
|
||||
static let defaultValue: NetworkMonitor = NetworkMonitor()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The network monitor for observing connectivity status
|
||||
var networkMonitor: NetworkMonitor {
|
||||
get { self[NetworkMonitorKey.self] }
|
||||
set { self[NetworkMonitorKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Inject a custom network monitor into the view hierarchy
|
||||
/// - Parameter monitor: The network monitor to use
|
||||
/// - Returns: A view with the network monitor injected into the environment
|
||||
func networkMonitor(_ monitor: NetworkMonitor) -> some View {
|
||||
environment(\.networkMonitor, monitor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//
|
||||
// ValueTransformers.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Custom value transformers for Core Data to securely transform
|
||||
// complex types for storage and retrieval.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - URLArrayTransformer
|
||||
|
||||
/// Transforms arrays of URLs for secure Core Data storage.
|
||||
/// Uses NSSecureUnarchiveFromDataTransformer for safe archiving/unarchiving.
|
||||
@objc(URLArrayTransformer)
|
||||
final class URLArrayTransformer: NSSecureUnarchiveFromDataTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "URLArrayTransformer")
|
||||
|
||||
/// Classes that can be safely decoded at the top level
|
||||
override static var allowedTopLevelClasses: [AnyClass] {
|
||||
[NSArray.self, NSURL.self]
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
URLArrayTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StringArrayTransformer
|
||||
|
||||
/// Transforms arrays of Strings for secure Core Data storage.
|
||||
/// Uses NSSecureUnarchiveFromDataTransformer for safe archiving/unarchiving.
|
||||
@objc(StringArrayTransformer)
|
||||
final class StringArrayTransformer: NSSecureUnarchiveFromDataTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "StringArrayTransformer")
|
||||
|
||||
/// Classes that can be safely decoded at the top level
|
||||
override static var allowedTopLevelClasses: [AnyClass] {
|
||||
[NSArray.self, NSString.self]
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
StringArrayTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentificationResultArrayTransformer
|
||||
|
||||
/// Transforms arrays of PlantIdentification objects for secure Core Data storage.
|
||||
/// Uses JSON encoding/decoding to handle Codable types securely.
|
||||
@objc(IdentificationResultArrayTransformer)
|
||||
final class IdentificationResultArrayTransformer: ValueTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "IdentificationResultArrayTransformer")
|
||||
|
||||
/// Indicates this transformer allows reverse transformation
|
||||
override class func allowsReverseTransformation() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The class returned when transforming
|
||||
override class func transformedValueClass() -> AnyClass {
|
||||
NSData.self
|
||||
}
|
||||
|
||||
/// Transforms an array of PlantIdentification objects to Data for storage
|
||||
/// - Parameter value: The array of PlantIdentification objects to transform
|
||||
/// - Returns: Encoded Data representation, or nil if transformation fails
|
||||
override func transformedValue(_ value: Any?) -> Any? {
|
||||
guard let identifications = value as? [PlantIdentification] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(identifications)
|
||||
return data
|
||||
} catch {
|
||||
print("IdentificationResultArrayTransformer: Failed to encode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms Data back to an array of PlantIdentification objects
|
||||
/// - Parameter value: The Data to transform back
|
||||
/// - Returns: Array of PlantIdentification objects, or nil if transformation fails
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any? {
|
||||
guard let data = value as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let identifications = try decoder.decode([PlantIdentification].self, from: data)
|
||||
return identifications
|
||||
} catch {
|
||||
print("IdentificationResultArrayTransformer: Failed to decode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
IdentificationResultArrayTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WateringScheduleTransformer
|
||||
|
||||
/// Transforms WateringSchedule for secure Core Data storage.
|
||||
/// Uses JSON encoding/decoding to handle Codable types securely.
|
||||
@objc(WateringScheduleTransformer)
|
||||
final class WateringScheduleTransformer: ValueTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "WateringScheduleTransformer")
|
||||
|
||||
/// Indicates this transformer allows reverse transformation
|
||||
override class func allowsReverseTransformation() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The class returned when transforming
|
||||
override class func transformedValueClass() -> AnyClass {
|
||||
NSData.self
|
||||
}
|
||||
|
||||
/// Transforms a WateringSchedule to Data for storage
|
||||
/// - Parameter value: The WateringSchedule to transform
|
||||
/// - Returns: Encoded Data representation, or nil if transformation fails
|
||||
override func transformedValue(_ value: Any?) -> Any? {
|
||||
guard let schedule = value as? WateringSchedule else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(schedule)
|
||||
return data
|
||||
} catch {
|
||||
print("WateringScheduleTransformer: Failed to encode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms Data back to a WateringSchedule
|
||||
/// - Parameter value: The Data to transform back
|
||||
/// - Returns: WateringSchedule, or nil if transformation fails
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any? {
|
||||
guard let data = value as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let schedule = try decoder.decode(WateringSchedule.self, from: data)
|
||||
return schedule
|
||||
} catch {
|
||||
print("WateringScheduleTransformer: Failed to decode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
WateringScheduleTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TemperatureRangeTransformer
|
||||
|
||||
/// Transforms TemperatureRange for secure Core Data storage.
|
||||
/// Uses JSON encoding/decoding to handle Codable types securely.
|
||||
@objc(TemperatureRangeTransformer)
|
||||
final class TemperatureRangeTransformer: ValueTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "TemperatureRangeTransformer")
|
||||
|
||||
/// Indicates this transformer allows reverse transformation
|
||||
override class func allowsReverseTransformation() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The class returned when transforming
|
||||
override class func transformedValueClass() -> AnyClass {
|
||||
NSData.self
|
||||
}
|
||||
|
||||
/// Transforms a TemperatureRange to Data for storage
|
||||
/// - Parameter value: The TemperatureRange to transform
|
||||
/// - Returns: Encoded Data representation, or nil if transformation fails
|
||||
override func transformedValue(_ value: Any?) -> Any? {
|
||||
guard let range = value as? TemperatureRange else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(range)
|
||||
return data
|
||||
} catch {
|
||||
print("TemperatureRangeTransformer: Failed to encode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms Data back to a TemperatureRange
|
||||
/// - Parameter value: The Data to transform back
|
||||
/// - Returns: TemperatureRange, or nil if transformation fails
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any? {
|
||||
guard let data = value as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let range = try decoder.decode(TemperatureRange.self, from: data)
|
||||
return range
|
||||
} catch {
|
||||
print("TemperatureRangeTransformer: Failed to decode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
TemperatureRangeTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FertilizerScheduleTransformer
|
||||
|
||||
/// Transforms FertilizerSchedule for secure Core Data storage.
|
||||
/// Uses JSON encoding/decoding to handle Codable types securely.
|
||||
@objc(FertilizerScheduleTransformer)
|
||||
final class FertilizerScheduleTransformer: ValueTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "FertilizerScheduleTransformer")
|
||||
|
||||
/// Indicates this transformer allows reverse transformation
|
||||
override class func allowsReverseTransformation() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The class returned when transforming
|
||||
override class func transformedValueClass() -> AnyClass {
|
||||
NSData.self
|
||||
}
|
||||
|
||||
/// Transforms a FertilizerSchedule to Data for storage
|
||||
/// - Parameter value: The FertilizerSchedule to transform
|
||||
/// - Returns: Encoded Data representation, or nil if transformation fails
|
||||
override func transformedValue(_ value: Any?) -> Any? {
|
||||
guard let schedule = value as? FertilizerSchedule else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(schedule)
|
||||
return data
|
||||
} catch {
|
||||
print("FertilizerScheduleTransformer: Failed to encode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms Data back to a FertilizerSchedule
|
||||
/// - Parameter value: The Data to transform back
|
||||
/// - Returns: FertilizerSchedule, or nil if transformation fails
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any? {
|
||||
guard let data = value as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let schedule = try decoder.decode(FertilizerSchedule.self, from: data)
|
||||
return schedule
|
||||
} catch {
|
||||
print("FertilizerScheduleTransformer: Failed to decode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
FertilizerScheduleTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SeasonArrayTransformer
|
||||
|
||||
/// Transforms arrays of Season enums for secure Core Data storage.
|
||||
/// Uses JSON encoding/decoding to handle Codable types securely.
|
||||
@objc(SeasonArrayTransformer)
|
||||
final class SeasonArrayTransformer: ValueTransformer {
|
||||
|
||||
/// The registered name for this transformer
|
||||
static let name = NSValueTransformerName(rawValue: "SeasonArrayTransformer")
|
||||
|
||||
/// Indicates this transformer allows reverse transformation
|
||||
override class func allowsReverseTransformation() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The class returned when transforming
|
||||
override class func transformedValueClass() -> AnyClass {
|
||||
NSData.self
|
||||
}
|
||||
|
||||
/// Transforms an array of Season to Data for storage
|
||||
/// - Parameter value: The array of Season to transform
|
||||
/// - Returns: Encoded Data representation, or nil if transformation fails
|
||||
override func transformedValue(_ value: Any?) -> Any? {
|
||||
guard let seasons = value as? [Season] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(seasons)
|
||||
return data
|
||||
} catch {
|
||||
print("SeasonArrayTransformer: Failed to encode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms Data back to an array of Season
|
||||
/// - Parameter value: The Data to transform back
|
||||
/// - Returns: Array of Season, or nil if transformation fails
|
||||
override func reverseTransformedValue(_ value: Any?) -> Any? {
|
||||
guard let data = value as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let seasons = try decoder.decode([Season].self, from: data)
|
||||
return seasons
|
||||
} catch {
|
||||
print("SeasonArrayTransformer: Failed to decode - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this transformer with the ValueTransformer registry.
|
||||
/// Must be called before Core Data stack is initialized.
|
||||
static func register() {
|
||||
ValueTransformer.setValueTransformer(
|
||||
SeasonArrayTransformer(),
|
||||
forName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlantIdentification Codable Conformance
|
||||
|
||||
/// Extension to make PlantIdentification conform to Codable for transformer support
|
||||
extension PlantIdentification: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case species
|
||||
case confidence
|
||||
case source
|
||||
case timestamp
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let id = try container.decode(UUID.self, forKey: .id)
|
||||
let species = try container.decode(String.self, forKey: .species)
|
||||
let confidence = try container.decode(Double.self, forKey: .confidence)
|
||||
let source = try container.decode(IdentificationSource.self, forKey: .source)
|
||||
let timestamp = try container.decode(Date.self, forKey: .timestamp)
|
||||
|
||||
self.init(
|
||||
id: id,
|
||||
species: species,
|
||||
confidence: confidence,
|
||||
source: source,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(species, forKey: .species)
|
||||
try container.encode(confidence, forKey: .confidence)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(timestamp, forKey: .timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//
|
||||
// IdentificationCache.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - CachedPrediction
|
||||
|
||||
/// Represents a single prediction result stored in the cache.
|
||||
///
|
||||
/// This is a lightweight representation of a species prediction
|
||||
/// suitable for serialization and long-term storage.
|
||||
public struct CachedPrediction: Codable, Sendable, Equatable {
|
||||
/// The scientific species name
|
||||
public let speciesName: String
|
||||
|
||||
/// The common name for the species, if available
|
||||
public let commonName: String?
|
||||
|
||||
/// Confidence score from 0.0 to 1.0
|
||||
public let confidence: Double
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(speciesName: String, commonName: String?, confidence: Double) {
|
||||
self.speciesName = speciesName
|
||||
self.commonName = commonName
|
||||
self.confidence = min(max(confidence, 0.0), 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CachedIdentification
|
||||
|
||||
/// Represents a complete cached identification result.
|
||||
///
|
||||
/// Contains all predictions from an identification attempt along with
|
||||
/// metadata about when and how the identification was performed.
|
||||
public struct CachedIdentification: Codable, Sendable, Equatable {
|
||||
/// All species predictions from the identification
|
||||
public let predictions: [CachedPrediction]
|
||||
|
||||
/// When the identification was performed
|
||||
public let timestamp: Date
|
||||
|
||||
/// The source of the identification ("onDevice" or "plantNetAPI")
|
||||
public let source: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(predictions: [CachedPrediction], timestamp: Date = Date(), source: String) {
|
||||
self.predictions = predictions
|
||||
self.timestamp = timestamp
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageHasher
|
||||
|
||||
/// Utility for generating consistent hashes from image data.
|
||||
///
|
||||
/// Uses SHA256 to create a deterministic hash that can be used
|
||||
/// as a cache key for identification results.
|
||||
public enum ImageHasher {
|
||||
/// Generates a hash string from image data.
|
||||
///
|
||||
/// - Parameter imageData: The raw image data to hash.
|
||||
/// - Returns: A 16-character hexadecimal string representing the hash.
|
||||
public static func hash(imageData: Data) -> String {
|
||||
let digest = SHA256.hash(data: imageData)
|
||||
let hashString = digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return String(hashString.prefix(16))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentificationCacheProtocol
|
||||
|
||||
/// Protocol for identification caching, enabling testability through dependency injection.
|
||||
public protocol IdentificationCacheProtocol: Sendable {
|
||||
/// Retrieves a cached identification result for the given image hash.
|
||||
/// - Parameter imageHash: The hash of the image to look up.
|
||||
/// - Returns: The cached identification if found and not expired, nil otherwise.
|
||||
func get(for imageHash: String) async -> CachedIdentification?
|
||||
|
||||
/// Stores an identification result in the cache.
|
||||
/// - Parameters:
|
||||
/// - result: The identification result to cache.
|
||||
/// - imageHash: The hash of the image to use as the cache key.
|
||||
func store(_ result: CachedIdentification, for imageHash: String) async
|
||||
|
||||
/// Removes all entries from the cache.
|
||||
func clear() async
|
||||
|
||||
/// Removes entries that have exceeded the TTL.
|
||||
func clearExpired() async
|
||||
|
||||
/// The current number of entries in the cache.
|
||||
var entryCount: Int { get async }
|
||||
}
|
||||
|
||||
// MARK: - IdentificationCache
|
||||
|
||||
/// An actor-based cache for plant identification results.
|
||||
///
|
||||
/// This cache stores identification results keyed by image hash to avoid
|
||||
/// redundant API calls for the same image. It uses file-based JSON storage
|
||||
/// for persistence and implements LRU eviction when the cache exceeds
|
||||
/// the maximum entry count.
|
||||
///
|
||||
/// Thread safety is guaranteed through Swift's actor isolation.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let cache = IdentificationCache()
|
||||
///
|
||||
/// // Check for cached result
|
||||
/// let imageHash = ImageHasher.hash(imageData: imageData)
|
||||
/// if let cached = await cache.get(for: imageHash) {
|
||||
/// // Use cached result
|
||||
/// return cached
|
||||
/// }
|
||||
///
|
||||
/// // Perform identification...
|
||||
/// let result = CachedIdentification(predictions: predictions, source: "onDevice")
|
||||
///
|
||||
/// // Store result
|
||||
/// await cache.store(result, for: imageHash)
|
||||
/// ```
|
||||
public actor IdentificationCache: IdentificationCacheProtocol {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let cacheFileName = "identification_cache.json"
|
||||
static let keyPrefix = "identification_cache_"
|
||||
static let defaultTTLDays = 7
|
||||
static let defaultMaxEntries = 100
|
||||
}
|
||||
|
||||
// MARK: - Cache Entry
|
||||
|
||||
/// Internal structure for storing cache entries with access tracking.
|
||||
private struct CacheEntry: Codable, Sendable {
|
||||
let identification: CachedIdentification
|
||||
var lastAccessDate: Date
|
||||
|
||||
init(identification: CachedIdentification, lastAccessDate: Date = Date()) {
|
||||
self.identification = identification
|
||||
self.lastAccessDate = lastAccessDate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Storage
|
||||
|
||||
/// Internal structure for the entire cache storage.
|
||||
private struct CacheStorage: Codable, Sendable {
|
||||
var entries: [String: CacheEntry]
|
||||
|
||||
init(entries: [String: CacheEntry] = [:]) {
|
||||
self.entries = entries
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Time-to-live in days for cache entries.
|
||||
public let ttlDays: Int
|
||||
|
||||
/// Maximum number of entries before LRU eviction occurs.
|
||||
public let maxEntries: Int
|
||||
|
||||
private var storage: CacheStorage
|
||||
private let fileURL: URL
|
||||
private let fileManager: FileManager
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new identification cache.
|
||||
/// - Parameters:
|
||||
/// - ttlDays: Time-to-live in days for cache entries (default: 7).
|
||||
/// - maxEntries: Maximum number of entries before eviction (default: 100).
|
||||
/// - fileManager: FileManager instance for file operations (default: .default).
|
||||
public init(
|
||||
ttlDays: Int = 7,
|
||||
maxEntries: Int = 100,
|
||||
fileManager: FileManager = .default
|
||||
) {
|
||||
self.ttlDays = ttlDays
|
||||
self.maxEntries = maxEntries
|
||||
self.fileManager = fileManager
|
||||
|
||||
// Set up cache file URL in Application Support directory
|
||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let cacheDirectory = appSupport.appendingPathComponent("PlantGuide", isDirectory: true)
|
||||
|
||||
// Create directory if needed
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
|
||||
self.fileURL = cacheDirectory.appendingPathComponent(Constants.cacheFileName)
|
||||
|
||||
// Load existing cache or create empty storage
|
||||
self.storage = Self.loadStorage(from: fileURL)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Retrieves a cached identification result for the given image hash.
|
||||
///
|
||||
/// Updates the last access date for LRU tracking when an entry is found.
|
||||
/// Returns nil if the entry doesn't exist or has expired.
|
||||
///
|
||||
/// - Parameter imageHash: The hash of the image to look up.
|
||||
/// - Returns: The cached identification if found and not expired, nil otherwise.
|
||||
public func get(for imageHash: String) -> CachedIdentification? {
|
||||
let key = cacheKey(for: imageHash)
|
||||
|
||||
guard var entry = storage.entries[key] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
guard !isExpired(entry.identification.timestamp) else {
|
||||
storage.entries.removeValue(forKey: key)
|
||||
persistStorage()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update last access date for LRU tracking
|
||||
entry.lastAccessDate = Date()
|
||||
storage.entries[key] = entry
|
||||
persistStorage()
|
||||
|
||||
return entry.identification
|
||||
}
|
||||
|
||||
/// Stores an identification result in the cache.
|
||||
///
|
||||
/// If the cache exceeds the maximum entry count after insertion,
|
||||
/// the least recently used entries are evicted.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - result: The identification result to cache.
|
||||
/// - imageHash: The hash of the image to use as the cache key.
|
||||
public func store(_ result: CachedIdentification, for imageHash: String) {
|
||||
let key = cacheKey(for: imageHash)
|
||||
let entry = CacheEntry(identification: result)
|
||||
|
||||
storage.entries[key] = entry
|
||||
|
||||
// Evict if over capacity
|
||||
evictIfNeeded()
|
||||
|
||||
persistStorage()
|
||||
}
|
||||
|
||||
/// Removes all entries from the cache.
|
||||
public func clear() {
|
||||
storage.entries.removeAll()
|
||||
persistStorage()
|
||||
}
|
||||
|
||||
/// Removes entries that have exceeded the TTL.
|
||||
public func clearExpired() {
|
||||
let expiredKeys = storage.entries.filter { isExpired($0.value.identification.timestamp) }.keys
|
||||
for key in expiredKeys {
|
||||
storage.entries.removeValue(forKey: key)
|
||||
}
|
||||
persistStorage()
|
||||
}
|
||||
|
||||
/// The current number of entries in the cache.
|
||||
public var entryCount: Int {
|
||||
storage.entries.count
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Generates a cache key from an image hash.
|
||||
private func cacheKey(for imageHash: String) -> String {
|
||||
Constants.keyPrefix + imageHash
|
||||
}
|
||||
|
||||
/// Checks if a timestamp has exceeded the TTL.
|
||||
private func isExpired(_ timestamp: Date) -> Bool {
|
||||
let expirationDate = Calendar.current.date(byAdding: .day, value: ttlDays, to: timestamp) ?? timestamp
|
||||
return Date() > expirationDate
|
||||
}
|
||||
|
||||
/// Evicts least recently used entries if the cache exceeds capacity.
|
||||
private func evictIfNeeded() {
|
||||
guard storage.entries.count > maxEntries else { return }
|
||||
|
||||
// Sort by last access date (oldest first)
|
||||
let sortedKeys = storage.entries
|
||||
.sorted { $0.value.lastAccessDate < $1.value.lastAccessDate }
|
||||
.map { $0.key }
|
||||
|
||||
// Remove oldest entries until we're at capacity
|
||||
let entriesToRemove = storage.entries.count - maxEntries
|
||||
for key in sortedKeys.prefix(entriesToRemove) {
|
||||
storage.entries.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the current storage to disk.
|
||||
private func persistStorage() {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(storage)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
// Log error in production; silently fail for now
|
||||
print("IdentificationCache: Failed to persist storage: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads storage from disk.
|
||||
private static func loadStorage(from url: URL) -> CacheStorage {
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
return CacheStorage()
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try decoder.decode(CacheStorage.self, from: data)
|
||||
} catch {
|
||||
// Log error in production; return empty storage
|
||||
print("IdentificationCache: Failed to load storage: \(error)")
|
||||
return CacheStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//
|
||||
// ImageCache.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - ImageCacheError
|
||||
|
||||
/// Errors that can occur during image caching operations.
|
||||
public enum ImageCacheError: Error, LocalizedError {
|
||||
/// The data could not be converted to a valid image.
|
||||
case invalidImageData
|
||||
|
||||
/// JPEG compression failed.
|
||||
case compressionFailed
|
||||
|
||||
/// Failed to write the image to disk.
|
||||
case writeFailed
|
||||
|
||||
/// Failed to download the image from the URL.
|
||||
case downloadFailed(Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImageData:
|
||||
return "The image data is invalid or corrupted."
|
||||
case .compressionFailed:
|
||||
return "Failed to compress the image."
|
||||
case .writeFailed:
|
||||
return "Failed to write the image to disk."
|
||||
case .downloadFailed(let error):
|
||||
return "Failed to download image: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageCacheProtocol
|
||||
|
||||
/// Protocol for image caching, enabling testability through dependency injection.
|
||||
public protocol ImageCacheProtocol: Sendable {
|
||||
/// Downloads and caches an image from a URL for a specific plant.
|
||||
/// - Parameters:
|
||||
/// - url: The URL to download the image from.
|
||||
/// - plantID: The UUID of the plant this image belongs to.
|
||||
func cacheImage(from url: URL, for plantID: UUID) async throws
|
||||
|
||||
/// Retrieves a cached image by plant ID and URL hash.
|
||||
/// - Parameters:
|
||||
/// - plantID: The UUID of the plant.
|
||||
/// - urlHash: The SHA256 hash of the original URL.
|
||||
/// - Returns: The cached UIImage if found, nil otherwise.
|
||||
func getCachedImage(for plantID: UUID, urlHash: String) async -> UIImage?
|
||||
|
||||
/// Retrieves a cached image by plant ID and URL.
|
||||
/// - Parameters:
|
||||
/// - plantID: The UUID of the plant.
|
||||
/// - url: The original URL of the image.
|
||||
/// - Returns: The cached UIImage if found, nil otherwise.
|
||||
func getCachedImage(for plantID: UUID, url: URL) async -> UIImage?
|
||||
|
||||
/// Clears all cached images for a specific plant.
|
||||
/// - Parameter plantID: The UUID of the plant whose cache should be cleared.
|
||||
func clearCache(for plantID: UUID) async
|
||||
|
||||
/// Clears the entire image cache.
|
||||
func clearAllCache() async
|
||||
|
||||
/// Gets the total size of the disk cache in bytes.
|
||||
/// - Returns: The total cache size in bytes.
|
||||
func getCacheSize() async -> Int64
|
||||
}
|
||||
|
||||
// MARK: - MemoryCacheWrapper
|
||||
|
||||
/// A thread-safe wrapper around NSCache for use in actors.
|
||||
/// Automatically clears cache on memory warnings to prevent app termination.
|
||||
private final class MemoryCacheWrapper: @unchecked Sendable {
|
||||
private let cache: NSCache<NSString, UIImage>
|
||||
private var memoryWarningObserver: NSObjectProtocol?
|
||||
|
||||
init(countLimit: Int, totalCostLimit: Int) {
|
||||
self.cache = NSCache<NSString, UIImage>()
|
||||
self.cache.countLimit = countLimit
|
||||
self.cache.totalCostLimit = totalCostLimit
|
||||
// Set cache name for debugging
|
||||
self.cache.name = "com.plantguide.imageCache"
|
||||
|
||||
// Register for memory warning notifications to automatically clear cache
|
||||
// when the system is under memory pressure
|
||||
memoryWarningObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didReceiveMemoryWarningNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.cache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = memoryWarningObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
func setObject(_ image: UIImage, forKey key: String, cost: Int) {
|
||||
cache.setObject(image, forKey: key as NSString, cost: cost)
|
||||
}
|
||||
|
||||
func object(forKey key: String) -> UIImage? {
|
||||
cache.object(forKey: key as NSString)
|
||||
}
|
||||
|
||||
func removeObject(forKey key: String) {
|
||||
cache.removeObject(forKey: key as NSString)
|
||||
}
|
||||
|
||||
func removeAllObjects() {
|
||||
cache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageCache
|
||||
|
||||
/// An actor-based cache for plant images with both memory and disk storage.
|
||||
///
|
||||
/// This cache provides two-tier caching:
|
||||
/// - Memory cache using NSCache for fast access (limited to 50 images, 100MB)
|
||||
/// - Disk cache in the Caches directory for persistence
|
||||
///
|
||||
/// Images are stored as JPEG files with 0.8 compression quality.
|
||||
/// Thread safety is guaranteed through Swift's actor isolation.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let cache = ImageCache()
|
||||
///
|
||||
/// // Cache an image from URL
|
||||
/// try await cache.cacheImage(from: imageURL, for: plantID)
|
||||
///
|
||||
/// // Retrieve cached image
|
||||
/// if let image = await cache.getCachedImage(for: plantID, url: imageURL) {
|
||||
/// // Use cached image
|
||||
/// }
|
||||
/// ```
|
||||
public actor ImageCache: ImageCacheProtocol {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let cacheDirectoryName = "PlantImages"
|
||||
static let memoryCountLimit = 50
|
||||
static let memoryCostLimit = 100 * 1024 * 1024 // 100MB
|
||||
static let jpegCompressionQuality: CGFloat = 0.8
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let memoryCache: MemoryCacheWrapper
|
||||
private let cacheDirectory: URL
|
||||
private let fileManager: FileManager
|
||||
private let urlSession: URLSession
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new image cache.
|
||||
/// - Parameters:
|
||||
/// - fileManager: FileManager instance for file operations (default: .default).
|
||||
/// - urlSession: URLSession for downloading images (default: .shared).
|
||||
public init(
|
||||
fileManager: FileManager = .default,
|
||||
urlSession: URLSession = .shared
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.urlSession = urlSession
|
||||
|
||||
// Initialize memory cache
|
||||
self.memoryCache = MemoryCacheWrapper(
|
||||
countLimit: Constants.memoryCountLimit,
|
||||
totalCostLimit: Constants.memoryCostLimit
|
||||
)
|
||||
|
||||
// Set up disk cache directory
|
||||
let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
self.cacheDirectory = cachesDirectory.appendingPathComponent(Constants.cacheDirectoryName, isDirectory: true)
|
||||
|
||||
// Create directory if needed
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Downloads and caches an image from a URL for a specific plant.
|
||||
///
|
||||
/// The image is stored in both memory and disk caches. If the image
|
||||
/// already exists in cache, this operation is a no-op.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The URL to download the image from.
|
||||
/// - plantID: The UUID of the plant this image belongs to.
|
||||
/// - Throws: `ImageCacheError` if download, compression, or storage fails.
|
||||
public func cacheImage(from url: URL, for plantID: UUID) async throws {
|
||||
let urlHash = url.absoluteString.sha256Hash
|
||||
let cacheKey = cacheKey(for: plantID, urlHash: urlHash)
|
||||
|
||||
// Check if already cached in memory
|
||||
if memoryCache.object(forKey: cacheKey) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already cached on disk
|
||||
let filePath = diskPath(for: plantID, urlHash: urlHash)
|
||||
if fileManager.fileExists(atPath: filePath.path) {
|
||||
// Load from disk on background thread to avoid blocking
|
||||
let loadedImage = await loadImageFromDisk(at: filePath)
|
||||
if let image = loadedImage {
|
||||
let cost = imageCost(image)
|
||||
memoryCache.setObject(image, forKey: cacheKey, cost: cost)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Download image
|
||||
let data: Data
|
||||
do {
|
||||
let (downloadedData, _) = try await urlSession.data(from: url)
|
||||
data = downloadedData
|
||||
} catch {
|
||||
throw ImageCacheError.downloadFailed(error)
|
||||
}
|
||||
|
||||
// Process image on background thread to avoid blocking
|
||||
let (image, jpegData) = try await processImageData(data)
|
||||
|
||||
// Create plant directory if needed
|
||||
let plantDirectory = cacheDirectory.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
try? fileManager.createDirectory(at: plantDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Write to disk on background thread with atomic option for data integrity
|
||||
try await writeToDisk(data: jpegData, at: filePath)
|
||||
|
||||
// Store in memory cache
|
||||
let cost = imageCost(image)
|
||||
memoryCache.setObject(image, forKey: cacheKey, cost: cost)
|
||||
}
|
||||
|
||||
/// Retrieves a cached image by plant ID and URL hash.
|
||||
///
|
||||
/// Checks memory cache first, then falls back to disk cache.
|
||||
/// If found on disk, the image is loaded into memory cache.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantID: The UUID of the plant.
|
||||
/// - urlHash: The SHA256 hash of the original URL.
|
||||
/// - Returns: The cached UIImage if found, nil otherwise.
|
||||
public func getCachedImage(for plantID: UUID, urlHash: String) async -> UIImage? {
|
||||
let cacheKey = cacheKey(for: plantID, urlHash: urlHash)
|
||||
|
||||
// Check memory cache first
|
||||
if let image = memoryCache.object(forKey: cacheKey) {
|
||||
return image
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
let filePath = diskPath(for: plantID, urlHash: urlHash)
|
||||
guard let image = UIImage(contentsOfFile: filePath.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load into memory cache
|
||||
let cost = imageCost(image)
|
||||
memoryCache.setObject(image, forKey: cacheKey, cost: cost)
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
/// Retrieves a cached image by plant ID and URL.
|
||||
///
|
||||
/// Convenience method that hashes the URL automatically.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantID: The UUID of the plant.
|
||||
/// - url: The original URL of the image.
|
||||
/// - Returns: The cached UIImage if found, nil otherwise.
|
||||
public func getCachedImage(for plantID: UUID, url: URL) async -> UIImage? {
|
||||
let urlHash = url.absoluteString.sha256Hash
|
||||
return await getCachedImage(for: plantID, urlHash: urlHash)
|
||||
}
|
||||
|
||||
/// Clears all cached images for a specific plant.
|
||||
///
|
||||
/// Removes both memory and disk cached images for the plant.
|
||||
///
|
||||
/// - Parameter plantID: The UUID of the plant whose cache should be cleared.
|
||||
public func clearCache(for plantID: UUID) async {
|
||||
let plantDirectory = cacheDirectory.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
|
||||
// Remove disk cache
|
||||
try? fileManager.removeItem(at: plantDirectory)
|
||||
|
||||
// Note: We cannot selectively clear NSCache by prefix, so we clear all memory cache
|
||||
// This is acceptable as disk cache serves as the persistent layer
|
||||
memoryCache.removeAllObjects()
|
||||
}
|
||||
|
||||
/// Clears the entire image cache.
|
||||
///
|
||||
/// Removes all images from both memory and disk caches.
|
||||
public func clearAllCache() async {
|
||||
// Clear memory cache
|
||||
memoryCache.removeAllObjects()
|
||||
|
||||
// Clear disk cache
|
||||
if let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) {
|
||||
for url in contents {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the total size of the disk cache in bytes.
|
||||
///
|
||||
/// Recursively calculates the size of all cached image files.
|
||||
///
|
||||
/// - Returns: The total cache size in bytes.
|
||||
public func getCacheSize() async -> Int64 {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: cacheDirectory,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Generates a cache key from plant ID and URL hash.
|
||||
private func cacheKey(for plantID: UUID, urlHash: String) -> String {
|
||||
"\(plantID.uuidString)_\(urlHash)"
|
||||
}
|
||||
|
||||
/// Generates the disk path for a cached image.
|
||||
private func diskPath(for plantID: UUID, urlHash: String) -> URL {
|
||||
cacheDirectory
|
||||
.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
.appendingPathComponent("\(urlHash).jpg")
|
||||
}
|
||||
|
||||
/// Estimates the memory cost of an image for NSCache.
|
||||
private func imageCost(_ image: UIImage) -> Int {
|
||||
guard let cgImage = image.cgImage else { return 0 }
|
||||
return cgImage.bytesPerRow * cgImage.height
|
||||
}
|
||||
|
||||
// MARK: - Background I/O Helpers
|
||||
|
||||
/// Loads an image from disk on a background thread.
|
||||
/// This prevents blocking the actor when loading large images.
|
||||
private func loadImageFromDisk(at path: URL) async -> UIImage? {
|
||||
await Task.detached(priority: .utility) {
|
||||
UIImage(contentsOfFile: path.path)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Processes raw image data into a UIImage and compressed JPEG data.
|
||||
/// Runs on a background thread to avoid blocking during image processing.
|
||||
private func processImageData(_ data: Data) async throws -> (UIImage, Data) {
|
||||
try await Task.detached(priority: .utility) {
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw ImageCacheError.invalidImageData
|
||||
}
|
||||
|
||||
guard let jpegData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) else {
|
||||
throw ImageCacheError.compressionFailed
|
||||
}
|
||||
|
||||
return (image, jpegData)
|
||||
}.value
|
||||
}
|
||||
|
||||
/// Writes data to disk on a background thread with atomic write for data integrity.
|
||||
private func writeToDisk(data: Data, at path: URL) async throws {
|
||||
try await Task.detached(priority: .utility) {
|
||||
do {
|
||||
try data.write(to: path, options: [.atomic, .completeFileProtection])
|
||||
} catch {
|
||||
throw ImageCacheError.writeFailed
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//
|
||||
// LocalImageStorage.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - ImageStorageError
|
||||
|
||||
/// Errors that can occur during local image storage operations.
|
||||
public enum ImageStorageError: Error, LocalizedError {
|
||||
/// JPEG compression failed.
|
||||
case compressionFailed
|
||||
|
||||
/// Failed to write the image to disk.
|
||||
case writeFailed(Error)
|
||||
|
||||
/// Failed to delete the image from disk.
|
||||
case deleteFailed(Error)
|
||||
|
||||
/// The specified path does not exist.
|
||||
case fileNotFound
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .compressionFailed:
|
||||
return "Failed to compress the image."
|
||||
case .writeFailed(let error):
|
||||
return "Failed to write image: \(error.localizedDescription)"
|
||||
case .deleteFailed(let error):
|
||||
return "Failed to delete image: \(error.localizedDescription)"
|
||||
case .fileNotFound:
|
||||
return "The image file was not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageStorageProtocol
|
||||
|
||||
/// Protocol for local image storage, enabling testability through dependency injection.
|
||||
public protocol ImageStorageProtocol: Sendable {
|
||||
/// Saves an image for a specific plant.
|
||||
/// - Parameters:
|
||||
/// - image: The UIImage to save.
|
||||
/// - plantID: The UUID of the plant this image belongs to.
|
||||
/// - Returns: The relative path where the image was saved.
|
||||
func save(_ image: UIImage, for plantID: UUID) async throws -> String
|
||||
|
||||
/// Loads an image from the specified path.
|
||||
/// - Parameter path: The relative path to the image.
|
||||
/// - Returns: The loaded UIImage if found, nil otherwise.
|
||||
func load(path: String) async -> UIImage?
|
||||
|
||||
/// Deletes an image at the specified path.
|
||||
/// - Parameter path: The relative path to the image.
|
||||
func delete(path: String) async throws
|
||||
|
||||
/// Deletes all images for a specific plant.
|
||||
/// - Parameter plantID: The UUID of the plant whose images should be deleted.
|
||||
func deleteAll(for plantID: UUID) async throws
|
||||
}
|
||||
|
||||
// MARK: - LocalImageStorage
|
||||
|
||||
/// An actor-based storage for locally captured plant images.
|
||||
///
|
||||
/// This storage is designed for user-captured photos that should persist
|
||||
/// across app sessions and be included in device backups. Images are stored
|
||||
/// in the Documents directory with JPEG compression at 0.9 quality.
|
||||
///
|
||||
/// Unlike `ImageCache` which stores remote images in the Caches directory,
|
||||
/// `LocalImageStorage` uses the Documents directory to ensure images
|
||||
/// survive system cache purges and are included in iCloud/iTunes backups.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let storage = LocalImageStorage()
|
||||
///
|
||||
/// // Save a captured photo
|
||||
/// let relativePath = try await storage.save(capturedImage, for: plantID)
|
||||
///
|
||||
/// // Store the relative path in your Plant entity
|
||||
/// plant.imagePath = relativePath
|
||||
///
|
||||
/// // Load the image later
|
||||
/// if let image = await storage.load(path: plant.imagePath) {
|
||||
/// // Display the image
|
||||
/// }
|
||||
/// ```
|
||||
public actor LocalImageStorage: ImageStorageProtocol {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let storageDirectoryName = "PlantImages"
|
||||
static let jpegCompressionQuality: CGFloat = 0.9
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let storageDirectory: URL
|
||||
private let fileManager: FileManager
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new local image storage.
|
||||
/// - Parameter fileManager: FileManager instance for file operations (default: .default).
|
||||
public init(fileManager: FileManager = .default) {
|
||||
self.fileManager = fileManager
|
||||
|
||||
// Set up storage directory in Documents
|
||||
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
self.storageDirectory = documentsDirectory.appendingPathComponent(Constants.storageDirectoryName, isDirectory: true)
|
||||
|
||||
// Create directory if needed
|
||||
try? fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Saves an image for a specific plant.
|
||||
///
|
||||
/// The image is compressed as JPEG at 0.9 quality and stored in a
|
||||
/// plant-specific subdirectory. A unique filename is generated using UUID.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - image: The UIImage to save.
|
||||
/// - plantID: The UUID of the plant this image belongs to.
|
||||
/// - Returns: The relative path where the image was saved (e.g., "plantID/imageID.jpg").
|
||||
/// - Throws: `ImageStorageError` if compression or storage fails.
|
||||
public func save(_ image: UIImage, for plantID: UUID) async throws -> String {
|
||||
// Compress to JPEG
|
||||
guard let jpegData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) else {
|
||||
throw ImageStorageError.compressionFailed
|
||||
}
|
||||
|
||||
// Create plant directory if needed
|
||||
let plantDirectory = storageDirectory.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
try? fileManager.createDirectory(at: plantDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Generate unique filename
|
||||
let imageID = UUID().uuidString
|
||||
let filename = "\(imageID).jpg"
|
||||
let relativePath = "\(plantID.uuidString)/\(filename)"
|
||||
let fullPath = storageDirectory.appendingPathComponent(relativePath)
|
||||
|
||||
// Write to disk
|
||||
do {
|
||||
try jpegData.write(to: fullPath, options: .atomic)
|
||||
} catch {
|
||||
throw ImageStorageError.writeFailed(error)
|
||||
}
|
||||
|
||||
return relativePath
|
||||
}
|
||||
|
||||
/// Loads an image from the specified relative path.
|
||||
///
|
||||
/// - Parameter path: The relative path to the image (as returned by `save`).
|
||||
/// - Returns: The loaded UIImage if found, nil otherwise.
|
||||
public func load(path: String) async -> UIImage? {
|
||||
let fullPath = storageDirectory.appendingPathComponent(path)
|
||||
return UIImage(contentsOfFile: fullPath.path)
|
||||
}
|
||||
|
||||
/// Deletes an image at the specified relative path.
|
||||
///
|
||||
/// - Parameter path: The relative path to the image (as returned by `save`).
|
||||
/// - Throws: `ImageStorageError.deleteFailed` if the deletion fails,
|
||||
/// or `ImageStorageError.fileNotFound` if the file doesn't exist.
|
||||
public func delete(path: String) async throws {
|
||||
let fullPath = storageDirectory.appendingPathComponent(path)
|
||||
|
||||
guard fileManager.fileExists(atPath: fullPath.path) else {
|
||||
throw ImageStorageError.fileNotFound
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.removeItem(at: fullPath)
|
||||
} catch {
|
||||
throw ImageStorageError.deleteFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes all images for a specific plant.
|
||||
///
|
||||
/// Removes the entire plant subdirectory and all images within it.
|
||||
///
|
||||
/// - Parameter plantID: The UUID of the plant whose images should be deleted.
|
||||
/// - Throws: `ImageStorageError.deleteFailed` if the deletion fails.
|
||||
public func deleteAll(for plantID: UUID) async throws {
|
||||
let plantDirectory = storageDirectory.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
|
||||
guard fileManager.fileExists(atPath: plantDirectory.path) else {
|
||||
// No directory means no images to delete
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.removeItem(at: plantDirectory)
|
||||
} catch {
|
||||
throw ImageStorageError.deleteFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Utility Methods
|
||||
|
||||
/// Gets the absolute URL for a relative path.
|
||||
///
|
||||
/// Useful for displaying images with frameworks that require absolute URLs.
|
||||
///
|
||||
/// - Parameter path: The relative path to the image.
|
||||
/// - Returns: The absolute file URL.
|
||||
public func absoluteURL(for path: String) -> URL {
|
||||
storageDirectory.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
/// Gets the total size of stored images in bytes.
|
||||
///
|
||||
/// - Returns: The total storage size in bytes.
|
||||
public func getStorageSize() async -> Int64 {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: storageDirectory,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = resourceValues.fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
/// Gets the count of stored images for a specific plant.
|
||||
///
|
||||
/// - Parameter plantID: The UUID of the plant.
|
||||
/// - Returns: The number of images stored for the plant.
|
||||
public func imageCount(for plantID: UUID) async -> Int {
|
||||
let plantDirectory = storageDirectory.appendingPathComponent(plantID.uuidString, isDirectory: true)
|
||||
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: plantDirectory,
|
||||
includingPropertiesForKeys: nil
|
||||
) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return contents.filter { $0.pathExtension.lowercased() == "jpg" }.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// CoreDataCareScheduleStorage.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Core Data implementation of care schedule repository.
|
||||
// Provides persistent storage for plant care schedules and tasks.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - Care Schedule Storage Error
|
||||
|
||||
/// Errors that can occur during care schedule storage operations
|
||||
enum CareScheduleStorageError: Error, LocalizedError {
|
||||
/// The schedule with the specified plant ID was not found
|
||||
case scheduleNotFound(UUID)
|
||||
/// The task with the specified ID was not found
|
||||
case taskNotFound(UUID)
|
||||
/// Failed to save schedule data
|
||||
case saveFailed(Error)
|
||||
/// Failed to fetch schedule data
|
||||
case fetchFailed(Error)
|
||||
/// Failed to delete schedule data
|
||||
case deleteFailed(Error)
|
||||
/// Failed to update task data
|
||||
case updateFailed(Error)
|
||||
/// Invalid data encountered during conversion
|
||||
case invalidData(String)
|
||||
/// The Core Data entity was not found in the model
|
||||
case entityNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .scheduleNotFound(let id):
|
||||
return "Care schedule for plant ID \(id) was not found"
|
||||
case .taskNotFound(let id):
|
||||
return "Care task with ID \(id) was not found"
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save care schedule: \(error.localizedDescription)"
|
||||
case .fetchFailed(let error):
|
||||
return "Failed to fetch care schedule data: \(error.localizedDescription)"
|
||||
case .deleteFailed(let error):
|
||||
return "Failed to delete care schedule: \(error.localizedDescription)"
|
||||
case .updateFailed(let error):
|
||||
return "Failed to update care task: \(error.localizedDescription)"
|
||||
case .invalidData(let message):
|
||||
return "Invalid data: \(message)"
|
||||
case .entityNotFound(let name):
|
||||
return "Entity '\(name)' not found in Core Data model"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core Data Care Schedule Storage
|
||||
|
||||
/// Core Data-backed implementation of the care schedule repository.
|
||||
/// Handles all persistent storage operations for plant care schedules and tasks.
|
||||
final class CoreDataCareScheduleStorage: CareScheduleRepositoryProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The Core Data stack used for persistence operations
|
||||
private let coreDataStack: CoreDataStackProtocol
|
||||
|
||||
/// Entity name for care schedule managed objects
|
||||
private let scheduleEntityName = "CareScheduleMO"
|
||||
|
||||
/// Entity name for care task managed objects
|
||||
private let taskEntityName = "CareTaskMO"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new Core Data care schedule storage instance
|
||||
/// - Parameter coreDataStack: The Core Data stack to use for persistence
|
||||
init(coreDataStack: CoreDataStackProtocol = CoreDataStack.shared) {
|
||||
self.coreDataStack = coreDataStack
|
||||
}
|
||||
|
||||
// MARK: - CareScheduleRepositoryProtocol
|
||||
|
||||
/// Saves a care schedule to Core Data
|
||||
/// - Parameter schedule: The care schedule entity to save
|
||||
/// - Throws: CareScheduleStorageError if the save operation fails
|
||||
func save(_ schedule: PlantCareSchedule) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [scheduleEntityName] context in
|
||||
// Check if schedule already exists for this plant
|
||||
let fetchRequest = CareScheduleMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "plantID == %@", schedule.plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let existingSchedules = try context.fetch(fetchRequest)
|
||||
|
||||
if let existingSchedule = existingSchedules.first {
|
||||
// Update existing schedule
|
||||
existingSchedule.update(from: schedule, context: context)
|
||||
} else {
|
||||
// Create new schedule
|
||||
_ = CareScheduleMO.fromDomainModel(schedule, context: context)
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the care schedule for a specific plant
|
||||
/// - Parameter plantID: The unique identifier of the plant whose schedule to fetch
|
||||
/// - Returns: The care schedule if found, or nil if no schedule exists for the given plant
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetch(for plantID: UUID) async throws -> PlantCareSchedule? {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareScheduleMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let scheduleMO = results.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return scheduleMO.toDomainModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches all care schedules from the repository
|
||||
/// - Returns: An array of all stored care schedules
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetchAll() async throws -> [PlantCareSchedule] {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareScheduleMO.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "plantID", ascending: true)]
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.map { $0.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches all care tasks across all schedules
|
||||
/// - Returns: An array of all care tasks
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetchAllTasks() async throws -> [CareTask] {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scheduledDate", ascending: true)]
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.map { $0.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a specific care task in the repository
|
||||
/// - Parameter task: The updated care task
|
||||
/// - Throws: CareScheduleStorageError if the update operation fails
|
||||
func updateTask(_ task: CareTask) async throws {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", task.id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let taskMO = results.first else {
|
||||
throw CareScheduleStorageError.taskNotFound(task.id)
|
||||
}
|
||||
|
||||
taskMO.update(from: task)
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the care schedule for a specific plant
|
||||
/// - Parameter plantID: The unique identifier of the plant whose schedule to delete
|
||||
/// - Throws: CareScheduleStorageError if the delete operation fails
|
||||
func delete(for plantID: UUID) async throws {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareScheduleMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let scheduleToDelete = results.first else {
|
||||
throw CareScheduleStorageError.scheduleNotFound(plantID)
|
||||
}
|
||||
|
||||
// Delete associated tasks first (cascade should handle this, but being explicit)
|
||||
if let tasks = scheduleToDelete.tasks as? Set<CareTaskMO> {
|
||||
for task in tasks {
|
||||
context.delete(task)
|
||||
}
|
||||
}
|
||||
|
||||
context.delete(scheduleToDelete)
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Methods
|
||||
|
||||
/// Fetches upcoming care tasks within the specified number of days
|
||||
/// - Parameter days: The number of days to look ahead for upcoming tasks
|
||||
/// - Returns: An array of care tasks scheduled within the specified days
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetchUpcomingTasks(days: Int) async throws -> [CareTask] {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
guard let futureDate = calendar.date(byAdding: .day, value: days, to: now) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate >= %@ AND scheduledDate <= %@",
|
||||
now as NSDate,
|
||||
futureDate as NSDate
|
||||
)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scheduledDate", ascending: true)]
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.map { $0.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a care task as completed with the current date
|
||||
/// - Parameter taskID: The unique identifier of the task to mark as completed
|
||||
/// - Throws: CareScheduleStorageError if the task is not found or update fails
|
||||
func markTaskCompleted(taskID: UUID) async throws {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let taskMO = results.first else {
|
||||
throw CareScheduleStorageError.taskNotFound(taskID)
|
||||
}
|
||||
|
||||
taskMO.completedDate = Date()
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches overdue care tasks (past scheduled date and not completed)
|
||||
/// - Returns: An array of overdue care tasks
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetchOverdueTasks() async throws -> [CareTask] {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let now = Date()
|
||||
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate < %@",
|
||||
now as NSDate
|
||||
)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scheduledDate", ascending: true)]
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.map { $0.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches all tasks for a specific plant
|
||||
/// - Parameter plantID: The unique identifier of the plant
|
||||
/// - Returns: An array of care tasks for the plant
|
||||
/// - Throws: CareScheduleStorageError if the fetch operation fails
|
||||
func fetchTasks(for plantID: UUID) async throws -> [CareTask] {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareTaskMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scheduledDate", ascending: true)]
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.map { $0.toDomainModel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a care schedule exists for a given plant
|
||||
/// - Parameter plantID: The unique identifier of the plant
|
||||
/// - Returns: True if a schedule exists, false otherwise
|
||||
/// - Throws: CareScheduleStorageError if the check operation fails
|
||||
func exists(for plantID: UUID) async throws -> Bool {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let fetchRequest = CareScheduleMO.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "plantID == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let count = try context.count(for: fetchRequest)
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets task statistics (upcoming and overdue counts)
|
||||
/// - Returns: A tuple containing (upcomingCount, overdueCount)
|
||||
/// - Throws: CareScheduleStorageError if the calculation fails
|
||||
func getTaskStatistics() async throws -> (upcoming: Int, overdue: Int) {
|
||||
try await coreDataStack.performBackgroundTask { context in
|
||||
let now = Date()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Calculate overdue tasks
|
||||
let overdueRequest = CareTaskMO.fetchRequest()
|
||||
overdueRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate < %@",
|
||||
now as NSDate
|
||||
)
|
||||
let overdueCount = try context.count(for: overdueRequest)
|
||||
|
||||
// Calculate upcoming tasks (next 7 days)
|
||||
guard let weekFromNow = calendar.date(byAdding: .day, value: 7, to: now) else {
|
||||
return (0, overdueCount)
|
||||
}
|
||||
|
||||
let upcomingRequest = CareTaskMO.fetchRequest()
|
||||
upcomingRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate >= %@ AND scheduledDate <= %@",
|
||||
now as NSDate,
|
||||
weekFromNow as NSDate
|
||||
)
|
||||
let upcomingCount = try context.count(for: upcomingRequest)
|
||||
|
||||
return (upcomingCount, overdueCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
#if DEBUG
|
||||
extension CoreDataCareScheduleStorage {
|
||||
/// Creates a storage instance with an in-memory Core Data stack for testing
|
||||
/// - Returns: A CoreDataCareScheduleStorage instance backed by an in-memory store
|
||||
static func inMemoryStorage() -> CoreDataCareScheduleStorage {
|
||||
return CoreDataCareScheduleStorage(coreDataStack: CoreDataStack.inMemoryStack())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,309 @@
|
||||
# PlantGuide Core Data Model Setup Guide
|
||||
|
||||
This document describes the Core Data model configuration for the PlantGuide plant identification app.
|
||||
|
||||
## Model File Setup
|
||||
|
||||
1. In Xcode, create a new Core Data Model file:
|
||||
- File > New > File > Data Model
|
||||
- Name it: `PlantGuideModel.xcdatamodeld`
|
||||
- Place it in: `PlantGuide/Data/DataSources/Local/CoreData/`
|
||||
|
||||
2. Ensure the model file is added to the target's "Compile Sources" build phase.
|
||||
|
||||
---
|
||||
|
||||
## Entity Definitions
|
||||
|
||||
### 1. PlantMO (Plant Managed Object)
|
||||
|
||||
Stores identified plant data.
|
||||
|
||||
| Attribute | Type | Optional | Default | Notes |
|
||||
|-----------|------|----------|---------|-------|
|
||||
| `id` | UUID | No | - | Primary identifier |
|
||||
| `scientificName` | String | No | - | Botanical name (e.g., "Monstera deliciosa") |
|
||||
| `commonNames` | Transformable | Yes | - | Array of common names (see Transformable setup) |
|
||||
| `family` | String | Yes | - | Plant family (e.g., "Araceae") |
|
||||
| `genus` | String | Yes | - | Plant genus (e.g., "Monstera") |
|
||||
| `imageURLs` | Transformable | Yes | - | Array of image URL strings |
|
||||
| `dateIdentified` | Date | No | - | When the plant was identified |
|
||||
| `identificationSource` | Integer 16 | No | 0 | Source enum: 0=unknown, 1=camera, 2=gallery, 3=manual |
|
||||
|
||||
**Relationships:**
|
||||
- `careSchedule` -> CareScheduleMO (Optional, To One, Inverse: plant)
|
||||
- `identifications` -> IdentificationMO (Optional, To Many, Inverse: plant)
|
||||
|
||||
**Indexes:**
|
||||
- Index on `scientificName` for search
|
||||
- Index on `dateIdentified` for sorting
|
||||
|
||||
---
|
||||
|
||||
### 2. CareScheduleMO (Care Schedule Managed Object)
|
||||
|
||||
Stores plant care requirements and schedules.
|
||||
|
||||
| Attribute | Type | Optional | Default | Notes |
|
||||
|-----------|------|----------|---------|-------|
|
||||
| `id` | UUID | No | - | Primary identifier |
|
||||
| `plantID` | UUID | No | - | Reference to parent plant |
|
||||
| `lightRequirement` | Integer 16 | No | 0 | 0=unknown, 1=low, 2=medium, 3=bright indirect, 4=direct |
|
||||
| `wateringSchedule` | String | Yes | - | Cron-like schedule or description |
|
||||
| `temperatureMin` | Integer 16 | Yes | - | Minimum temperature (Celsius) |
|
||||
| `temperatureMax` | Integer 16 | Yes | - | Maximum temperature (Celsius) |
|
||||
| `fertilizerSchedule` | String | Yes | - | Fertilizer schedule description |
|
||||
| `humidity` | Integer 16 | Yes | - | Preferred humidity percentage |
|
||||
| `soilType` | String | Yes | - | Recommended soil type |
|
||||
| `lastUpdated` | Date | Yes | - | When schedule was last modified |
|
||||
|
||||
**Relationships:**
|
||||
- `plant` -> PlantMO (Optional, To One, Inverse: careSchedule)
|
||||
- `tasks` -> CareTaskMO (Optional, To Many, Inverse: careSchedule, Delete Rule: Cascade)
|
||||
|
||||
---
|
||||
|
||||
### 3. IdentificationMO (Identification Managed Object)
|
||||
|
||||
Stores plant identification results from ML models or APIs.
|
||||
|
||||
| Attribute | Type | Optional | Default | Notes |
|
||||
|-----------|------|----------|---------|-------|
|
||||
| `id` | UUID | No | - | Primary identifier |
|
||||
| `species` | String | No | - | Identified species name |
|
||||
| `confidence` | Double | No | 0.0 | Confidence score (0.0 - 1.0) |
|
||||
| `source` | Integer 16 | No | 0 | 0=unknown, 1=onDevice, 2=plantNet, 3=custom |
|
||||
| `timestamp` | Date | No | - | When identification was made |
|
||||
| `imageData` | Binary Data | Yes | - | Optional snapshot of identified image |
|
||||
| `rawResponse` | String | Yes | - | Raw JSON response for debugging |
|
||||
|
||||
**Relationships:**
|
||||
- `plant` -> PlantMO (Optional, To One, Inverse: identifications)
|
||||
|
||||
**Indexes:**
|
||||
- Index on `confidence` for filtering high-confidence results
|
||||
- Index on `timestamp` for sorting
|
||||
|
||||
---
|
||||
|
||||
### 4. CareTaskMO (Care Task Managed Object)
|
||||
|
||||
Stores individual care tasks for plants.
|
||||
|
||||
| Attribute | Type | Optional | Default | Notes |
|
||||
|-----------|------|----------|---------|-------|
|
||||
| `id` | UUID | No | - | Primary identifier |
|
||||
| `type` | Integer 16 | No | 0 | 0=water, 1=fertilize, 2=repot, 3=prune, 4=rotate, 5=mist |
|
||||
| `scheduledDate` | Date | No | - | When task is scheduled |
|
||||
| `completedDate` | Date | Yes | - | When task was completed (nil if pending) |
|
||||
| `notes` | String | Yes | - | User notes about the task |
|
||||
| `isRecurring` | Boolean | No | false | Whether task repeats |
|
||||
| `recurrenceInterval` | Integer 32 | Yes | - | Days between recurrences |
|
||||
|
||||
**Relationships:**
|
||||
- `careSchedule` -> CareScheduleMO (Optional, To One, Inverse: tasks)
|
||||
|
||||
**Indexes:**
|
||||
- Index on `scheduledDate` for upcoming tasks
|
||||
- Compound index on `type` + `scheduledDate` for filtered queries
|
||||
|
||||
---
|
||||
|
||||
## Transformable Attribute Configuration
|
||||
|
||||
For `commonNames` and `imageURLs` attributes that store arrays:
|
||||
|
||||
### Option 1: Secure Coding (Recommended)
|
||||
|
||||
1. Create a value transformer:
|
||||
|
||||
```swift
|
||||
@objc(StringArrayTransformer)
|
||||
final class StringArrayTransformer: NSSecureUnarchiveFromDataTransformer {
|
||||
static let name = NSValueTransformerName(rawValue: "StringArrayTransformer")
|
||||
|
||||
override static var allowedTopLevelClasses: [AnyClass] {
|
||||
return [NSArray.self, NSString.self]
|
||||
}
|
||||
|
||||
static func register() {
|
||||
let transformer = StringArrayTransformer()
|
||||
ValueTransformer.setValueTransformer(transformer, forName: name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Register in app startup (before Core Data loads):
|
||||
```swift
|
||||
StringArrayTransformer.register()
|
||||
```
|
||||
|
||||
3. In the model editor, set:
|
||||
- Attribute Type: Transformable
|
||||
- Transformer: StringArrayTransformer
|
||||
- Custom Class: [String]
|
||||
|
||||
### Option 2: Codable JSON Storage
|
||||
|
||||
Store as Data attribute and encode/decode manually in extensions.
|
||||
|
||||
---
|
||||
|
||||
## Enum Definitions
|
||||
|
||||
Reference these enums in your Swift code:
|
||||
|
||||
```swift
|
||||
// IdentificationSource
|
||||
enum IdentificationSource: Int16 {
|
||||
case unknown = 0
|
||||
case camera = 1
|
||||
case gallery = 2
|
||||
case manual = 3
|
||||
}
|
||||
|
||||
// LightRequirement
|
||||
enum LightRequirement: Int16 {
|
||||
case unknown = 0
|
||||
case low = 1
|
||||
case medium = 2
|
||||
case brightIndirect = 3
|
||||
case direct = 4
|
||||
}
|
||||
|
||||
// IdentificationProvider
|
||||
enum IdentificationProvider: Int16 {
|
||||
case unknown = 0
|
||||
case onDevice = 1
|
||||
case plantNet = 2
|
||||
case custom = 3
|
||||
}
|
||||
|
||||
// CareTaskType
|
||||
enum CareTaskType: Int16 {
|
||||
case water = 0
|
||||
case fertilize = 1
|
||||
case repot = 2
|
||||
case prune = 3
|
||||
case rotate = 4
|
||||
case mist = 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fetch Request Templates
|
||||
|
||||
Add these fetch request templates in the model editor for common queries:
|
||||
|
||||
### FetchPlantsByName
|
||||
- Entity: PlantMO
|
||||
- Predicate: `scientificName CONTAINS[cd] $NAME OR ANY commonNames CONTAINS[cd] $NAME`
|
||||
|
||||
### FetchRecentIdentifications
|
||||
- Entity: IdentificationMO
|
||||
- Predicate: `timestamp >= $START_DATE`
|
||||
- Sort: `timestamp` descending
|
||||
|
||||
### FetchPendingTasks
|
||||
- Entity: CareTaskMO
|
||||
- Predicate: `completedDate == nil AND scheduledDate <= $END_DATE`
|
||||
- Sort: `scheduledDate` ascending
|
||||
|
||||
### FetchHighConfidenceIdentifications
|
||||
- Entity: IdentificationMO
|
||||
- Predicate: `confidence >= $MIN_CONFIDENCE`
|
||||
- Sort: `confidence` descending
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Version History
|
||||
|
||||
| Version | Changes |
|
||||
|---------|---------|
|
||||
| 1.0.0 | Initial model with PlantMO, CareScheduleMO, IdentificationMO, CareTaskMO |
|
||||
|
||||
### Adding New Versions
|
||||
|
||||
1. Select the .xcdatamodeld file
|
||||
2. Editor > Add Model Version
|
||||
3. Name: `PlantGuideModel_v1_1_0` (following semantic versioning)
|
||||
4. Make changes in the new version
|
||||
5. Set as current model version in File Inspector
|
||||
|
||||
### Lightweight Migration Compatibility
|
||||
|
||||
These changes support automatic lightweight migration:
|
||||
- Adding optional attributes
|
||||
- Adding entities
|
||||
- Removing entities
|
||||
- Removing attributes
|
||||
- Renaming entities/attributes (with renaming identifier)
|
||||
|
||||
### Custom Migration Required For:
|
||||
- Changing attribute types
|
||||
- Moving attributes between entities
|
||||
- Complex data transformations
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Batch Size**: Set appropriate fetch batch sizes (20-50 for lists)
|
||||
2. **Faulting**: Use `returnsObjectsAsFaults = false` only when needed
|
||||
3. **Prefetching**: Use `relationshipKeyPathsForPrefetching` for related objects
|
||||
4. **Indexes**: Add indexes for frequently queried attributes
|
||||
5. **Binary Data**: Store large images externally, keep only references
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Configure these in the model editor:
|
||||
|
||||
### PlantMO
|
||||
- `scientificName`: Min length 2, Max length 200
|
||||
- `identificationSource`: Min 0, Max 3
|
||||
|
||||
### IdentificationMO
|
||||
- `confidence`: Min 0.0, Max 1.0
|
||||
- `species`: Min length 1
|
||||
|
||||
### CareTaskMO
|
||||
- `type`: Min 0, Max 5
|
||||
|
||||
---
|
||||
|
||||
## Testing the Model
|
||||
|
||||
After creating the model file, verify with:
|
||||
|
||||
```swift
|
||||
func testCoreDataSetup() async throws {
|
||||
let stack = CoreDataStack.inMemoryStack()
|
||||
|
||||
// Test save/fetch cycle
|
||||
let plantID = try await stack.performBackgroundTask { context in
|
||||
let plant = NSEntityDescription.insertNewObject(
|
||||
forEntityName: "PlantMO",
|
||||
into: context
|
||||
)
|
||||
plant.setValue(UUID(), forKey: "id")
|
||||
plant.setValue("Monstera deliciosa", forKey: "scientificName")
|
||||
plant.setValue(Date(), forKey: "dateIdentified")
|
||||
plant.setValue(Int16(1), forKey: "identificationSource")
|
||||
|
||||
return plant.value(forKey: "id") as! UUID
|
||||
}
|
||||
|
||||
// Verify fetch
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "PlantMO")
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
|
||||
let results = try stack.viewContext().fetch(fetchRequest)
|
||||
assert(results.count == 1)
|
||||
assert(results.first?.value(forKey: "scientificName") as? String == "Monstera deliciosa")
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// CoreDataPlantCareInfoStorage.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Core Data implementation of PlantCareInfo repository.
|
||||
// Provides caching for Trefle API care information.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - Plant Care Info Storage Error
|
||||
|
||||
/// Errors that can occur during plant care info storage operations
|
||||
enum PlantCareInfoStorageError: Error, LocalizedError {
|
||||
/// Failed to save care info
|
||||
case saveFailed(Error)
|
||||
/// Failed to fetch care info
|
||||
case fetchFailed(Error)
|
||||
/// Failed to delete care info
|
||||
case deleteFailed(Error)
|
||||
/// Failed to encode care info for storage
|
||||
case encodingFailed
|
||||
/// The Core Data entity was not found in the model
|
||||
case entityNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save care info: \(error.localizedDescription)"
|
||||
case .fetchFailed(let error):
|
||||
return "Failed to fetch care info: \(error.localizedDescription)"
|
||||
case .deleteFailed(let error):
|
||||
return "Failed to delete care info: \(error.localizedDescription)"
|
||||
case .encodingFailed:
|
||||
return "Failed to encode care info for storage"
|
||||
case .entityNotFound(let name):
|
||||
return "Entity '\(name)' not found in Core Data model"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core Data Plant Care Info Storage
|
||||
|
||||
/// Core Data-backed implementation of the plant care info repository.
|
||||
/// Handles caching of Trefle API care information.
|
||||
final class CoreDataPlantCareInfoStorage: PlantCareInfoRepositoryProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The Core Data stack used for persistence operations
|
||||
private let coreDataStack: CoreDataStackProtocol
|
||||
|
||||
/// Entity name for plant care info managed objects
|
||||
private let careInfoEntityName = "PlantCareInfoMO"
|
||||
|
||||
/// Entity name for plant managed objects
|
||||
private let plantEntityName = "PlantMO"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new Core Data plant care info storage instance
|
||||
/// - Parameter coreDataStack: The Core Data stack to use for persistence
|
||||
init(coreDataStack: CoreDataStackProtocol = CoreDataStack.shared) {
|
||||
self.coreDataStack = coreDataStack
|
||||
}
|
||||
|
||||
// MARK: - PlantCareInfoRepositoryProtocol
|
||||
|
||||
func fetch(scientificName: String) async throws -> PlantCareInfo? {
|
||||
try await coreDataStack.performBackgroundTask { [careInfoEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<PlantCareInfoMO>(entityName: careInfoEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "scientificName ==[c] %@", scientificName)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.first?.toDomainModel()
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.fetchFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(trefleID: Int) async throws -> PlantCareInfo? {
|
||||
try await coreDataStack.performBackgroundTask { [careInfoEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<PlantCareInfoMO>(entityName: careInfoEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "trefleID == %d", Int32(trefleID))
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.first?.toDomainModel()
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.fetchFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(for plantID: UUID) async throws -> PlantCareInfo? {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<PlantMO>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = ["plantCareInfo"]
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return results.first?.plantCareInfo?.toDomainModel()
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.fetchFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ careInfo: PlantCareInfo, for plantID: UUID?) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [careInfoEntityName, plantEntityName] context in
|
||||
// Check if care info already exists by scientific name
|
||||
let fetchRequest = NSFetchRequest<PlantCareInfoMO>(entityName: careInfoEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "scientificName ==[c] %@", careInfo.scientificName)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
do {
|
||||
let existingResults = try context.fetch(fetchRequest)
|
||||
|
||||
let careInfoMO: PlantCareInfoMO
|
||||
if let existing = existingResults.first {
|
||||
// Update existing
|
||||
guard existing.update(from: careInfo) else {
|
||||
throw PlantCareInfoStorageError.encodingFailed
|
||||
}
|
||||
careInfoMO = existing
|
||||
} else {
|
||||
// Create new
|
||||
guard let newMO = PlantCareInfoMO.fromDomainModel(careInfo, context: context) else {
|
||||
throw PlantCareInfoStorageError.encodingFailed
|
||||
}
|
||||
careInfoMO = newMO
|
||||
}
|
||||
|
||||
// Link to plant if plantID provided
|
||||
if let plantID = plantID {
|
||||
let plantFetch = NSFetchRequest<PlantMO>(entityName: plantEntityName)
|
||||
plantFetch.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
plantFetch.fetchLimit = 1
|
||||
|
||||
if let plant = try context.fetch(plantFetch).first {
|
||||
careInfoMO.plant = plant
|
||||
}
|
||||
}
|
||||
|
||||
try context.save()
|
||||
} catch let error as PlantCareInfoStorageError {
|
||||
throw error
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.saveFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isCacheStale(scientificName: String, cacheExpiration: TimeInterval) async throws -> Bool {
|
||||
try await coreDataStack.performBackgroundTask { [careInfoEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<PlantCareInfoMO>(entityName: careInfoEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "scientificName ==[c] %@", scientificName)
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.propertiesToFetch = ["fetchedAt"]
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let careInfo = results.first else {
|
||||
// No cache exists, consider it stale
|
||||
return true
|
||||
}
|
||||
|
||||
let cacheAge = Date().timeIntervalSince(careInfo.fetchedAt)
|
||||
return cacheAge > cacheExpiration
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.fetchFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func delete(for plantID: UUID) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<PlantMO>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = ["plantCareInfo"]
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
if let plant = results.first, let careInfo = plant.plantCareInfo {
|
||||
context.delete(careInfo)
|
||||
try context.save()
|
||||
}
|
||||
} catch {
|
||||
throw PlantCareInfoStorageError.deleteFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//
|
||||
// CoreDataPlantStorage.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Core Data implementation of plant collection repository.
|
||||
// Provides persistent storage for the user's plant collection.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - Plant Storage Error
|
||||
|
||||
/// Errors that can occur during plant storage operations
|
||||
enum PlantStorageError: Error, LocalizedError {
|
||||
/// The plant with the specified ID was not found
|
||||
case plantNotFound(UUID)
|
||||
/// Failed to save plant data
|
||||
case saveFailed(Error)
|
||||
/// Failed to fetch plant data
|
||||
case fetchFailed(Error)
|
||||
/// Failed to delete plant data
|
||||
case deleteFailed(Error)
|
||||
/// Failed to update plant data
|
||||
case updateFailed(Error)
|
||||
/// Invalid data encountered during conversion
|
||||
case invalidData(String)
|
||||
/// The Core Data entity was not found in the model
|
||||
case entityNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .plantNotFound(let id):
|
||||
return "Plant with ID \(id) was not found"
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save plant: \(error.localizedDescription)"
|
||||
case .fetchFailed(let error):
|
||||
return "Failed to fetch plant data: \(error.localizedDescription)"
|
||||
case .deleteFailed(let error):
|
||||
return "Failed to delete plant: \(error.localizedDescription)"
|
||||
case .updateFailed(let error):
|
||||
return "Failed to update plant: \(error.localizedDescription)"
|
||||
case .invalidData(let message):
|
||||
return "Invalid data: \(message)"
|
||||
case .entityNotFound(let name):
|
||||
return "Entity '\(name)' not found in Core Data model"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core Data Plant Storage
|
||||
|
||||
/// Core Data-backed implementation of the plant collection repository.
|
||||
/// Handles all persistent storage operations for the user's plant collection.
|
||||
final class CoreDataPlantStorage: PlantCollectionRepositoryProtocol, FavoritePlantRepositoryProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The Core Data stack used for persistence operations
|
||||
private let coreDataStack: CoreDataStackProtocol
|
||||
|
||||
/// Entity name for plant managed objects
|
||||
private let plantEntityName = "PlantMO"
|
||||
|
||||
/// Entity name for care task managed objects
|
||||
private let careTaskEntityName = "CareTaskMO"
|
||||
|
||||
// MARK: - Fetch Configuration
|
||||
|
||||
/// Default batch size for fetch requests.
|
||||
/// Optimized for typical collection sizes - fetches objects in batches
|
||||
/// to reduce memory usage while maintaining good scrolling performance.
|
||||
private let defaultFetchBatchSize = 20
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new Core Data plant storage instance
|
||||
/// - Parameter coreDataStack: The Core Data stack to use for persistence
|
||||
init(coreDataStack: CoreDataStackProtocol = CoreDataStack.shared) {
|
||||
self.coreDataStack = coreDataStack
|
||||
}
|
||||
|
||||
// MARK: - PlantRepositoryProtocol
|
||||
|
||||
/// Saves a plant to Core Data
|
||||
/// - Parameter plant: The plant entity to save
|
||||
/// - Throws: PlantStorageError if the save operation fails
|
||||
func save(_ plant: Plant) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
// Check if plant already exists
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plant.id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let existingPlants = try context.fetch(fetchRequest)
|
||||
|
||||
if let existingPlant = existingPlants.first {
|
||||
// Update existing plant
|
||||
existingPlant.updateFromPlant(plant)
|
||||
Self.updateMutableFields(on: existingPlant, from: plant)
|
||||
} else {
|
||||
// Create new plant
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: plantEntityName, in: context) else {
|
||||
throw PlantStorageError.entityNotFound(plantEntityName)
|
||||
}
|
||||
|
||||
let managedObject = NSManagedObject(entity: entity, insertInto: context)
|
||||
managedObject.updateFromPlant(plant)
|
||||
Self.updateMutableFields(on: managedObject, from: plant)
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a plant by its unique identifier
|
||||
/// - Parameter id: The unique identifier of the plant
|
||||
/// - Returns: The plant if found, or nil if no plant exists with the given ID
|
||||
/// - Throws: PlantStorageError if the fetch operation fails
|
||||
func fetch(id: UUID) async throws -> Plant? {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let managedObject = results.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try Self.convertToPlant(from: managedObject)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches all plants from the repository
|
||||
/// - Returns: An array of all stored plants
|
||||
/// - Throws: PlantStorageError if the fetch operation fails
|
||||
func fetchAll() async throws -> [Plant] {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName, defaultFetchBatchSize] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "dateAdded", ascending: false)]
|
||||
// Batch fetching reduces memory pressure for large collections
|
||||
fetchRequest.fetchBatchSize = defaultFetchBatchSize
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return try results.map { try Self.convertToPlant(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a plant by its unique identifier
|
||||
/// - Parameter id: The unique identifier of the plant to delete
|
||||
/// - Throws: PlantStorageError if the delete operation fails
|
||||
func delete(id: UUID) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let plantToDelete = results.first else {
|
||||
throw PlantStorageError.plantNotFound(id)
|
||||
}
|
||||
|
||||
context.delete(plantToDelete)
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlantCollectionRepositoryProtocol
|
||||
|
||||
/// Searches plants by a text query
|
||||
/// Searches across scientific name, common names, family, and notes
|
||||
/// - Parameter query: The search text to match against plant data
|
||||
/// - Returns: An array of plants matching the search query
|
||||
/// - Throws: PlantStorageError if the search operation fails
|
||||
func searchPlants(query: String) async throws -> [Plant] {
|
||||
guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return try await fetchAll()
|
||||
}
|
||||
|
||||
return try await coreDataStack.performBackgroundTask { [plantEntityName, defaultFetchBatchSize] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
|
||||
// Search in scientificName, commonNames (stored as transformable array),
|
||||
// family, and notes
|
||||
let searchPredicate = NSPredicate(
|
||||
format: """
|
||||
scientificName CONTAINS[cd] %@ OR
|
||||
family CONTAINS[cd] %@ OR
|
||||
notes CONTAINS[cd] %@ OR
|
||||
ANY commonNames CONTAINS[cd] %@
|
||||
""",
|
||||
query, query, query, query
|
||||
)
|
||||
|
||||
fetchRequest.predicate = searchPredicate
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scientificName", ascending: true)]
|
||||
fetchRequest.fetchBatchSize = defaultFetchBatchSize
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return try results.map { try Self.convertToPlant(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters plants based on the provided filter configuration
|
||||
/// - Parameter filter: The filter configuration to apply
|
||||
/// - Returns: An array of plants matching all specified filter criteria
|
||||
/// - Throws: PlantStorageError if the filter operation fails
|
||||
func filterPlants(by filter: PlantFilter) async throws -> [Plant] {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName, defaultFetchBatchSize] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
|
||||
var predicates: [NSPredicate] = []
|
||||
|
||||
// Search query predicate
|
||||
if let searchQuery = filter.searchQuery,
|
||||
!searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let searchPredicate = NSPredicate(
|
||||
format: """
|
||||
scientificName CONTAINS[cd] %@ OR
|
||||
family CONTAINS[cd] %@ OR
|
||||
notes CONTAINS[cd] %@ OR
|
||||
customName CONTAINS[cd] %@
|
||||
""",
|
||||
searchQuery, searchQuery, searchQuery, searchQuery
|
||||
)
|
||||
predicates.append(searchPredicate)
|
||||
}
|
||||
|
||||
// Family filter
|
||||
if let families = filter.families, !families.isEmpty {
|
||||
let familyPredicate = NSPredicate(format: "family IN %@", families)
|
||||
predicates.append(familyPredicate)
|
||||
}
|
||||
|
||||
// Favorite filter
|
||||
if let isFavorite = filter.isFavorite {
|
||||
let favoritePredicate = NSPredicate(format: "isFavorite == %@", NSNumber(value: isFavorite))
|
||||
predicates.append(favoritePredicate)
|
||||
}
|
||||
|
||||
// Identification source filter
|
||||
if let source = filter.identificationSource {
|
||||
let sourcePredicate = NSPredicate(format: "identificationSource == %@", source.rawValue)
|
||||
predicates.append(sourcePredicate)
|
||||
}
|
||||
|
||||
// Combine all predicates with AND
|
||||
if !predicates.isEmpty {
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
fetchRequest.sortDescriptors = Self.sortDescriptors(for: filter)
|
||||
// Batch fetching for large result sets
|
||||
fetchRequest.fetchBatchSize = defaultFetchBatchSize
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return try results.map { try Self.convertToPlant(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves all plants marked as favorites
|
||||
/// - Returns: An array of favorite plants, sorted by date added (newest first)
|
||||
/// - Throws: PlantStorageError if the fetch operation fails
|
||||
func getFavorites() async throws -> [Plant] {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName, defaultFetchBatchSize] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "isFavorite == %@", NSNumber(value: true))
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "dateAdded", ascending: false)]
|
||||
fetchRequest.fetchBatchSize = defaultFetchBatchSize
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
return try results.map { try Self.convertToPlant(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the favorite status of a plant
|
||||
/// - Parameters:
|
||||
/// - plantID: The unique identifier of the plant to update
|
||||
/// - isFavorite: The new favorite status
|
||||
/// - Throws: PlantStorageError if the update operation fails or if the plant is not found
|
||||
func setFavorite(plantID: UUID, isFavorite: Bool) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let plant = results.first else {
|
||||
throw PlantStorageError.plantNotFound(plantID)
|
||||
}
|
||||
|
||||
plant.setValue(isFavorite, forKey: "isFavorite")
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates an existing plant in the repository
|
||||
/// - Parameter plant: The plant with updated values to save
|
||||
/// - Throws: PlantStorageError if the update operation fails or if the plant is not found
|
||||
func updatePlant(_ plant: Plant) async throws {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plant.id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let existingPlant = results.first else {
|
||||
throw PlantStorageError.plantNotFound(plant.id)
|
||||
}
|
||||
|
||||
existingPlant.updateFromPlant(plant)
|
||||
Self.updateMutableFields(on: existingPlant, from: plant)
|
||||
return ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves aggregate statistics about the plant collection
|
||||
/// - Returns: A CollectionStatistics object containing aggregate data
|
||||
/// - Throws: PlantStorageError if the statistics calculation fails
|
||||
func getCollectionStatistics() async throws -> CollectionStatistics {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName, careTaskEntityName] context in
|
||||
// Fetch all plants for statistics
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
let allPlants = try context.fetch(fetchRequest)
|
||||
|
||||
// Calculate total plants
|
||||
let totalPlants = allPlants.count
|
||||
|
||||
// Calculate favorite count
|
||||
let favoriteCount = allPlants.filter { ($0.value(forKey: "isFavorite") as? Bool) == true }.count
|
||||
|
||||
// Calculate family distribution
|
||||
var familyDistribution: [String: Int] = [:]
|
||||
for plant in allPlants {
|
||||
if let family = plant.value(forKey: "family") as? String, !family.isEmpty {
|
||||
familyDistribution[family, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate identification source breakdown
|
||||
var sourceBreakdown: [IdentificationSource: Int] = [:]
|
||||
for plant in allPlants {
|
||||
if let sourceRaw = plant.value(forKey: "identificationSource") as? String,
|
||||
let source = IdentificationSource(rawValue: sourceRaw) {
|
||||
sourceBreakdown[source, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate plants added this month
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now
|
||||
let plantsAddedThisMonth = allPlants.filter { plant in
|
||||
guard let dateAdded = plant.value(forKey: "dateAdded") as? Date else {
|
||||
return false
|
||||
}
|
||||
return dateAdded >= startOfMonth
|
||||
}.count
|
||||
|
||||
// Calculate upcoming and overdue tasks
|
||||
let (upcomingCount, overdueCount) = try Self.calculateTaskCounts(
|
||||
in: context,
|
||||
entityName: careTaskEntityName,
|
||||
now: now
|
||||
)
|
||||
|
||||
return CollectionStatistics(
|
||||
totalPlants: totalPlants,
|
||||
favoriteCount: favoriteCount,
|
||||
familyDistribution: familyDistribution,
|
||||
identificationSourceBreakdown: sourceBreakdown,
|
||||
plantsAddedThisMonth: plantsAddedThisMonth,
|
||||
upcomingTasksCount: upcomingCount,
|
||||
overdueTasksCount: overdueCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Methods (for use cases)
|
||||
|
||||
/// Checks if a plant exists with the given ID
|
||||
/// - Parameter id: The unique identifier of the plant
|
||||
/// - Returns: True if the plant exists, false otherwise
|
||||
/// - Throws: PlantStorageError if the check operation fails
|
||||
func exists(id: UUID) async throws -> Bool {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let count = try context.count(for: fetchRequest)
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches plants matching a filter
|
||||
/// - Parameter filter: The filter configuration to apply
|
||||
/// - Returns: An array of plants matching the filter criteria
|
||||
/// - Throws: PlantStorageError if the fetch operation fails
|
||||
func fetch(filter: PlantFilter) async throws -> [Plant] {
|
||||
try await filterPlants(by: filter)
|
||||
}
|
||||
|
||||
/// Fetches collection statistics (alias for getCollectionStatistics)
|
||||
/// - Returns: A CollectionStatistics object containing aggregate data
|
||||
/// - Throws: PlantStorageError if the statistics calculation fails
|
||||
func fetchStatistics() async throws -> CollectionStatistics {
|
||||
try await getCollectionStatistics()
|
||||
}
|
||||
|
||||
// MARK: - FavoritePlantRepositoryProtocol
|
||||
|
||||
/// Checks if a plant is marked as favorite
|
||||
/// - Parameter plantID: The unique identifier of the plant
|
||||
/// - Returns: True if the plant is a favorite, false otherwise
|
||||
/// - Throws: PlantStorageError if the check operation fails
|
||||
func isFavorite(plantID: UUID) async throws -> Bool {
|
||||
try await coreDataStack.performBackgroundTask { [plantEntityName] context in
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: plantEntityName)
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", plantID as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try context.fetch(fetchRequest)
|
||||
|
||||
guard let plant = results.first else {
|
||||
throw PlantStorageError.plantNotFound(plantID)
|
||||
}
|
||||
|
||||
return (plant.value(forKey: "isFavorite") as? Bool) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Updates mutable fields on a managed object from a Plant domain entity
|
||||
/// - Parameters:
|
||||
/// - managedObject: The managed object to update
|
||||
/// - plant: The plant domain entity with updated values
|
||||
private static func updateMutableFields(on managedObject: NSManagedObject, from plant: Plant) {
|
||||
managedObject.setValue(plant.localImagePaths as NSArray, forKey: "localImagePaths")
|
||||
managedObject.setValue(plant.dateAdded, forKey: "dateAdded")
|
||||
managedObject.setValue(plant.confidenceScore, forKey: "confidenceScore")
|
||||
managedObject.setValue(plant.notes, forKey: "notes")
|
||||
managedObject.setValue(plant.isFavorite, forKey: "isFavorite")
|
||||
managedObject.setValue(plant.customName, forKey: "customName")
|
||||
managedObject.setValue(plant.location, forKey: "location")
|
||||
}
|
||||
|
||||
/// Converts a managed object to a Plant domain entity
|
||||
/// - Parameter managedObject: The managed object to convert
|
||||
/// - Returns: A Plant domain entity
|
||||
/// - Throws: PlantStorageError if required data is missing or invalid
|
||||
private static func convertToPlant(from managedObject: NSManagedObject) throws -> Plant {
|
||||
guard let id = managedObject.value(forKey: "id") as? UUID,
|
||||
let scientificName = managedObject.value(forKey: "scientificName") as? String,
|
||||
let dateIdentified = managedObject.value(forKey: "dateIdentified") as? Date else {
|
||||
throw PlantStorageError.invalidData("Missing required plant fields")
|
||||
}
|
||||
|
||||
let commonNames = managedObject.value(forKey: "commonNames") as? [String] ?? []
|
||||
let family = managedObject.value(forKey: "family") as? String ?? ""
|
||||
let genus = managedObject.value(forKey: "genus") as? String ?? ""
|
||||
|
||||
let imageURLStrings = managedObject.value(forKey: "imageURLs") as? [String] ?? []
|
||||
let imageURLs = imageURLStrings.compactMap { URL(string: $0) }
|
||||
|
||||
let sourceRaw = managedObject.value(forKey: "identificationSource") as? String ?? "userManual"
|
||||
let identificationSource = IdentificationSource(rawValue: sourceRaw) ?? .userManual
|
||||
|
||||
let localImagePaths = managedObject.value(forKey: "localImagePaths") as? [String] ?? []
|
||||
let dateAdded = managedObject.value(forKey: "dateAdded") as? Date
|
||||
let confidenceScore = managedObject.value(forKey: "confidenceScore") as? Double
|
||||
let notes = managedObject.value(forKey: "notes") as? String
|
||||
let isFavorite = (managedObject.value(forKey: "isFavorite") as? Bool) ?? false
|
||||
let customName = managedObject.value(forKey: "customName") as? String
|
||||
let location = managedObject.value(forKey: "location") as? String
|
||||
|
||||
return Plant(
|
||||
id: id,
|
||||
scientificName: scientificName,
|
||||
commonNames: commonNames,
|
||||
family: family,
|
||||
genus: genus,
|
||||
imageURLs: imageURLs,
|
||||
dateIdentified: dateIdentified,
|
||||
identificationSource: identificationSource,
|
||||
localImagePaths: localImagePaths,
|
||||
dateAdded: dateAdded,
|
||||
confidenceScore: confidenceScore,
|
||||
notes: notes,
|
||||
isFavorite: isFavorite,
|
||||
customName: customName,
|
||||
location: location
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates sort descriptors based on the filter configuration
|
||||
/// - Parameter filter: The filter configuration containing sort options
|
||||
/// - Returns: An array of NSSortDescriptor objects
|
||||
private static func sortDescriptors(for filter: PlantFilter) -> [NSSortDescriptor] {
|
||||
let ascending = filter.sortAscending
|
||||
|
||||
switch filter.sortBy {
|
||||
case .dateAdded:
|
||||
return [NSSortDescriptor(key: "dateAdded", ascending: ascending)]
|
||||
case .dateIdentified:
|
||||
return [NSSortDescriptor(key: "dateIdentified", ascending: ascending)]
|
||||
case .name:
|
||||
return [
|
||||
NSSortDescriptor(key: "customName", ascending: ascending),
|
||||
NSSortDescriptor(key: "scientificName", ascending: ascending)
|
||||
]
|
||||
case .family:
|
||||
return [
|
||||
NSSortDescriptor(key: "family", ascending: ascending),
|
||||
NSSortDescriptor(key: "scientificName", ascending: ascending)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates upcoming and overdue task counts
|
||||
/// - Parameters:
|
||||
/// - context: The managed object context to use
|
||||
/// - entityName: The care task entity name
|
||||
/// - now: The current date for comparison
|
||||
/// - Returns: A tuple containing (upcomingCount, overdueCount)
|
||||
private static func calculateTaskCounts(
|
||||
in context: NSManagedObjectContext,
|
||||
entityName: String,
|
||||
now: Date
|
||||
) throws -> (upcoming: Int, overdue: Int) {
|
||||
// Calculate overdue tasks (scheduled before now, not completed)
|
||||
let overdueRequest = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
overdueRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate < %@",
|
||||
now as NSDate
|
||||
)
|
||||
let overdueCount = try context.count(for: overdueRequest)
|
||||
|
||||
// Calculate upcoming tasks (scheduled in the next 7 days, not completed)
|
||||
let calendar = Calendar.current
|
||||
guard let weekFromNow = calendar.date(byAdding: .day, value: 7, to: now) else {
|
||||
return (0, overdueCount)
|
||||
}
|
||||
|
||||
let upcomingRequest = NSFetchRequest<NSManagedObject>(entityName: entityName)
|
||||
upcomingRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate >= %@ AND scheduledDate <= %@",
|
||||
now as NSDate,
|
||||
weekFromNow as NSDate
|
||||
)
|
||||
let upcomingCount = try context.count(for: upcomingRequest)
|
||||
|
||||
return (upcomingCount, overdueCount)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
#if DEBUG
|
||||
extension CoreDataPlantStorage {
|
||||
/// Creates a storage instance with an in-memory Core Data stack for testing
|
||||
/// - Returns: A CoreDataPlantStorage instance backed by an in-memory store
|
||||
static func inMemoryStorage() -> CoreDataPlantStorage {
|
||||
return CoreDataPlantStorage(coreDataStack: CoreDataStack.inMemoryStack())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// CoreDataStack.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Core Data stack implementation with actor-based thread safety
|
||||
// for plant identification iOS app.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - Core Data Stack Errors
|
||||
|
||||
enum CoreDataError: Error, LocalizedError {
|
||||
case failedToLoadPersistentStore(Error)
|
||||
case contextSaveError(Error)
|
||||
case entityNotFound(String)
|
||||
case invalidManagedObject
|
||||
case migrationFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToLoadPersistentStore(let error):
|
||||
return "Failed to load persistent store: \(error.localizedDescription)"
|
||||
case .contextSaveError(let error):
|
||||
return "Failed to save context: \(error.localizedDescription)"
|
||||
case .entityNotFound(let name):
|
||||
return "Entity not found: \(name)"
|
||||
case .invalidManagedObject:
|
||||
return "Invalid managed object"
|
||||
case .migrationFailed(let error):
|
||||
return "Migration failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Support
|
||||
|
||||
/// Describes a Core Data model version for migration tracking
|
||||
struct CoreDataModelVersion: Comparable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
||||
var identifier: String {
|
||||
"PlantGuideModel_v\(major)_\(minor)_\(patch)"
|
||||
}
|
||||
|
||||
static func < (lhs: CoreDataModelVersion, rhs: CoreDataModelVersion) -> Bool {
|
||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||
return lhs.patch < rhs.patch
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for Core Data migration
|
||||
struct MigrationConfiguration {
|
||||
let shouldMigrateAutomatically: Bool
|
||||
let shouldInferMappingModelAutomatically: Bool
|
||||
let currentVersion: CoreDataModelVersion
|
||||
|
||||
static let `default` = MigrationConfiguration(
|
||||
shouldMigrateAutomatically: true,
|
||||
shouldInferMappingModelAutomatically: true,
|
||||
currentVersion: CoreDataModelVersion(major: 1, minor: 0, patch: 0)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Core Data Stack Protocol
|
||||
|
||||
/// Protocol defining Core Data stack capabilities
|
||||
protocol CoreDataStackProtocol: Sendable {
|
||||
func viewContext() -> NSManagedObjectContext
|
||||
func newBackgroundContext() -> NSManagedObjectContext
|
||||
func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T
|
||||
func save(context: NSManagedObjectContext) throws
|
||||
}
|
||||
|
||||
// MARK: - Core Data Stack Actor
|
||||
|
||||
/// Thread-safe Core Data stack using Swift actor for concurrency safety
|
||||
/// Manages persistent container, contexts, and migrations for PlantGuide app
|
||||
@globalActor
|
||||
actor CoreDataStackActor {
|
||||
static let shared = CoreDataStackActor()
|
||||
}
|
||||
|
||||
final class CoreDataStack: CoreDataStackProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Shared singleton instance
|
||||
static let shared = CoreDataStack()
|
||||
|
||||
/// The name of the Core Data model file (without extension)
|
||||
private let modelName: String
|
||||
|
||||
/// Migration configuration
|
||||
private let migrationConfig: MigrationConfiguration
|
||||
|
||||
// MARK: - Thread Safety
|
||||
// This type is @unchecked Sendable because:
|
||||
// - persistentContainer is initialized eagerly in init() before any concurrent access
|
||||
// - All mutable operations go through Core Data's context.perform() for thread safety
|
||||
// - The coreDataQueue serializes any direct container operations
|
||||
|
||||
/// The persistent container managing the Core Data stack
|
||||
private let persistentContainer: NSPersistentContainer
|
||||
|
||||
/// Serial queue for thread-safe operations
|
||||
private let coreDataQueue = DispatchQueue(label: "com.plantguide.coredata", qos: .userInitiated)
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initializes the Core Data stack
|
||||
/// - Parameters:
|
||||
/// - modelName: Name of the .xcdatamodeld file (default: "PlantGuideModel")
|
||||
/// - migrationConfig: Migration configuration (default: automatic migration)
|
||||
init(
|
||||
modelName: String = "PlantGuideModel",
|
||||
migrationConfig: MigrationConfiguration = .default
|
||||
) {
|
||||
self.modelName = modelName
|
||||
self.migrationConfig = migrationConfig
|
||||
self.persistentContainer = Self.createPersistentContainer(
|
||||
modelName: modelName,
|
||||
migrationConfig: migrationConfig
|
||||
)
|
||||
}
|
||||
|
||||
/// Initializes for testing with in-memory store
|
||||
/// - Parameter inMemory: If true, uses in-memory store for testing
|
||||
convenience init(inMemory: Bool) {
|
||||
self.init()
|
||||
if inMemory {
|
||||
Self.setupInMemoryStore(for: persistentContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistent Container Setup
|
||||
|
||||
private static func createPersistentContainer(
|
||||
modelName: String,
|
||||
migrationConfig: MigrationConfiguration
|
||||
) -> NSPersistentContainer {
|
||||
let container = NSPersistentContainer(name: modelName)
|
||||
|
||||
// Configure store description with migration options
|
||||
let storeDescription = container.persistentStoreDescriptions.first
|
||||
storeDescription?.shouldMigrateStoreAutomatically = migrationConfig.shouldMigrateAutomatically
|
||||
storeDescription?.shouldInferMappingModelAutomatically = migrationConfig.shouldInferMappingModelAutomatically
|
||||
|
||||
// Enable persistent history tracking for background context synchronization
|
||||
storeDescription?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
storeDescription?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||
|
||||
container.loadPersistentStores { storeDescription, error in
|
||||
if let error = error as NSError? {
|
||||
// Log the error - in production, consider recovery strategies
|
||||
print("Core Data persistent store error: \(error), \(error.userInfo)")
|
||||
|
||||
// Attempt migration recovery if needed
|
||||
attemptMigrationRecovery(container: container, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure view context for main thread usage
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
container.viewContext.undoManager = nil
|
||||
container.viewContext.shouldDeleteInaccessibleFaults = true
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
private static func setupInMemoryStore(for container: NSPersistentContainer) {
|
||||
let description = NSPersistentStoreDescription()
|
||||
description.type = NSInMemoryStoreType
|
||||
description.shouldAddStoreAsynchronously = false
|
||||
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
fatalError("In-memory store failed to load: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Support
|
||||
|
||||
/// Attempts to recover from migration failures
|
||||
private static func attemptMigrationRecovery(container: NSPersistentContainer, error: NSError) {
|
||||
// Check if this is a migration-related error
|
||||
guard error.domain == NSCocoaErrorDomain else { return }
|
||||
|
||||
let migrationErrorCodes: [Int] = [
|
||||
NSPersistentStoreIncompatibleVersionHashError,
|
||||
NSMigrationMissingSourceModelError,
|
||||
NSMigrationMissingMappingModelError
|
||||
]
|
||||
|
||||
if migrationErrorCodes.contains(error.code) {
|
||||
print("Migration error detected. Consider implementing progressive migration.")
|
||||
// In production, implement custom migration logic here
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if migration is needed for the persistent store
|
||||
/// - Returns: True if migration is required
|
||||
func requiresMigration() -> Bool {
|
||||
guard let storeURL = persistentContainer.persistentStoreDescriptions.first?.url else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: storeURL.path) else {
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
|
||||
ofType: NSSQLiteStoreType,
|
||||
at: storeURL
|
||||
)
|
||||
|
||||
let model = persistentContainer.managedObjectModel
|
||||
return !model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Returns the main context for UI operations
|
||||
/// - Important: Only use on the main thread
|
||||
/// - Returns: The view context associated with the main queue
|
||||
func viewContext() -> NSManagedObjectContext {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
/// Creates a new background context for background operations
|
||||
/// - Returns: A new private queue context
|
||||
func newBackgroundContext() -> NSManagedObjectContext {
|
||||
let context = persistentContainer.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
context.undoManager = nil
|
||||
context.automaticallyMergesChangesFromParent = true
|
||||
return context
|
||||
}
|
||||
|
||||
/// Performs a task on a background context asynchronously
|
||||
/// - Parameter block: The block to execute with the background context
|
||||
/// - Returns: The result of the block execution
|
||||
/// - Throws: Any error thrown by the block or save operation
|
||||
func performBackgroundTask<T: Sendable>(_ block: @escaping @Sendable (NSManagedObjectContext) throws -> T) async throws -> T {
|
||||
let context = newBackgroundContext()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let result = try block(context)
|
||||
|
||||
if context.hasChanges {
|
||||
try context.save()
|
||||
}
|
||||
|
||||
continuation.resume(returning: result)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the given context if it has changes
|
||||
/// - Parameter context: The context to save
|
||||
/// - Throws: CoreDataError.contextSaveError if save fails
|
||||
func save(context: NSManagedObjectContext) throws {
|
||||
guard context.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
throw CoreDataError.contextSaveError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the view context
|
||||
/// - Throws: CoreDataError.contextSaveError if save fails
|
||||
func saveViewContext() throws {
|
||||
try save(context: viewContext())
|
||||
}
|
||||
|
||||
// MARK: - Batch Operations
|
||||
|
||||
/// Performs a batch delete operation
|
||||
/// - Parameters:
|
||||
/// - fetchRequest: The fetch request defining objects to delete
|
||||
/// - context: The context to perform the operation in
|
||||
/// - Returns: The batch delete result
|
||||
@discardableResult
|
||||
func batchDelete<T: NSManagedObject>(
|
||||
fetchRequest: NSFetchRequest<T>,
|
||||
context: NSManagedObjectContext
|
||||
) throws -> NSBatchDeleteResult {
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest as! NSFetchRequest<NSFetchRequestResult>)
|
||||
deleteRequest.resultType = .resultTypeObjectIDs
|
||||
|
||||
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
|
||||
|
||||
// Merge changes into context
|
||||
if let objectIDs = result?.result as? [NSManagedObjectID] {
|
||||
let changes = [NSDeletedObjectsKey: objectIDs]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [viewContext()])
|
||||
}
|
||||
|
||||
return result ?? NSBatchDeleteResult()
|
||||
}
|
||||
|
||||
// MARK: - Store Management
|
||||
|
||||
/// Returns the URL of the persistent store
|
||||
var storeURL: URL? {
|
||||
persistentContainer.persistentStoreDescriptions.first?.url
|
||||
}
|
||||
|
||||
/// Destroys the persistent store (use with caution)
|
||||
/// - Warning: This permanently deletes all data
|
||||
func destroyPersistentStore() throws {
|
||||
guard let storeURL = storeURL else { return }
|
||||
|
||||
let coordinator = persistentContainer.persistentStoreCoordinator
|
||||
|
||||
try coordinator.destroyPersistentStore(
|
||||
at: storeURL,
|
||||
ofType: NSSQLiteStoreType,
|
||||
options: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Resets the Core Data stack by destroying and recreating the store
|
||||
func resetStack() throws {
|
||||
try destroyPersistentStore()
|
||||
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
print("Failed to reload persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
#if DEBUG
|
||||
extension CoreDataStack {
|
||||
/// Creates an in-memory stack for testing
|
||||
static func inMemoryStack() -> CoreDataStack {
|
||||
return CoreDataStack(inMemory: true)
|
||||
}
|
||||
|
||||
/// Resets the in-memory store for test isolation
|
||||
func resetForTesting() throws {
|
||||
let context = viewContext()
|
||||
|
||||
// Delete all entities
|
||||
let entityNames = persistentContainer.managedObjectModel.entities.compactMap { $0.name }
|
||||
|
||||
for entityName in entityNames {
|
||||
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
|
||||
try context.execute(deleteRequest)
|
||||
}
|
||||
|
||||
try save(context: context)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// ManagedObjectExtensions.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Extensions to convert between Core Data managed objects and domain entities.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - Domain Entity Protocols
|
||||
|
||||
/// Protocol for domain entities that can be converted to/from managed objects
|
||||
protocol ManagedObjectConvertible {
|
||||
associatedtype ManagedObject: NSManagedObject
|
||||
|
||||
/// Creates a domain entity from a managed object
|
||||
init(managedObject: ManagedObject) throws
|
||||
|
||||
/// Updates a managed object with the domain entity's values
|
||||
func update(managedObject: ManagedObject)
|
||||
}
|
||||
|
||||
/// Protocol for managed objects that can be converted to domain entities
|
||||
protocol DomainConvertible: NSManagedObject {
|
||||
associatedtype DomainEntity
|
||||
|
||||
/// Converts to a domain entity
|
||||
func toDomainEntity() throws -> DomainEntity
|
||||
}
|
||||
|
||||
// MARK: - NSManagedObject Extensions
|
||||
|
||||
/// Extension for PlantMO managed object
|
||||
extension NSManagedObject {
|
||||
|
||||
// MARK: - Plant Conversion
|
||||
|
||||
/// Creates a Plant domain entity from a PlantMO managed object
|
||||
/// - Returns: Plant domain entity
|
||||
func toPlant() throws -> Plant {
|
||||
guard let entityName = entity.name, entityName == "PlantMO" else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
guard let id = value(forKey: "id") as? UUID,
|
||||
let scientificName = value(forKey: "scientificName") as? String,
|
||||
let dateIdentified = value(forKey: "dateIdentified") as? Date else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
let commonNames = value(forKey: "commonNames") as? [String] ?? []
|
||||
let family = value(forKey: "family") as? String ?? ""
|
||||
let genus = value(forKey: "genus") as? String ?? ""
|
||||
let imageURLStrings = value(forKey: "imageURLs") as? [String] ?? []
|
||||
let imageURLs = imageURLStrings.compactMap { URL(string: $0) }
|
||||
let sourceRaw = value(forKey: "identificationSource") as? String ?? "userManual"
|
||||
let source = IdentificationSource(rawValue: sourceRaw) ?? .userManual
|
||||
|
||||
return Plant(
|
||||
id: id,
|
||||
scientificName: scientificName,
|
||||
commonNames: commonNames,
|
||||
family: family,
|
||||
genus: genus,
|
||||
imageURLs: imageURLs,
|
||||
dateIdentified: dateIdentified,
|
||||
identificationSource: source
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates PlantMO managed object from Plant domain entity
|
||||
func updateFromPlant(_ plant: Plant) {
|
||||
setValue(plant.id, forKey: "id")
|
||||
setValue(plant.scientificName, forKey: "scientificName")
|
||||
setValue(plant.commonNames as NSArray, forKey: "commonNames")
|
||||
setValue(plant.family, forKey: "family")
|
||||
setValue(plant.genus, forKey: "genus")
|
||||
setValue(plant.imageURLs.map { $0.absoluteString } as NSArray, forKey: "imageURLs")
|
||||
setValue(plant.dateIdentified, forKey: "dateIdentified")
|
||||
setValue(plant.identificationSource.rawValue, forKey: "identificationSource")
|
||||
}
|
||||
|
||||
// MARK: - PlantCareSchedule Conversion
|
||||
|
||||
/// Creates a PlantCareSchedule domain entity from a CareScheduleMO managed object
|
||||
func toPlantCareSchedule() throws -> PlantCareSchedule {
|
||||
guard let entityName = entity.name, entityName == "CareScheduleMO" else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
guard let id = value(forKey: "id") as? UUID,
|
||||
let plantID = value(forKey: "plantID") as? UUID else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
let lightRaw = value(forKey: "lightRequirement") as? String ?? "lowLight"
|
||||
let lightRequirement = LightRequirement(rawValue: lightRaw) ?? .lowLight
|
||||
|
||||
var tasks: [CareTask] = []
|
||||
if let tasksSet = value(forKey: "tasks") as? Set<NSManagedObject> {
|
||||
tasks = try tasksSet.map { try $0.toCareTask() }
|
||||
}
|
||||
|
||||
let tempMin = value(forKey: "temperatureMin") as? Int ?? 60
|
||||
let tempMax = value(forKey: "temperatureMax") as? Int ?? 80
|
||||
|
||||
return PlantCareSchedule(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
lightRequirement: lightRequirement,
|
||||
wateringSchedule: value(forKey: "wateringSchedule") as? String ?? "",
|
||||
temperatureRange: tempMin...tempMax,
|
||||
fertilizerSchedule: value(forKey: "fertilizerSchedule") as? String ?? "",
|
||||
tasks: tasks
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates CareScheduleMO managed object from PlantCareSchedule domain entity
|
||||
func updateFromPlantCareSchedule(_ schedule: PlantCareSchedule) {
|
||||
setValue(schedule.id, forKey: "id")
|
||||
setValue(schedule.plantID, forKey: "plantID")
|
||||
setValue(schedule.lightRequirement.rawValue, forKey: "lightRequirement")
|
||||
setValue(schedule.wateringSchedule, forKey: "wateringSchedule")
|
||||
setValue(schedule.fertilizerSchedule, forKey: "fertilizerSchedule")
|
||||
}
|
||||
|
||||
// MARK: - PlantIdentification Conversion
|
||||
|
||||
/// Creates a PlantIdentification domain entity from an IdentificationMO managed object
|
||||
func toPlantIdentification() throws -> PlantIdentification {
|
||||
guard let entityName = entity.name, entityName == "IdentificationMO" else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
guard let id = value(forKey: "id") as? UUID,
|
||||
let species = value(forKey: "species") as? String,
|
||||
let timestamp = value(forKey: "timestamp") as? Date else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
let confidence = value(forKey: "confidence") as? Double ?? 0.0
|
||||
let sourceRaw = value(forKey: "source") as? String ?? "userManual"
|
||||
let source = IdentificationSource(rawValue: sourceRaw) ?? .userManual
|
||||
|
||||
return PlantIdentification(
|
||||
id: id,
|
||||
species: species,
|
||||
confidence: confidence,
|
||||
source: source,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates IdentificationMO managed object from PlantIdentification domain entity
|
||||
func updateFromPlantIdentification(_ identification: PlantIdentification) {
|
||||
setValue(identification.id, forKey: "id")
|
||||
setValue(identification.species, forKey: "species")
|
||||
setValue(identification.confidence, forKey: "confidence")
|
||||
setValue(identification.source.rawValue, forKey: "source")
|
||||
setValue(identification.timestamp, forKey: "timestamp")
|
||||
}
|
||||
|
||||
// MARK: - CareTask Conversion
|
||||
|
||||
/// Creates a CareTask domain entity from a CareTaskMO managed object
|
||||
func toCareTask() throws -> CareTask {
|
||||
guard let entityName = entity.name, entityName == "CareTaskMO" else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
guard let id = value(forKey: "id") as? UUID,
|
||||
let plantID = value(forKey: "plantID") as? UUID,
|
||||
let scheduledDate = value(forKey: "scheduledDate") as? Date else {
|
||||
throw CoreDataError.invalidManagedObject
|
||||
}
|
||||
|
||||
let typeRaw = value(forKey: "type") as? String ?? "watering"
|
||||
let type = CareTaskType(rawValue: typeRaw) ?? .watering
|
||||
|
||||
return CareTask(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
type: type,
|
||||
scheduledDate: scheduledDate,
|
||||
completedDate: value(forKey: "completedDate") as? Date,
|
||||
notes: value(forKey: "notes") as? String ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates CareTaskMO managed object from CareTask domain entity
|
||||
func updateFromCareTask(_ task: CareTask) {
|
||||
setValue(task.id, forKey: "id")
|
||||
setValue(task.plantID, forKey: "plantID")
|
||||
setValue(task.type.rawValue, forKey: "type")
|
||||
setValue(task.scheduledDate, forKey: "scheduledDate")
|
||||
setValue(task.completedDate, forKey: "completedDate")
|
||||
setValue(task.notes, forKey: "notes")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request Helpers
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
|
||||
/// Fetches all plants sorted by date identified
|
||||
func fetchAllPlants() throws -> [Plant] {
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "PlantMO")
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "dateIdentified", ascending: false)]
|
||||
|
||||
let results = try fetch(fetchRequest)
|
||||
return try results.map { try $0.toPlant() }
|
||||
}
|
||||
|
||||
/// Fetches a plant by ID
|
||||
func fetchPlant(byID id: UUID) throws -> Plant? {
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "PlantMO")
|
||||
fetchRequest.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let results = try fetch(fetchRequest)
|
||||
return try results.first?.toPlant()
|
||||
}
|
||||
|
||||
/// Fetches plants matching a search term
|
||||
func fetchPlants(matching searchTerm: String) throws -> [Plant] {
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "PlantMO")
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "scientificName CONTAINS[cd] %@ OR family CONTAINS[cd] %@ OR genus CONTAINS[cd] %@",
|
||||
searchTerm, searchTerm, searchTerm
|
||||
)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scientificName", ascending: true)]
|
||||
|
||||
let results = try fetch(fetchRequest)
|
||||
return try results.map { try $0.toPlant() }
|
||||
}
|
||||
|
||||
/// Fetches pending care tasks up to a given date
|
||||
func fetchPendingTasks(until date: Date = Date()) throws -> [CareTask] {
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "CareTaskMO")
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "completedDate == nil AND scheduledDate <= %@",
|
||||
date as NSDate
|
||||
)
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "scheduledDate", ascending: true)]
|
||||
|
||||
let results = try fetch(fetchRequest)
|
||||
return try results.map { try $0.toCareTask() }
|
||||
}
|
||||
|
||||
/// Fetches recent identifications
|
||||
func fetchRecentIdentifications(limit: Int = 10) throws -> [PlantIdentification] {
|
||||
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "IdentificationMO")
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
|
||||
fetchRequest.fetchLimit = limit
|
||||
|
||||
let results = try fetch(fetchRequest)
|
||||
return try results.map { try $0.toPlantIdentification() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entity Creation Helpers
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
|
||||
/// Creates and inserts a new PlantMO from a Plant domain entity
|
||||
@discardableResult
|
||||
func insertPlant(_ plant: Plant) throws -> NSManagedObject {
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: "PlantMO", in: self) else {
|
||||
throw CoreDataError.entityNotFound("PlantMO")
|
||||
}
|
||||
|
||||
let managedObject = NSManagedObject(entity: entity, insertInto: self)
|
||||
managedObject.updateFromPlant(plant)
|
||||
return managedObject
|
||||
}
|
||||
|
||||
/// Creates and inserts a new CareScheduleMO from a PlantCareSchedule domain entity
|
||||
@discardableResult
|
||||
func insertCareSchedule(_ schedule: PlantCareSchedule) throws -> NSManagedObject {
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: "CareScheduleMO", in: self) else {
|
||||
throw CoreDataError.entityNotFound("CareScheduleMO")
|
||||
}
|
||||
|
||||
let managedObject = NSManagedObject(entity: entity, insertInto: self)
|
||||
managedObject.updateFromPlantCareSchedule(schedule)
|
||||
return managedObject
|
||||
}
|
||||
|
||||
/// Creates and inserts a new IdentificationMO from a PlantIdentification domain entity
|
||||
@discardableResult
|
||||
func insertIdentification(_ identification: PlantIdentification) throws -> NSManagedObject {
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: "IdentificationMO", in: self) else {
|
||||
throw CoreDataError.entityNotFound("IdentificationMO")
|
||||
}
|
||||
|
||||
let managedObject = NSManagedObject(entity: entity, insertInto: self)
|
||||
managedObject.updateFromPlantIdentification(identification)
|
||||
return managedObject
|
||||
}
|
||||
|
||||
/// Creates and inserts a new CareTaskMO from a CareTask domain entity
|
||||
@discardableResult
|
||||
func insertCareTask(_ task: CareTask) throws -> NSManagedObject {
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: "CareTaskMO", in: self) else {
|
||||
throw CoreDataError.entityNotFound("CareTaskMO")
|
||||
}
|
||||
|
||||
let managedObject = NSManagedObject(entity: entity, insertInto: self)
|
||||
managedObject.updateFromCareTask(task)
|
||||
return managedObject
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - CareScheduleMO
|
||||
|
||||
/// Core Data managed object representing a PlantCareSchedule entity.
|
||||
/// Maps to the PlantCareSchedule domain model for persistence.
|
||||
@objc(CareScheduleMO)
|
||||
public class CareScheduleMO: NSManagedObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Unique identifier for the care schedule
|
||||
@NSManaged public var id: UUID
|
||||
|
||||
/// The ID of the plant this schedule belongs to
|
||||
@NSManaged public var plantID: UUID
|
||||
|
||||
/// The raw value string for LightRequirement enum
|
||||
@NSManaged public var lightRequirement: String
|
||||
|
||||
/// Description of the watering schedule
|
||||
@NSManaged public var wateringSchedule: String
|
||||
|
||||
/// The minimum acceptable temperature
|
||||
@NSManaged public var temperatureMin: Int32
|
||||
|
||||
/// The maximum acceptable temperature
|
||||
@NSManaged public var temperatureMax: Int32
|
||||
|
||||
/// Description of the fertilizer schedule
|
||||
@NSManaged public var fertilizerSchedule: String
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
/// The plant this schedule belongs to (inverse relationship, optional)
|
||||
@NSManaged public var plant: PlantMO?
|
||||
|
||||
/// The care tasks associated with this schedule (one-to-many)
|
||||
@NSManaged public var tasks: NSSet?
|
||||
}
|
||||
|
||||
// MARK: - Generated Accessors for Tasks
|
||||
|
||||
extension CareScheduleMO {
|
||||
|
||||
@objc(addTasksObject:)
|
||||
@NSManaged public func addToTasks(_ value: CareTaskMO)
|
||||
|
||||
@objc(removeTasksObject:)
|
||||
@NSManaged public func removeFromTasks(_ value: CareTaskMO)
|
||||
|
||||
@objc(addTasks:)
|
||||
@NSManaged public func addToTasks(_ values: NSSet)
|
||||
|
||||
@objc(removeTasks:)
|
||||
@NSManaged public func removeFromTasks(_ values: NSSet)
|
||||
}
|
||||
|
||||
// MARK: - Domain Model Conversion
|
||||
|
||||
extension CareScheduleMO {
|
||||
|
||||
/// Converts this managed object to a PlantCareSchedule domain model.
|
||||
/// - Returns: A PlantCareSchedule domain entity populated with this managed object's data.
|
||||
func toDomainModel() -> PlantCareSchedule {
|
||||
let light = LightRequirement(rawValue: lightRequirement) ?? .partialShade
|
||||
let temperatureRange = Int(temperatureMin)...Int(temperatureMax)
|
||||
|
||||
// Convert tasks NSSet to array of CareTask domain models
|
||||
let taskArray: [CareTask]
|
||||
if let tasksSet = tasks as? Set<CareTaskMO> {
|
||||
taskArray = tasksSet.map { $0.toDomainModel() }
|
||||
} else {
|
||||
taskArray = []
|
||||
}
|
||||
|
||||
return PlantCareSchedule(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
lightRequirement: light,
|
||||
wateringSchedule: wateringSchedule,
|
||||
temperatureRange: temperatureRange,
|
||||
fertilizerSchedule: fertilizerSchedule,
|
||||
tasks: taskArray
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a CareScheduleMO managed object from a PlantCareSchedule domain model.
|
||||
/// - Parameters:
|
||||
/// - schedule: The PlantCareSchedule domain entity to convert.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new CareScheduleMO instance populated with the schedule's data.
|
||||
static func fromDomainModel(
|
||||
_ schedule: PlantCareSchedule,
|
||||
context: NSManagedObjectContext
|
||||
) -> CareScheduleMO {
|
||||
let scheduleMO = CareScheduleMO(context: context)
|
||||
|
||||
scheduleMO.id = schedule.id
|
||||
scheduleMO.plantID = schedule.plantID
|
||||
scheduleMO.lightRequirement = schedule.lightRequirement.rawValue
|
||||
scheduleMO.wateringSchedule = schedule.wateringSchedule
|
||||
scheduleMO.temperatureMin = Int32(schedule.temperatureRange.lowerBound)
|
||||
scheduleMO.temperatureMax = Int32(schedule.temperatureRange.upperBound)
|
||||
scheduleMO.fertilizerSchedule = schedule.fertilizerSchedule
|
||||
|
||||
// Convert and add tasks
|
||||
let taskMOs = schedule.tasks.map { CareTaskMO.fromDomainModel($0, context: context) }
|
||||
for taskMO in taskMOs {
|
||||
taskMO.careSchedule = scheduleMO
|
||||
}
|
||||
scheduleMO.tasks = NSSet(array: taskMOs)
|
||||
|
||||
return scheduleMO
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a PlantCareSchedule domain model.
|
||||
/// - Parameters:
|
||||
/// - schedule: The PlantCareSchedule domain entity to update from.
|
||||
/// - context: The managed object context for creating new task objects.
|
||||
func update(from schedule: PlantCareSchedule, context: NSManagedObjectContext) {
|
||||
id = schedule.id
|
||||
plantID = schedule.plantID
|
||||
lightRequirement = schedule.lightRequirement.rawValue
|
||||
wateringSchedule = schedule.wateringSchedule
|
||||
temperatureMin = Int32(schedule.temperatureRange.lowerBound)
|
||||
temperatureMax = Int32(schedule.temperatureRange.upperBound)
|
||||
fertilizerSchedule = schedule.fertilizerSchedule
|
||||
|
||||
// Remove existing tasks
|
||||
if let existingTasks = tasks as? Set<CareTaskMO> {
|
||||
for task in existingTasks {
|
||||
context.delete(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new tasks
|
||||
let taskMOs = schedule.tasks.map { CareTaskMO.fromDomainModel($0, context: context) }
|
||||
for taskMO in taskMOs {
|
||||
taskMO.careSchedule = self
|
||||
}
|
||||
tasks = NSSet(array: taskMOs)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension CareScheduleMO {
|
||||
|
||||
/// Creates a fetch request for CareScheduleMO entities.
|
||||
/// - Returns: A configured NSFetchRequest for CareScheduleMO.
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<CareScheduleMO> {
|
||||
return NSFetchRequest<CareScheduleMO>(entityName: "CareScheduleMO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - CareTaskMO
|
||||
|
||||
/// Core Data managed object representing a CareTask entity.
|
||||
/// Maps to the CareTask domain model for persistence.
|
||||
@objc(CareTaskMO)
|
||||
public class CareTaskMO: NSManagedObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Unique identifier for the care task
|
||||
@NSManaged public var id: UUID
|
||||
|
||||
/// The ID of the plant this task belongs to
|
||||
@NSManaged public var plantID: UUID
|
||||
|
||||
/// The raw value string for CareTaskType enum
|
||||
@NSManaged public var type: String
|
||||
|
||||
/// The date when the task is scheduled
|
||||
@NSManaged public var scheduledDate: Date
|
||||
|
||||
/// The date when the task was completed, nil if not yet completed
|
||||
@NSManaged public var completedDate: Date?
|
||||
|
||||
/// Additional notes or instructions for the task
|
||||
@NSManaged public var notes: String
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
/// The care schedule this task belongs to (inverse relationship, optional)
|
||||
@NSManaged public var careSchedule: CareScheduleMO?
|
||||
}
|
||||
|
||||
// MARK: - Domain Model Conversion
|
||||
|
||||
extension CareTaskMO {
|
||||
|
||||
/// Converts this managed object to a CareTask domain model.
|
||||
/// - Returns: A CareTask domain entity populated with this managed object's data.
|
||||
func toDomainModel() -> CareTask {
|
||||
let taskType = CareTaskType(rawValue: type) ?? .watering
|
||||
|
||||
return CareTask(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
type: taskType,
|
||||
scheduledDate: scheduledDate,
|
||||
completedDate: completedDate,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a CareTaskMO managed object from a CareTask domain model.
|
||||
/// - Parameters:
|
||||
/// - task: The CareTask domain entity to convert.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new CareTaskMO instance populated with the task's data.
|
||||
static func fromDomainModel(_ task: CareTask, context: NSManagedObjectContext) -> CareTaskMO {
|
||||
let taskMO = CareTaskMO(context: context)
|
||||
|
||||
taskMO.id = task.id
|
||||
taskMO.plantID = task.plantID
|
||||
taskMO.type = task.type.rawValue
|
||||
taskMO.scheduledDate = task.scheduledDate
|
||||
taskMO.completedDate = task.completedDate
|
||||
taskMO.notes = task.notes
|
||||
|
||||
return taskMO
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a CareTask domain model.
|
||||
/// - Parameter task: The CareTask domain entity to update from.
|
||||
func update(from task: CareTask) {
|
||||
id = task.id
|
||||
plantID = task.plantID
|
||||
type = task.type.rawValue
|
||||
scheduledDate = task.scheduledDate
|
||||
completedDate = task.completedDate
|
||||
notes = task.notes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension CareTaskMO {
|
||||
|
||||
/// Creates a fetch request for CareTaskMO entities.
|
||||
/// - Returns: A configured NSFetchRequest for CareTaskMO.
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<CareTaskMO> {
|
||||
return NSFetchRequest<CareTaskMO>(entityName: "CareTaskMO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - IdentificationMO
|
||||
|
||||
/// Core Data managed object representing a PlantIdentification entity.
|
||||
/// Maps to the PlantIdentification domain model for persistence.
|
||||
@objc(IdentificationMO)
|
||||
public class IdentificationMO: NSManagedObject {
|
||||
|
||||
// MARK: - Core Properties
|
||||
|
||||
/// Unique identifier for the identification
|
||||
@NSManaged public var id: UUID
|
||||
|
||||
/// The date when the identification was performed
|
||||
@NSManaged public var date: Date
|
||||
|
||||
/// The raw value string for IdentificationSource enum (onDeviceML, plantNetAPI, userManual)
|
||||
@NSManaged public var source: String
|
||||
|
||||
/// The confidence score from the identification process (0.0 to 1.0)
|
||||
@NSManaged public var confidenceScore: Double
|
||||
|
||||
// MARK: - Optional Properties
|
||||
|
||||
/// Binary image data with external storage for the identification image
|
||||
@NSManaged public var imageData: Data?
|
||||
|
||||
/// The latitude coordinate where the identification was made (optional)
|
||||
/// Stored as NSNumber to support optional Double
|
||||
@NSManaged public var latitude: NSNumber?
|
||||
|
||||
/// The longitude coordinate where the identification was made (optional)
|
||||
/// Stored as NSNumber to support optional Double
|
||||
@NSManaged public var longitude: NSNumber?
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
/// The plant associated with this identification (optional, many-to-one)
|
||||
@NSManaged public var plant: PlantMO?
|
||||
}
|
||||
|
||||
// MARK: - Domain Model Conversion
|
||||
|
||||
extension IdentificationMO {
|
||||
|
||||
/// Converts this managed object to a PlantIdentification domain model.
|
||||
/// - Returns: A PlantIdentification domain entity populated with this managed object's data.
|
||||
func toDomainModel() -> PlantIdentification {
|
||||
let identificationSource = IdentificationSource(rawValue: source) ?? .userManual
|
||||
|
||||
// Get species from associated plant, or use empty string if no plant relationship
|
||||
let species = plant?.scientificName ?? ""
|
||||
|
||||
return PlantIdentification(
|
||||
id: id,
|
||||
species: species,
|
||||
confidence: confidenceScore,
|
||||
source: identificationSource,
|
||||
timestamp: date
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an IdentificationMO managed object from a PlantIdentification domain model.
|
||||
/// - Parameters:
|
||||
/// - identification: The PlantIdentification domain entity to convert.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new IdentificationMO instance populated with the identification's data.
|
||||
static func fromDomainModel(_ identification: PlantIdentification, context: NSManagedObjectContext) -> IdentificationMO {
|
||||
let identificationMO = IdentificationMO(context: context)
|
||||
|
||||
identificationMO.id = identification.id
|
||||
identificationMO.date = identification.timestamp
|
||||
identificationMO.source = identification.source.rawValue
|
||||
identificationMO.confidenceScore = identification.confidence
|
||||
|
||||
return identificationMO
|
||||
}
|
||||
|
||||
/// Creates an IdentificationMO managed object from a PlantIdentification domain model with additional data.
|
||||
/// - Parameters:
|
||||
/// - identification: The PlantIdentification domain entity to convert.
|
||||
/// - imageData: Optional binary image data for the identification.
|
||||
/// - latitude: Optional latitude coordinate where identification was made.
|
||||
/// - longitude: Optional longitude coordinate where identification was made.
|
||||
/// - plant: Optional associated PlantMO relationship.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new IdentificationMO instance populated with all provided data.
|
||||
static func fromDomainModel(
|
||||
_ identification: PlantIdentification,
|
||||
imageData: Data? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
plant: PlantMO? = nil,
|
||||
context: NSManagedObjectContext
|
||||
) -> IdentificationMO {
|
||||
let identificationMO = fromDomainModel(identification, context: context)
|
||||
|
||||
identificationMO.imageData = imageData
|
||||
identificationMO.latitude = latitude.map { NSNumber(value: $0) }
|
||||
identificationMO.longitude = longitude.map { NSNumber(value: $0) }
|
||||
identificationMO.plant = plant
|
||||
|
||||
return identificationMO
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a PlantIdentification domain model.
|
||||
/// - Parameter identification: The PlantIdentification domain entity to update from.
|
||||
func update(from identification: PlantIdentification) {
|
||||
id = identification.id
|
||||
date = identification.timestamp
|
||||
source = identification.source.rawValue
|
||||
confidenceScore = identification.confidence
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a PlantIdentification domain model and additional data.
|
||||
/// - Parameters:
|
||||
/// - identification: The PlantIdentification domain entity to update from.
|
||||
/// - imageData: Optional binary image data for the identification.
|
||||
/// - latitude: Optional latitude coordinate where identification was made.
|
||||
/// - longitude: Optional longitude coordinate where identification was made.
|
||||
func update(
|
||||
from identification: PlantIdentification,
|
||||
imageData: Data? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil
|
||||
) {
|
||||
update(from: identification)
|
||||
|
||||
if let imageData = imageData {
|
||||
self.imageData = imageData
|
||||
}
|
||||
if let latitude = latitude {
|
||||
self.latitude = NSNumber(value: latitude)
|
||||
}
|
||||
if let longitude = longitude {
|
||||
self.longitude = NSNumber(value: longitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension IdentificationMO {
|
||||
|
||||
/// Creates a fetch request for IdentificationMO entities.
|
||||
/// - Returns: A configured NSFetchRequest for IdentificationMO.
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<IdentificationMO> {
|
||||
return NSFetchRequest<IdentificationMO>(entityName: "IdentificationMO")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlantMO Relationship Accessors
|
||||
|
||||
extension PlantMO {
|
||||
|
||||
/// Adds an identification to this plant's identification set.
|
||||
/// - Parameter identification: The IdentificationMO to add.
|
||||
@objc(addIdentificationsObject:)
|
||||
@NSManaged public func addToIdentifications(_ identification: IdentificationMO)
|
||||
|
||||
/// Removes an identification from this plant's identification set.
|
||||
/// - Parameter identification: The IdentificationMO to remove.
|
||||
@objc(removeIdentificationsObject:)
|
||||
@NSManaged public func removeFromIdentifications(_ identification: IdentificationMO)
|
||||
|
||||
/// Adds multiple identifications to this plant's identification set.
|
||||
/// - Parameter identifications: The set of IdentificationMO objects to add.
|
||||
@objc(addIdentifications:)
|
||||
@NSManaged public func addToIdentifications(_ identifications: NSSet)
|
||||
|
||||
/// Removes multiple identifications from this plant's identification set.
|
||||
/// - Parameter identifications: The set of IdentificationMO objects to remove.
|
||||
@objc(removeIdentifications:)
|
||||
@NSManaged public func removeFromIdentifications(_ identifications: NSSet)
|
||||
|
||||
/// Returns the identifications as a typed array.
|
||||
var identificationsArray: [IdentificationMO] {
|
||||
let set = identifications as? Set<IdentificationMO> ?? []
|
||||
return set.sorted { $0.date > $1.date }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantCareInfoMO
|
||||
|
||||
/// Core Data managed object representing cached PlantCareInfo from Trefle API.
|
||||
/// Maps to the PlantCareInfo domain model for persistence.
|
||||
@objc(PlantCareInfoMO)
|
||||
public class PlantCareInfoMO: NSManagedObject {
|
||||
|
||||
// MARK: - Core Properties
|
||||
|
||||
/// Unique identifier for the care info
|
||||
@NSManaged public var id: UUID
|
||||
|
||||
/// The scientific (botanical) name of the plant species
|
||||
@NSManaged public var scientificName: String
|
||||
|
||||
/// The common name of the plant, if available
|
||||
@NSManaged public var commonName: String?
|
||||
|
||||
/// The raw value string for LightRequirement enum
|
||||
@NSManaged public var lightRequirement: String
|
||||
|
||||
/// JSON-encoded WateringSchedule data
|
||||
@NSManaged public var wateringScheduleData: Data?
|
||||
|
||||
/// JSON-encoded TemperatureRange data
|
||||
@NSManaged public var temperatureRangeData: Data?
|
||||
|
||||
/// JSON-encoded FertilizerSchedule data (optional)
|
||||
@NSManaged public var fertilizerScheduleData: Data?
|
||||
|
||||
/// The raw value string for HumidityLevel enum (optional)
|
||||
@NSManaged public var humidity: String?
|
||||
|
||||
/// The raw value string for GrowthRate enum (optional)
|
||||
@NSManaged public var growthRate: String?
|
||||
|
||||
/// JSON-encoded [Season] array (optional)
|
||||
@NSManaged public var bloomingSeasonData: Data?
|
||||
|
||||
/// Additional care notes or special instructions
|
||||
@NSManaged public var additionalNotes: String?
|
||||
|
||||
/// URL to the source of the care information
|
||||
@NSManaged public var sourceURL: URL?
|
||||
|
||||
/// The Trefle API identifier for the plant
|
||||
@NSManaged public var trefleID: Int32
|
||||
|
||||
/// The date when the care info was fetched (for cache expiration)
|
||||
@NSManaged public var fetchedAt: Date
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
/// The plant this care info belongs to (optional, many-to-one)
|
||||
@NSManaged public var plant: PlantMO?
|
||||
}
|
||||
|
||||
// MARK: - Domain Model Conversion
|
||||
|
||||
extension PlantCareInfoMO {
|
||||
|
||||
/// Converts this managed object to a PlantCareInfo domain model.
|
||||
/// - Returns: A PlantCareInfo domain entity populated with this managed object's data,
|
||||
/// or nil if required data cannot be decoded.
|
||||
func toDomainModel() -> PlantCareInfo? {
|
||||
// Decode light requirement
|
||||
guard let light = LightRequirement(rawValue: lightRequirement) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode watering schedule
|
||||
guard let wateringData = wateringScheduleData,
|
||||
let wateringSchedule = decodeJSON(WateringSchedule.self, from: wateringData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode temperature range
|
||||
guard let tempData = temperatureRangeData,
|
||||
let temperatureRange = decodeJSON(TemperatureRange.self, from: tempData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode optional fertilizer schedule
|
||||
let fertilizerSchedule: FertilizerSchedule?
|
||||
if let fertData = fertilizerScheduleData {
|
||||
fertilizerSchedule = decodeJSON(FertilizerSchedule.self, from: fertData)
|
||||
} else {
|
||||
fertilizerSchedule = nil
|
||||
}
|
||||
|
||||
// Decode optional humidity
|
||||
let humidityLevel: HumidityLevel?
|
||||
if let humidityStr = humidity {
|
||||
humidityLevel = HumidityLevel(rawValue: humidityStr)
|
||||
} else {
|
||||
humidityLevel = nil
|
||||
}
|
||||
|
||||
// Decode optional growth rate
|
||||
let growth: GrowthRate?
|
||||
if let growthStr = growthRate {
|
||||
growth = GrowthRate(rawValue: growthStr)
|
||||
} else {
|
||||
growth = nil
|
||||
}
|
||||
|
||||
// Decode optional blooming season
|
||||
let bloomingSeason: [Season]?
|
||||
if let bloomData = bloomingSeasonData {
|
||||
bloomingSeason = decodeJSON([Season].self, from: bloomData)
|
||||
} else {
|
||||
bloomingSeason = nil
|
||||
}
|
||||
|
||||
// Convert trefleID (0 means not set)
|
||||
let trefleIDValue: Int? = trefleID != 0 ? Int(trefleID) : nil
|
||||
|
||||
return PlantCareInfo(
|
||||
id: id,
|
||||
scientificName: scientificName,
|
||||
commonName: commonName,
|
||||
lightRequirement: light,
|
||||
wateringSchedule: wateringSchedule,
|
||||
temperatureRange: temperatureRange,
|
||||
fertilizerSchedule: fertilizerSchedule,
|
||||
humidity: humidityLevel,
|
||||
growthRate: growth,
|
||||
bloomingSeason: bloomingSeason,
|
||||
additionalNotes: additionalNotes,
|
||||
sourceURL: sourceURL,
|
||||
trefleID: trefleIDValue
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a PlantCareInfoMO managed object from a PlantCareInfo domain model.
|
||||
/// - Parameters:
|
||||
/// - careInfo: The PlantCareInfo domain entity to convert.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new PlantCareInfoMO instance populated with the care info's data,
|
||||
/// or nil if encoding fails.
|
||||
static func fromDomainModel(
|
||||
_ careInfo: PlantCareInfo,
|
||||
context: NSManagedObjectContext
|
||||
) -> PlantCareInfoMO? {
|
||||
// Encode required data
|
||||
guard let wateringData = encodeJSON(careInfo.wateringSchedule),
|
||||
let tempData = encodeJSON(careInfo.temperatureRange) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let careInfoMO = PlantCareInfoMO(context: context)
|
||||
|
||||
careInfoMO.id = careInfo.id
|
||||
careInfoMO.scientificName = careInfo.scientificName
|
||||
careInfoMO.commonName = careInfo.commonName
|
||||
careInfoMO.lightRequirement = careInfo.lightRequirement.rawValue
|
||||
careInfoMO.wateringScheduleData = wateringData
|
||||
careInfoMO.temperatureRangeData = tempData
|
||||
careInfoMO.fertilizerScheduleData = careInfo.fertilizerSchedule.flatMap { encodeJSON($0) }
|
||||
careInfoMO.humidity = careInfo.humidity?.rawValue
|
||||
careInfoMO.growthRate = careInfo.growthRate?.rawValue
|
||||
careInfoMO.bloomingSeasonData = careInfo.bloomingSeason.flatMap { encodeJSON($0) }
|
||||
careInfoMO.additionalNotes = careInfo.additionalNotes
|
||||
careInfoMO.sourceURL = careInfo.sourceURL
|
||||
careInfoMO.trefleID = Int32(careInfo.trefleID ?? 0)
|
||||
careInfoMO.fetchedAt = Date()
|
||||
|
||||
return careInfoMO
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a PlantCareInfo domain model.
|
||||
/// - Parameter careInfo: The PlantCareInfo domain entity to update from.
|
||||
/// - Returns: true if update succeeded, false if encoding failed.
|
||||
@discardableResult
|
||||
func update(from careInfo: PlantCareInfo) -> Bool {
|
||||
// Encode required data
|
||||
guard let wateringData = Self.encodeJSON(careInfo.wateringSchedule),
|
||||
let tempData = Self.encodeJSON(careInfo.temperatureRange) else {
|
||||
return false
|
||||
}
|
||||
|
||||
id = careInfo.id
|
||||
scientificName = careInfo.scientificName
|
||||
commonName = careInfo.commonName
|
||||
lightRequirement = careInfo.lightRequirement.rawValue
|
||||
wateringScheduleData = wateringData
|
||||
temperatureRangeData = tempData
|
||||
fertilizerScheduleData = careInfo.fertilizerSchedule.flatMap { Self.encodeJSON($0) }
|
||||
humidity = careInfo.humidity?.rawValue
|
||||
growthRate = careInfo.growthRate?.rawValue
|
||||
bloomingSeasonData = careInfo.bloomingSeason.flatMap { Self.encodeJSON($0) }
|
||||
additionalNotes = careInfo.additionalNotes
|
||||
sourceURL = careInfo.sourceURL
|
||||
trefleID = Int32(careInfo.trefleID ?? 0)
|
||||
fetchedAt = Date()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
print("PlantCareInfoMO: Failed to decode \(type) - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodeJSON<T: Encodable>(_ value: T) -> Data? {
|
||||
do {
|
||||
return try JSONEncoder().encode(value)
|
||||
} catch {
|
||||
print("PlantCareInfoMO: Failed to encode \(type(of: value)) - \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension PlantCareInfoMO {
|
||||
|
||||
/// Creates a fetch request for PlantCareInfoMO entities.
|
||||
/// - Returns: A configured NSFetchRequest for PlantCareInfoMO.
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PlantCareInfoMO> {
|
||||
return NSFetchRequest<PlantCareInfoMO>(entityName: "PlantCareInfoMO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantMO
|
||||
|
||||
/// Core Data managed object representing a Plant entity.
|
||||
/// Maps to the Plant domain model for persistence.
|
||||
@objc(PlantMO)
|
||||
public class PlantMO: NSManagedObject {
|
||||
|
||||
// MARK: - Core Identification Properties
|
||||
|
||||
/// Unique identifier for the plant
|
||||
@NSManaged public var id: UUID
|
||||
|
||||
/// The scientific (Latin) name of the plant
|
||||
@NSManaged public var scientificName: String
|
||||
|
||||
/// Common names for the plant stored as a transformable array
|
||||
@NSManaged public var commonNames: [String]
|
||||
|
||||
/// The botanical family the plant belongs to
|
||||
@NSManaged public var family: String
|
||||
|
||||
/// The genus classification of the plant
|
||||
@NSManaged public var genus: String
|
||||
|
||||
/// URLs to images of the plant (remote/API sources) stored as transformable
|
||||
@NSManaged public var imageURLs: [URL]
|
||||
|
||||
/// Paths to locally cached images on the device stored as transformable
|
||||
@NSManaged public var localImagePaths: [String]
|
||||
|
||||
/// The date when the plant was identified
|
||||
@NSManaged public var dateIdentified: Date
|
||||
|
||||
/// The raw value string for IdentificationSource enum
|
||||
@NSManaged public var identificationSource: String
|
||||
|
||||
// MARK: - Collection & User Properties
|
||||
|
||||
/// The date when the plant was added to the user's collection
|
||||
@NSManaged public var dateAdded: Date?
|
||||
|
||||
/// The confidence score from the identification process (0.0 to 1.0)
|
||||
/// Stored as NSNumber to support optional Double
|
||||
@NSManaged public var confidenceScore: NSNumber?
|
||||
|
||||
/// User-entered notes about this plant
|
||||
@NSManaged public var notes: String?
|
||||
|
||||
/// Whether this plant is marked as a favorite
|
||||
@NSManaged public var isFavorite: Bool
|
||||
|
||||
/// A custom name the user has given to this plant
|
||||
@NSManaged public var customName: String?
|
||||
|
||||
/// Description of where the plant is located
|
||||
@NSManaged public var location: String?
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
/// The care schedule associated with this plant (optional, one-to-one)
|
||||
@NSManaged public var careSchedule: CareScheduleMO?
|
||||
|
||||
/// The identification history for this plant (one-to-many, cascade delete)
|
||||
@NSManaged public var identifications: NSSet?
|
||||
|
||||
/// Cached care information from Trefle API (optional, one-to-one, cascade delete)
|
||||
@NSManaged public var plantCareInfo: PlantCareInfoMO?
|
||||
}
|
||||
|
||||
// MARK: - Domain Model Conversion
|
||||
|
||||
extension PlantMO {
|
||||
|
||||
/// Converts this managed object to a Plant domain model.
|
||||
/// - Returns: A Plant domain entity populated with this managed object's data.
|
||||
func toDomainModel() -> Plant {
|
||||
let source = IdentificationSource(rawValue: identificationSource) ?? .userManual
|
||||
|
||||
return Plant(
|
||||
id: id,
|
||||
scientificName: scientificName,
|
||||
commonNames: commonNames,
|
||||
family: family,
|
||||
genus: genus,
|
||||
imageURLs: imageURLs,
|
||||
dateIdentified: dateIdentified,
|
||||
identificationSource: source,
|
||||
localImagePaths: localImagePaths,
|
||||
dateAdded: dateAdded,
|
||||
confidenceScore: confidenceScore?.doubleValue,
|
||||
notes: notes,
|
||||
isFavorite: isFavorite,
|
||||
customName: customName,
|
||||
location: location
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a PlantMO managed object from a Plant domain model.
|
||||
/// - Parameters:
|
||||
/// - plant: The Plant domain entity to convert.
|
||||
/// - context: The managed object context to create the object in.
|
||||
/// - Returns: A new PlantMO instance populated with the plant's data.
|
||||
static func fromDomainModel(_ plant: Plant, context: NSManagedObjectContext) -> PlantMO {
|
||||
let plantMO = PlantMO(context: context)
|
||||
|
||||
plantMO.id = plant.id
|
||||
plantMO.scientificName = plant.scientificName
|
||||
plantMO.commonNames = plant.commonNames
|
||||
plantMO.family = plant.family
|
||||
plantMO.genus = plant.genus
|
||||
plantMO.imageURLs = plant.imageURLs
|
||||
plantMO.localImagePaths = plant.localImagePaths
|
||||
plantMO.dateIdentified = plant.dateIdentified
|
||||
plantMO.identificationSource = plant.identificationSource.rawValue
|
||||
plantMO.dateAdded = plant.dateAdded
|
||||
plantMO.confidenceScore = plant.confidenceScore.map { NSNumber(value: $0) }
|
||||
plantMO.notes = plant.notes
|
||||
plantMO.isFavorite = plant.isFavorite
|
||||
plantMO.customName = plant.customName
|
||||
plantMO.location = plant.location
|
||||
|
||||
return plantMO
|
||||
}
|
||||
|
||||
/// Updates this managed object with values from a Plant domain model.
|
||||
/// - Parameter plant: The Plant domain entity to update from.
|
||||
func update(from plant: Plant) {
|
||||
id = plant.id
|
||||
scientificName = plant.scientificName
|
||||
commonNames = plant.commonNames
|
||||
family = plant.family
|
||||
genus = plant.genus
|
||||
imageURLs = plant.imageURLs
|
||||
localImagePaths = plant.localImagePaths
|
||||
dateIdentified = plant.dateIdentified
|
||||
identificationSource = plant.identificationSource.rawValue
|
||||
dateAdded = plant.dateAdded
|
||||
confidenceScore = plant.confidenceScore.map { NSNumber(value: $0) }
|
||||
notes = plant.notes
|
||||
isFavorite = plant.isFavorite
|
||||
customName = plant.customName
|
||||
location = plant.location
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Request
|
||||
|
||||
extension PlantMO {
|
||||
|
||||
/// Creates a fetch request for PlantMO entities.
|
||||
/// - Returns: A configured NSFetchRequest for PlantMO.
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<PlantMO> {
|
||||
return NSFetchRequest<PlantMO>(entityName: "PlantMO")
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>PlantGuideModel.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="1.0.0">
|
||||
<entity name="PlantMO" representedClassName="PlantMO" syncable="YES">
|
||||
<attribute name="commonNames" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||
<attribute name="confidenceScore" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
||||
<attribute name="customName" optional="YES" attributeType="String"/>
|
||||
<attribute name="dateAdded" optional="YES" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="dateIdentified" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="family" attributeType="String"/>
|
||||
<attribute name="genus" attributeType="String"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="identificationSource" attributeType="String"/>
|
||||
<attribute name="imageURLs" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[URL]"/>
|
||||
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarType="YES"/>
|
||||
<attribute name="localImagePaths" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
|
||||
<attribute name="location" optional="YES" attributeType="String"/>
|
||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="scientificName" attributeType="String"/>
|
||||
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CareScheduleMO" inverseName="plant" inverseEntity="CareScheduleMO"/>
|
||||
<relationship name="identifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="IdentificationMO" inverseName="plant" inverseEntity="IdentificationMO"/>
|
||||
<relationship name="plantCareInfo" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="PlantCareInfoMO" inverseName="plant" inverseEntity="PlantCareInfoMO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="IdentificationMO" representedClassName="IdentificationMO" syncable="YES">
|
||||
<attribute name="confidenceScore" attributeType="Double" defaultValueString="0.0" usesScalarType="YES"/>
|
||||
<attribute name="date" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="imageData" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
|
||||
<attribute name="latitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
||||
<attribute name="longitude" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarType="NO"/>
|
||||
<attribute name="source" attributeType="String"/>
|
||||
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="identifications" inverseEntity="PlantMO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="CareScheduleMO" representedClassName="CareScheduleMO" syncable="YES">
|
||||
<attribute name="fertilizerSchedule" attributeType="String"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="lightRequirement" attributeType="String"/>
|
||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="temperatureMax" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
||||
<attribute name="temperatureMin" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
||||
<attribute name="wateringSchedule" attributeType="String"/>
|
||||
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="careSchedule" inverseEntity="PlantMO"/>
|
||||
<relationship name="tasks" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="CareTaskMO" inverseName="careSchedule" inverseEntity="CareTaskMO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="CareTaskMO" representedClassName="CareTaskMO" syncable="YES">
|
||||
<attribute name="completedDate" optional="YES" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="notes" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="plantID" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="scheduledDate" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="type" attributeType="String"/>
|
||||
<relationship name="careSchedule" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CareScheduleMO" inverseName="tasks" inverseEntity="CareScheduleMO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="PlantCareInfoMO" representedClassName="PlantCareInfoMO" syncable="YES">
|
||||
<attribute name="additionalNotes" optional="YES" attributeType="String"/>
|
||||
<attribute name="bloomingSeasonData" optional="YES" attributeType="Transformable" valueTransformerName="SeasonArrayTransformer"/>
|
||||
<attribute name="commonName" optional="YES" attributeType="String"/>
|
||||
<attribute name="fertilizerScheduleData" optional="YES" attributeType="Transformable" valueTransformerName="FertilizerScheduleTransformer"/>
|
||||
<attribute name="fetchedAt" attributeType="Date" usesScalarType="NO"/>
|
||||
<attribute name="growthRate" optional="YES" attributeType="String"/>
|
||||
<attribute name="humidity" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="UUID" usesScalarType="NO"/>
|
||||
<attribute name="lightRequirement" attributeType="String"/>
|
||||
<attribute name="scientificName" attributeType="String"/>
|
||||
<attribute name="sourceURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="temperatureRangeData" attributeType="Transformable" valueTransformerName="TemperatureRangeTransformer"/>
|
||||
<attribute name="trefleID" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarType="YES"/>
|
||||
<attribute name="wateringScheduleData" attributeType="Transformable" valueTransformerName="WateringScheduleTransformer"/>
|
||||
<relationship name="plant" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PlantMO" inverseName="plantCareInfo" inverseEntity="PlantMO"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
</model>
|
||||
@@ -0,0 +1,201 @@
|
||||
//
|
||||
// FilterPreferencesStorage.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - FilterPreferencesStorageProtocol
|
||||
|
||||
/// Protocol for persisting filter preferences.
|
||||
protocol FilterPreferencesStorageProtocol: Sendable {
|
||||
/// Saves the current filter configuration.
|
||||
/// - Parameter filter: The filter to save.
|
||||
func saveFilter(_ filter: PlantFilter)
|
||||
|
||||
/// Loads the saved filter configuration.
|
||||
/// - Returns: The saved filter, or `.default` if none exists.
|
||||
func loadFilter() -> PlantFilter
|
||||
|
||||
/// Clears all saved filter preferences.
|
||||
func clearFilter()
|
||||
|
||||
/// Saves the current view mode preference.
|
||||
/// - Parameter viewMode: The view mode to save.
|
||||
func saveViewMode(_ viewMode: ViewMode)
|
||||
|
||||
/// Loads the saved view mode preference.
|
||||
/// - Returns: The saved view mode, or `.grid` if none exists.
|
||||
func loadViewMode() -> ViewMode
|
||||
}
|
||||
|
||||
// MARK: - FilterPreferencesStorage
|
||||
|
||||
/// Service for persisting filter and view mode preferences to UserDefaults.
|
||||
///
|
||||
/// This service allows users to maintain their collection view settings
|
||||
/// across app sessions, providing a consistent experience.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let storage = FilterPreferencesStorage.shared
|
||||
/// storage.saveFilter(myFilter)
|
||||
/// let savedFilter = storage.loadFilter()
|
||||
/// ```
|
||||
final class FilterPreferencesStorage: FilterPreferencesStorageProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
/// Shared instance for app-wide use
|
||||
static let shared = FilterPreferencesStorage()
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Keys {
|
||||
static let filterSortBy = "PlantGuide.Filter.SortBy"
|
||||
static let filterSortAscending = "PlantGuide.Filter.SortAscending"
|
||||
static let filterFamilies = "PlantGuide.Filter.Families"
|
||||
static let filterLightRequirements = "PlantGuide.Filter.LightRequirements"
|
||||
static let filterIsFavorite = "PlantGuide.Filter.IsFavorite"
|
||||
static let filterIdentificationSource = "PlantGuide.Filter.IdentificationSource"
|
||||
static let viewMode = "PlantGuide.ViewMode"
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let userDefaults: UserDefaults
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new FilterPreferencesStorage instance.
|
||||
/// - Parameter userDefaults: The UserDefaults instance to use. Defaults to `.standard`.
|
||||
init(userDefaults: UserDefaults = .standard) {
|
||||
self.userDefaults = userDefaults
|
||||
}
|
||||
|
||||
// MARK: - FilterPreferencesStorageProtocol
|
||||
|
||||
func saveFilter(_ filter: PlantFilter) {
|
||||
// Save sortBy
|
||||
userDefaults.set(filter.sortBy.rawValue, forKey: Keys.filterSortBy)
|
||||
|
||||
// Save sortAscending
|
||||
userDefaults.set(filter.sortAscending, forKey: Keys.filterSortAscending)
|
||||
|
||||
// Save families (as array of strings)
|
||||
if let families = filter.families {
|
||||
userDefaults.set(Array(families), forKey: Keys.filterFamilies)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: Keys.filterFamilies)
|
||||
}
|
||||
|
||||
// Save light requirements (as array of raw values)
|
||||
if let lightRequirements = filter.lightRequirements {
|
||||
let rawValues = lightRequirements.map { $0.rawValue }
|
||||
userDefaults.set(rawValues, forKey: Keys.filterLightRequirements)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: Keys.filterLightRequirements)
|
||||
}
|
||||
|
||||
// Save isFavorite
|
||||
if let isFavorite = filter.isFavorite {
|
||||
userDefaults.set(isFavorite, forKey: Keys.filterIsFavorite)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: Keys.filterIsFavorite)
|
||||
}
|
||||
|
||||
// Save identification source
|
||||
if let source = filter.identificationSource {
|
||||
userDefaults.set(source.rawValue, forKey: Keys.filterIdentificationSource)
|
||||
} else {
|
||||
userDefaults.removeObject(forKey: Keys.filterIdentificationSource)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFilter() -> PlantFilter {
|
||||
var filter = PlantFilter()
|
||||
|
||||
// Load sortBy
|
||||
if let sortByRaw = userDefaults.string(forKey: Keys.filterSortBy),
|
||||
let sortBy = PlantFilter.SortOption(rawValue: sortByRaw) {
|
||||
filter.sortBy = sortBy
|
||||
}
|
||||
|
||||
// Load sortAscending
|
||||
if userDefaults.object(forKey: Keys.filterSortAscending) != nil {
|
||||
filter.sortAscending = userDefaults.bool(forKey: Keys.filterSortAscending)
|
||||
}
|
||||
|
||||
// Load families
|
||||
if let familiesArray = userDefaults.stringArray(forKey: Keys.filterFamilies) {
|
||||
filter.families = Set(familiesArray)
|
||||
}
|
||||
|
||||
// Load light requirements
|
||||
if let lightRawValues = userDefaults.stringArray(forKey: Keys.filterLightRequirements) {
|
||||
let lightRequirements = lightRawValues.compactMap { LightRequirement(rawValue: $0) }
|
||||
if !lightRequirements.isEmpty {
|
||||
filter.lightRequirements = Set(lightRequirements)
|
||||
}
|
||||
}
|
||||
|
||||
// Load isFavorite
|
||||
if userDefaults.object(forKey: Keys.filterIsFavorite) != nil {
|
||||
filter.isFavorite = userDefaults.bool(forKey: Keys.filterIsFavorite)
|
||||
}
|
||||
|
||||
// Load identification source
|
||||
if let sourceRaw = userDefaults.string(forKey: Keys.filterIdentificationSource),
|
||||
let source = IdentificationSource(rawValue: sourceRaw) {
|
||||
filter.identificationSource = source
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func clearFilter() {
|
||||
userDefaults.removeObject(forKey: Keys.filterSortBy)
|
||||
userDefaults.removeObject(forKey: Keys.filterSortAscending)
|
||||
userDefaults.removeObject(forKey: Keys.filterFamilies)
|
||||
userDefaults.removeObject(forKey: Keys.filterLightRequirements)
|
||||
userDefaults.removeObject(forKey: Keys.filterIsFavorite)
|
||||
userDefaults.removeObject(forKey: Keys.filterIdentificationSource)
|
||||
}
|
||||
|
||||
func saveViewMode(_ viewMode: ViewMode) {
|
||||
userDefaults.set(viewMode.rawValue, forKey: Keys.viewMode)
|
||||
}
|
||||
|
||||
func loadViewMode() -> ViewMode {
|
||||
guard let rawValue = userDefaults.string(forKey: Keys.viewMode),
|
||||
let viewMode = ViewMode(rawValue: rawValue) else {
|
||||
return .grid
|
||||
}
|
||||
return viewMode
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CollectionViewModel Integration
|
||||
|
||||
extension CollectionViewModel {
|
||||
|
||||
/// Loads persisted filter and view mode preferences.
|
||||
/// Call this method when initializing the view model to restore user preferences.
|
||||
func loadPersistedPreferences() {
|
||||
let storage = FilterPreferencesStorage.shared
|
||||
currentFilter = storage.loadFilter()
|
||||
viewMode = storage.loadViewMode()
|
||||
}
|
||||
|
||||
/// Saves the current filter configuration to persistent storage.
|
||||
func saveFilterPreferences() {
|
||||
FilterPreferencesStorage.shared.saveFilter(currentFilter)
|
||||
}
|
||||
|
||||
/// Saves the current view mode to persistent storage.
|
||||
func saveViewModePreference() {
|
||||
FilterPreferencesStorage.shared.saveViewMode(viewMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
/// Container for the complete local plant database loaded from JSON.
|
||||
struct LocalPlantDatabase: Codable, Sendable {
|
||||
|
||||
/// Date the database was last updated
|
||||
let sourceDate: String
|
||||
|
||||
/// Total number of plants in the database
|
||||
let totalPlants: Int
|
||||
|
||||
/// Sources used to compile the database
|
||||
let sources: [String]
|
||||
|
||||
/// All plant entries in the database
|
||||
let plants: [LocalPlantEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sourceDate = "source_date"
|
||||
case totalPlants = "total_plants"
|
||||
case sources
|
||||
case plants
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a single plant entry from the local houseplants database.
|
||||
/// This is a lightweight model for browsing and searching the local plant catalog.
|
||||
struct LocalPlantEntry: Codable, Identifiable, Sendable, Hashable {
|
||||
|
||||
/// The scientific (Latin) name of the plant
|
||||
let scientificName: String
|
||||
|
||||
/// Common names for the plant
|
||||
let commonNames: [String]
|
||||
|
||||
/// The botanical family the plant belongs to
|
||||
let family: String
|
||||
|
||||
/// The category/type of houseplant
|
||||
let category: PlantCategory
|
||||
|
||||
/// Unique identifier based on scientific name
|
||||
var id: String { scientificName }
|
||||
|
||||
/// The primary common name, if available
|
||||
var primaryCommonName: String? {
|
||||
commonNames.first
|
||||
}
|
||||
|
||||
/// Display name prioritizing common name over scientific
|
||||
var displayName: String {
|
||||
primaryCommonName ?? scientificName
|
||||
}
|
||||
|
||||
/// Extracts the genus from the scientific name (first word)
|
||||
var genus: String {
|
||||
scientificName.components(separatedBy: " ").first ?? scientificName
|
||||
}
|
||||
|
||||
/// Checks if this is a cultivar (has quotes in name)
|
||||
var isCultivar: Bool {
|
||||
scientificName.contains("'")
|
||||
}
|
||||
|
||||
/// Returns the base species name without cultivar designation
|
||||
var baseSpeciesName: String {
|
||||
guard isCultivar else { return scientificName }
|
||||
// Remove cultivar name in quotes and trim
|
||||
let parts = scientificName.components(separatedBy: "'")
|
||||
return parts.first?.trimmingCharacters(in: .whitespaces) ?? scientificName
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scientificName = "scientific_name"
|
||||
case commonNames = "common_names"
|
||||
case family
|
||||
case category
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// Categories for houseplants matching the local database schema.
|
||||
/// Each category represents a distinct type of indoor plant.
|
||||
enum PlantCategory: String, Codable, CaseIterable, Sendable {
|
||||
case airPlant = "Air Plant"
|
||||
case bromeliad = "Bromeliad"
|
||||
case cactus = "Cactus"
|
||||
case fern = "Fern"
|
||||
case floweringHouseplant = "Flowering Houseplant"
|
||||
case herb = "Herb"
|
||||
case orchid = "Orchid"
|
||||
case palm = "Palm"
|
||||
case succulent = "Succulent"
|
||||
case trailingClimbing = "Trailing/Climbing"
|
||||
case tropicalFoliage = "Tropical Foliage"
|
||||
|
||||
/// SF Symbol name for the category icon
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .airPlant: return "leaf.arrow.triangle.circlepath"
|
||||
case .bromeliad: return "sparkles"
|
||||
case .cactus: return "sun.max.fill"
|
||||
case .fern: return "leaf.fill"
|
||||
case .floweringHouseplant: return "camera.macro"
|
||||
case .herb: return "leaf.circle"
|
||||
case .orchid: return "camera.macro.circle"
|
||||
case .palm: return "tree.fill"
|
||||
case .succulent: return "drop.fill"
|
||||
case .trailingClimbing: return "arrow.up.right"
|
||||
case .tropicalFoliage: return "leaf.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name for the category
|
||||
var displayName: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors that can occur when working with the local plant database.
|
||||
enum PlantDatabaseError: LocalizedError, Sendable {
|
||||
case fileNotFound
|
||||
case decodingFailed(Error)
|
||||
case notLoaded
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .fileNotFound:
|
||||
return "Plant database file not found in app bundle."
|
||||
case .decodingFailed(let error):
|
||||
return "Failed to decode plant database: \(error.localizedDescription)"
|
||||
case .notLoaded:
|
||||
return "Plant database has not been loaded yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the plant database service interface.
|
||||
protocol PlantDatabaseServiceProtocol: Sendable {
|
||||
/// Loads the database from bundle. Call once at startup or first access.
|
||||
func loadDatabase() async throws
|
||||
|
||||
/// Searches plants by scientific name (case-insensitive, partial match)
|
||||
func searchByScientificName(_ query: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Searches plants by common name (case-insensitive, partial match)
|
||||
func searchByCommonName(_ query: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Searches plants by any name field (scientific or common)
|
||||
func searchAll(_ query: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Returns all plants in a specific botanical family
|
||||
func getByFamily(_ family: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Returns all plants in a specific category
|
||||
func getByCategory(_ category: PlantCategory) async -> [LocalPlantEntry]
|
||||
|
||||
/// Returns a specific plant by exact scientific name match
|
||||
func getPlant(scientificName: String) async -> LocalPlantEntry?
|
||||
|
||||
/// All available categories in the database
|
||||
var allCategories: [PlantCategory] { get async }
|
||||
|
||||
/// All unique botanical families in the database
|
||||
var allFamilies: [String] { get async }
|
||||
|
||||
/// Total number of plants in the database
|
||||
var plantCount: Int { get async }
|
||||
|
||||
/// Whether the database has been loaded
|
||||
var isLoaded: Bool { get async }
|
||||
}
|
||||
|
||||
/// Thread-safe actor that manages the local plant database.
|
||||
/// Provides efficient search and filtering capabilities.
|
||||
actor PlantDatabaseService: PlantDatabaseServiceProtocol {
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var database: LocalPlantDatabase?
|
||||
private var scientificNameIndex: [String: LocalPlantEntry] = [:]
|
||||
private var familyIndex: [String: [LocalPlantEntry]] = [:]
|
||||
private var categoryIndex: [PlantCategory: [LocalPlantEntry]] = [:]
|
||||
private var genusIndex: [String: [LocalPlantEntry]] = [:]
|
||||
|
||||
// MARK: - Public Properties
|
||||
|
||||
var allCategories: [PlantCategory] {
|
||||
PlantCategory.allCases
|
||||
}
|
||||
|
||||
var allFamilies: [String] {
|
||||
Array(familyIndex.keys).sorted()
|
||||
}
|
||||
|
||||
var plantCount: Int {
|
||||
database?.plants.count ?? 0
|
||||
}
|
||||
|
||||
var isLoaded: Bool {
|
||||
database != nil
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func loadDatabase() async throws {
|
||||
guard database == nil else { return }
|
||||
|
||||
guard let url = Bundle.main.url(forResource: "houseplants_list", withExtension: "json") else {
|
||||
throw PlantDatabaseError.fileNotFound
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
let loadedDatabase = try decoder.decode(LocalPlantDatabase.self, from: data)
|
||||
self.database = loadedDatabase
|
||||
buildIndices(from: loadedDatabase.plants)
|
||||
} catch let error as DecodingError {
|
||||
throw PlantDatabaseError.decodingFailed(error)
|
||||
} catch {
|
||||
throw PlantDatabaseError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Methods
|
||||
|
||||
func searchByScientificName(_ query: String) async -> [LocalPlantEntry] {
|
||||
guard let plants = database?.plants else { return [] }
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
if lowercaseQuery.isEmpty { return [] }
|
||||
|
||||
return plants.filter { plant in
|
||||
plant.scientificName.lowercased().contains(lowercaseQuery)
|
||||
}.sorted { lhs, rhs in
|
||||
// Prioritize exact prefix matches
|
||||
let lhsStartsWith = lhs.scientificName.lowercased().hasPrefix(lowercaseQuery)
|
||||
let rhsStartsWith = rhs.scientificName.lowercased().hasPrefix(lowercaseQuery)
|
||||
if lhsStartsWith != rhsStartsWith {
|
||||
return lhsStartsWith
|
||||
}
|
||||
return lhs.scientificName < rhs.scientificName
|
||||
}
|
||||
}
|
||||
|
||||
func searchByCommonName(_ query: String) async -> [LocalPlantEntry] {
|
||||
guard let plants = database?.plants else { return [] }
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
if lowercaseQuery.isEmpty { return [] }
|
||||
|
||||
return plants.filter { plant in
|
||||
plant.commonNames.contains { name in
|
||||
name.lowercased().contains(lowercaseQuery)
|
||||
}
|
||||
}.sorted { lhs, rhs in
|
||||
// Prioritize exact prefix matches in primary common name
|
||||
let lhsStartsWith = lhs.primaryCommonName?.lowercased().hasPrefix(lowercaseQuery) ?? false
|
||||
let rhsStartsWith = rhs.primaryCommonName?.lowercased().hasPrefix(lowercaseQuery) ?? false
|
||||
if lhsStartsWith != rhsStartsWith {
|
||||
return lhsStartsWith
|
||||
}
|
||||
return (lhs.primaryCommonName ?? "") < (rhs.primaryCommonName ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
func searchAll(_ query: String) async -> [LocalPlantEntry] {
|
||||
guard let plants = database?.plants else { return [] }
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
if lowercaseQuery.isEmpty { return [] }
|
||||
|
||||
var results: [(plant: LocalPlantEntry, score: Int)] = []
|
||||
|
||||
for plant in plants {
|
||||
var score = 0
|
||||
|
||||
// Check scientific name
|
||||
let scientificLower = plant.scientificName.lowercased()
|
||||
if scientificLower == lowercaseQuery {
|
||||
score = 100 // Exact match
|
||||
} else if scientificLower.hasPrefix(lowercaseQuery) {
|
||||
score = 80 // Prefix match
|
||||
} else if scientificLower.contains(lowercaseQuery) {
|
||||
score = 60 // Contains match
|
||||
}
|
||||
|
||||
// Check common names
|
||||
for commonName in plant.commonNames {
|
||||
let commonLower = commonName.lowercased()
|
||||
if commonLower == lowercaseQuery {
|
||||
score = max(score, 95)
|
||||
} else if commonLower.hasPrefix(lowercaseQuery) {
|
||||
score = max(score, 75)
|
||||
} else if commonLower.contains(lowercaseQuery) {
|
||||
score = max(score, 55)
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0 {
|
||||
results.append((plant, score))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sorted { $0.score > $1.score }
|
||||
.map { $0.plant }
|
||||
}
|
||||
|
||||
func getByFamily(_ family: String) async -> [LocalPlantEntry] {
|
||||
familyIndex[family] ?? []
|
||||
}
|
||||
|
||||
func getByCategory(_ category: PlantCategory) async -> [LocalPlantEntry] {
|
||||
categoryIndex[category] ?? []
|
||||
}
|
||||
|
||||
func getPlant(scientificName: String) async -> LocalPlantEntry? {
|
||||
scientificNameIndex[scientificName]
|
||||
}
|
||||
|
||||
/// Returns plants from the same genus as the given scientific name
|
||||
func getRelatedSpecies(for scientificName: String) async -> [LocalPlantEntry] {
|
||||
let genus = scientificName.components(separatedBy: " ").first ?? ""
|
||||
guard !genus.isEmpty else { return [] }
|
||||
return genusIndex[genus]?.filter { $0.scientificName != scientificName } ?? []
|
||||
}
|
||||
|
||||
/// Performs fuzzy search with typo tolerance using Levenshtein-like matching
|
||||
func fuzzySearch(_ query: String, maxResults: Int = 10) async -> [LocalPlantEntry] {
|
||||
guard let plants = database?.plants else { return [] }
|
||||
let lowercaseQuery = query.lowercased()
|
||||
|
||||
if lowercaseQuery.isEmpty { return [] }
|
||||
|
||||
var results: [(plant: LocalPlantEntry, distance: Int)] = []
|
||||
|
||||
for plant in plants {
|
||||
// Check scientific name
|
||||
let scientificDistance = levenshteinDistance(
|
||||
lowercaseQuery,
|
||||
plant.scientificName.lowercased().prefix(lowercaseQuery.count + 3).lowercased()
|
||||
)
|
||||
|
||||
var bestDistance = scientificDistance
|
||||
|
||||
// Check common names
|
||||
for commonName in plant.commonNames {
|
||||
let commonDistance = levenshteinDistance(
|
||||
lowercaseQuery,
|
||||
commonName.lowercased().prefix(lowercaseQuery.count + 3).lowercased()
|
||||
)
|
||||
bestDistance = min(bestDistance, commonDistance)
|
||||
}
|
||||
|
||||
// Only include if reasonably close (allow ~30% errors)
|
||||
let maxAllowedDistance = max(2, lowercaseQuery.count / 3)
|
||||
if bestDistance <= maxAllowedDistance {
|
||||
results.append((plant, bestDistance))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sorted { $0.distance < $1.distance }
|
||||
.prefix(maxResults)
|
||||
.map { $0.plant }
|
||||
}
|
||||
|
||||
/// Returns all plants, sorted alphabetically by scientific name
|
||||
func getAllPlants() async -> [LocalPlantEntry] {
|
||||
database?.plants.sorted { $0.scientificName < $1.scientificName } ?? []
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func buildIndices(from plants: [LocalPlantEntry]) {
|
||||
scientificNameIndex = Dictionary(uniqueKeysWithValues: plants.map { ($0.scientificName, $0) })
|
||||
|
||||
// Build family index
|
||||
var familyDict: [String: [LocalPlantEntry]] = [:]
|
||||
for plant in plants {
|
||||
familyDict[plant.family, default: []].append(plant)
|
||||
}
|
||||
familyIndex = familyDict
|
||||
|
||||
// Build category index
|
||||
var categoryDict: [PlantCategory: [LocalPlantEntry]] = [:]
|
||||
for plant in plants {
|
||||
categoryDict[plant.category, default: []].append(plant)
|
||||
}
|
||||
categoryIndex = categoryDict
|
||||
|
||||
// Build genus index
|
||||
var genusDict: [String: [LocalPlantEntry]] = [:]
|
||||
for plant in plants {
|
||||
genusDict[plant.genus, default: []].append(plant)
|
||||
}
|
||||
genusIndex = genusDict
|
||||
}
|
||||
|
||||
/// Simple Levenshtein distance implementation for fuzzy matching
|
||||
private func levenshteinDistance(_ s1: String, _ s2: String) -> Int {
|
||||
let s1Array = Array(s1)
|
||||
let s2Array = Array(s2)
|
||||
let m = s1Array.count
|
||||
let n = s2Array.count
|
||||
|
||||
if m == 0 { return n }
|
||||
if n == 0 { return m }
|
||||
|
||||
var matrix = [[Int]](repeating: [Int](repeating: 0, count: n + 1), count: m + 1)
|
||||
|
||||
for i in 0...m { matrix[i][0] = i }
|
||||
for j in 0...n { matrix[0][j] = j }
|
||||
|
||||
for i in 1...m {
|
||||
for j in 1...n {
|
||||
let cost = s1Array[i - 1] == s2Array[j - 1] ? 0 : 1
|
||||
matrix[i][j] = min(
|
||||
matrix[i - 1][j] + 1, // deletion
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[m][n]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// Endpoint.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// HTTP methods supported by the network service.
|
||||
public enum HTTPMethod: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case put = "PUT"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
/// Cache policy options for network requests.
|
||||
public enum EndpointCachePolicy: Sendable {
|
||||
/// Use protocol cache policy (default HTTP behavior)
|
||||
case useProtocolCachePolicy
|
||||
/// Return cached data if available, otherwise load from network
|
||||
case returnCacheDataElseLoad
|
||||
/// Always reload from network, ignoring cache
|
||||
case reloadIgnoringLocalCacheData
|
||||
/// Return cached data if available, don't load from network
|
||||
case returnCacheDataDontLoad
|
||||
|
||||
var urlRequestCachePolicy: URLRequest.CachePolicy {
|
||||
switch self {
|
||||
case .useProtocolCachePolicy:
|
||||
return .useProtocolCachePolicy
|
||||
case .returnCacheDataElseLoad:
|
||||
return .returnCacheDataElseLoad
|
||||
case .reloadIgnoringLocalCacheData:
|
||||
return .reloadIgnoringLocalCacheData
|
||||
case .returnCacheDataDontLoad:
|
||||
return .returnCacheDataDontLoad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an API endpoint configuration for network requests.
|
||||
public struct Endpoint: Sendable {
|
||||
/// The base URL for the API (e.g., "https://api.plantguide.com").
|
||||
public let baseURL: URL
|
||||
|
||||
/// The path component of the URL (e.g., "/v1/plants/identify").
|
||||
public let path: String
|
||||
|
||||
/// The HTTP method to use for the request.
|
||||
public let method: HTTPMethod
|
||||
|
||||
/// Optional HTTP headers to include in the request.
|
||||
public let headers: [String: String]?
|
||||
|
||||
/// Optional query parameters to append to the URL.
|
||||
public let queryItems: [URLQueryItem]?
|
||||
|
||||
/// Optional body data for POST/PUT requests.
|
||||
public let body: Data?
|
||||
|
||||
/// Cache policy for this request. Defaults to protocol cache policy.
|
||||
public let cachePolicy: EndpointCachePolicy
|
||||
|
||||
/// Creates a new endpoint configuration.
|
||||
/// - Parameters:
|
||||
/// - baseURL: The base URL for the API.
|
||||
/// - path: The path component of the URL.
|
||||
/// - method: The HTTP method to use (defaults to GET).
|
||||
/// - headers: Optional HTTP headers.
|
||||
/// - queryItems: Optional query parameters.
|
||||
/// - body: Optional request body data.
|
||||
/// - cachePolicy: Cache policy for the request (defaults to protocol cache policy).
|
||||
public init(
|
||||
baseURL: URL,
|
||||
path: String,
|
||||
method: HTTPMethod = .get,
|
||||
headers: [String: String]? = nil,
|
||||
queryItems: [URLQueryItem]? = nil,
|
||||
body: Data? = nil,
|
||||
cachePolicy: EndpointCachePolicy = .useProtocolCachePolicy
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.path = path
|
||||
self.method = method
|
||||
self.headers = headers
|
||||
self.queryItems = queryItems
|
||||
self.body = body
|
||||
self.cachePolicy = cachePolicy
|
||||
}
|
||||
|
||||
/// Constructs the full URL from the base URL, path, and query items.
|
||||
/// - Returns: The fully constructed URL, or `nil` if construction fails.
|
||||
public var url: URL? {
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true)
|
||||
|
||||
// Append path, ensuring proper slash handling
|
||||
let basePath = components?.path ?? ""
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
components?.path = basePath + normalizedPath
|
||||
|
||||
// Add query items if present
|
||||
if let queryItems, !queryItems.isEmpty {
|
||||
// Merge with any existing query items
|
||||
var existingItems = components?.queryItems ?? []
|
||||
existingItems.append(contentsOf: queryItems)
|
||||
components?.queryItems = existingItems
|
||||
}
|
||||
|
||||
return components?.url
|
||||
}
|
||||
|
||||
/// Creates a URLRequest from this endpoint configuration.
|
||||
/// - Parameter timeoutInterval: The timeout interval for the request.
|
||||
/// - Returns: A configured URLRequest, or `nil` if the URL is invalid.
|
||||
public func urlRequest(timeoutInterval: TimeInterval = 30) -> URLRequest? {
|
||||
guard let url else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.timeoutInterval = timeoutInterval
|
||||
request.cachePolicy = cachePolicy.urlRequestCachePolicy
|
||||
|
||||
// Set default headers
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
// Apply custom headers
|
||||
headers?.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Set body if present
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
// Set Content-Type if not already specified
|
||||
if request.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
extension Endpoint {
|
||||
/// Creates an endpoint with a JSON-encodable body.
|
||||
/// - Parameters:
|
||||
/// - baseURL: The base URL for the API.
|
||||
/// - path: The path component of the URL.
|
||||
/// - method: The HTTP method to use.
|
||||
/// - headers: Optional HTTP headers.
|
||||
/// - queryItems: Optional query parameters.
|
||||
/// - jsonBody: An encodable object to serialize as the request body.
|
||||
/// - encoder: The JSON encoder to use (defaults to a new instance).
|
||||
/// - cachePolicy: Cache policy for the request (defaults to protocol cache policy).
|
||||
/// - Returns: A new endpoint, or `nil` if encoding fails.
|
||||
public static func withJSON<T: Encodable>(
|
||||
baseURL: URL,
|
||||
path: String,
|
||||
method: HTTPMethod = .post,
|
||||
headers: [String: String]? = nil,
|
||||
queryItems: [URLQueryItem]? = nil,
|
||||
jsonBody: T,
|
||||
encoder: JSONEncoder = JSONEncoder(),
|
||||
cachePolicy: EndpointCachePolicy = .useProtocolCachePolicy
|
||||
) -> Endpoint? {
|
||||
guard let body = try? encoder.encode(jsonBody) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allHeaders = headers ?? [:]
|
||||
allHeaders["Content-Type"] = "application/json"
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: path,
|
||||
method: method,
|
||||
headers: allHeaders,
|
||||
queryItems: queryItems,
|
||||
body: body,
|
||||
cachePolicy: cachePolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
|
||||
extension Endpoint: CustomStringConvertible {
|
||||
public var description: String {
|
||||
"\(method.rawValue) \(url?.absoluteString ?? "invalid URL")"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// NetworkError.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents network-related errors that can occur during API requests.
|
||||
public enum NetworkError: Error, Sendable {
|
||||
/// The URL could not be constructed from the endpoint configuration.
|
||||
case invalidURL
|
||||
|
||||
/// The network request failed with an underlying error.
|
||||
case requestFailed(Error)
|
||||
|
||||
/// The server response was not a valid HTTP response.
|
||||
case invalidResponse
|
||||
|
||||
/// Failed to decode the response data into the expected type.
|
||||
case decodingFailed(Error)
|
||||
|
||||
/// The server returned an error status code.
|
||||
case serverError(statusCode: Int)
|
||||
|
||||
/// The response contained no data when data was expected.
|
||||
case noData
|
||||
|
||||
/// The request requires authentication (401).
|
||||
case unauthorized
|
||||
|
||||
/// The request was rate limited by the server (429).
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
extension NetworkError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "The request URL is invalid. Please try again later."
|
||||
case .requestFailed(let error):
|
||||
return "Network request failed: \(error.localizedDescription)"
|
||||
case .invalidResponse:
|
||||
return "Received an invalid response from the server."
|
||||
case .decodingFailed(let error):
|
||||
return "Failed to process server response: \(error.localizedDescription)"
|
||||
case .serverError(let statusCode):
|
||||
return "Server error occurred (code: \(statusCode)). Please try again later."
|
||||
case .noData:
|
||||
return "No data received from the server."
|
||||
case .unauthorized:
|
||||
return "Authentication required. Please sign in and try again."
|
||||
case .rateLimited:
|
||||
return "Too many requests. Please wait a moment and try again."
|
||||
}
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "The endpoint configuration produced an invalid URL."
|
||||
case .requestFailed(let error):
|
||||
return "Underlying error: \(error.localizedDescription)"
|
||||
case .invalidResponse:
|
||||
return "The response was not a valid HTTP response."
|
||||
case .decodingFailed(let error):
|
||||
return "Decoding error: \(error.localizedDescription)"
|
||||
case .serverError(let statusCode):
|
||||
return "HTTP status code: \(statusCode)"
|
||||
case .noData:
|
||||
return "The response body was empty."
|
||||
case .unauthorized:
|
||||
return "HTTP 401 Unauthorized"
|
||||
case .rateLimited:
|
||||
return "HTTP 429 Too Many Requests"
|
||||
}
|
||||
}
|
||||
|
||||
public var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "Check the endpoint configuration."
|
||||
case .requestFailed:
|
||||
return "Check your internet connection and try again."
|
||||
case .invalidResponse:
|
||||
return "The server may be experiencing issues. Try again later."
|
||||
case .decodingFailed:
|
||||
return "The app may need to be updated to handle the latest API response."
|
||||
case .serverError(let statusCode):
|
||||
if (500...599).contains(statusCode) {
|
||||
return "The server is experiencing issues. Please try again later."
|
||||
}
|
||||
return "Please contact support if this issue persists."
|
||||
case .noData:
|
||||
return "Try the request again."
|
||||
case .unauthorized:
|
||||
return "Sign in to your account and retry the request."
|
||||
case .rateLimited:
|
||||
return "Wait a few seconds before making another request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
extension NetworkError: Equatable {
|
||||
public static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.invalidURL, .invalidURL):
|
||||
return true
|
||||
case (.requestFailed(let lhsError), .requestFailed(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
case (.invalidResponse, .invalidResponse):
|
||||
return true
|
||||
case (.decodingFailed(let lhsError), .decodingFailed(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
case (.serverError(let lhsCode), .serverError(let rhsCode)):
|
||||
return lhsCode == rhsCode
|
||||
case (.noData, .noData):
|
||||
return true
|
||||
case (.unauthorized, .unauthorized):
|
||||
return true
|
||||
case (.rateLimited, .rateLimited):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//
|
||||
// NetworkService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
/// A production-ready network service for making API requests.
|
||||
///
|
||||
/// This service provides:
|
||||
/// - Async/await based API using URLSession
|
||||
/// - Configurable timeout
|
||||
/// - Request/response logging in debug builds
|
||||
/// - Proper error handling with descriptive errors
|
||||
/// - Multipart upload support for plant image identification
|
||||
public final class NetworkService: NetworkServiceProtocol, @unchecked Sendable {
|
||||
// MARK: - Properties
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let timeoutInterval: TimeInterval
|
||||
private let logger = Logger(subsystem: "com.plantguide.network", category: "NetworkService")
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new network service instance.
|
||||
/// - Parameters:
|
||||
/// - session: The URLSession to use for requests (defaults to shared session).
|
||||
/// - decoder: The JSON decoder for parsing responses (defaults to a configured instance).
|
||||
/// - timeoutInterval: The timeout interval for requests in seconds (defaults to 30).
|
||||
public init(
|
||||
session: URLSession = .shared,
|
||||
decoder: JSONDecoder = JSONDecoder(),
|
||||
timeoutInterval: TimeInterval = 30
|
||||
) {
|
||||
self.session = session
|
||||
self.decoder = decoder
|
||||
self.timeoutInterval = timeoutInterval
|
||||
}
|
||||
|
||||
/// Creates a network service with a custom configuration.
|
||||
/// - Parameters:
|
||||
/// - configuration: The URLSession configuration to use.
|
||||
/// - decoder: The JSON decoder for parsing responses.
|
||||
/// - timeoutInterval: The timeout interval for requests in seconds.
|
||||
public convenience init(
|
||||
configuration: URLSessionConfiguration,
|
||||
decoder: JSONDecoder = JSONDecoder(),
|
||||
timeoutInterval: TimeInterval = 30
|
||||
) {
|
||||
let session = URLSession(configuration: configuration)
|
||||
self.init(session: session, decoder: decoder, timeoutInterval: timeoutInterval)
|
||||
}
|
||||
|
||||
// MARK: - NetworkServiceProtocol
|
||||
|
||||
public func request<T: Decodable & Sendable>(_ endpoint: Endpoint) async throws -> T {
|
||||
guard let request = endpoint.urlRequest(timeoutInterval: timeoutInterval) else {
|
||||
logError("Invalid URL for endpoint: \(endpoint)")
|
||||
throw NetworkError.invalidURL
|
||||
}
|
||||
|
||||
logRequest(request)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
logError("Request failed: \(error.localizedDescription)")
|
||||
throw NetworkError.requestFailed(error)
|
||||
}
|
||||
|
||||
try validateResponse(response, data: data)
|
||||
logResponse(response, data: data)
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
logError("Decoding failed: \(error)")
|
||||
throw NetworkError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadMultipart(_ endpoint: Endpoint, imageData: Data) async throws -> Data {
|
||||
guard let baseRequest = endpoint.urlRequest(timeoutInterval: timeoutInterval) else {
|
||||
logError("Invalid URL for endpoint: \(endpoint)")
|
||||
throw NetworkError.invalidURL
|
||||
}
|
||||
|
||||
var request = baseRequest
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = createMultipartBody(imageData: imageData, boundary: boundary)
|
||||
|
||||
logRequest(request, isMultipart: true)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
logError("Upload failed: \(error.localizedDescription)")
|
||||
throw NetworkError.requestFailed(error)
|
||||
}
|
||||
|
||||
try validateResponse(response, data: data)
|
||||
logResponse(response, data: data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func validateResponse(_ response: URLResponse, data: Data) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logError("Invalid response type")
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
|
||||
switch statusCode {
|
||||
case 200...299:
|
||||
// Success
|
||||
return
|
||||
case 401:
|
||||
logError("Unauthorized (401)")
|
||||
throw NetworkError.unauthorized
|
||||
case 429:
|
||||
logError("Rate limited (429)")
|
||||
throw NetworkError.rateLimited
|
||||
case 400...499:
|
||||
logError("Client error: \(statusCode)")
|
||||
throw NetworkError.serverError(statusCode: statusCode)
|
||||
case 500...599:
|
||||
logError("Server error: \(statusCode)")
|
||||
throw NetworkError.serverError(statusCode: statusCode)
|
||||
default:
|
||||
logError("Unexpected status code: \(statusCode)")
|
||||
throw NetworkError.serverError(statusCode: statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
private func createMultipartBody(imageData: Data, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let lineBreak = "\r\n"
|
||||
|
||||
// Add image data
|
||||
body.append("--\(boundary)\(lineBreak)")
|
||||
body.append("Content-Disposition: form-data; name=\"image\"; filename=\"plant.jpg\"\(lineBreak)")
|
||||
body.append("Content-Type: image/jpeg\(lineBreak)\(lineBreak)")
|
||||
body.append(imageData)
|
||||
body.append(lineBreak)
|
||||
|
||||
// Close boundary
|
||||
body.append("--\(boundary)--\(lineBreak)")
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
private func logRequest(_ request: URLRequest, isMultipart: Bool = false) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "UNKNOWN"
|
||||
let url = request.url?.absoluteString ?? "nil"
|
||||
|
||||
logger.debug("[\(method)] \(url)")
|
||||
|
||||
if let headers = request.allHTTPHeaderFields, !headers.isEmpty {
|
||||
let headerString = headers.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
|
||||
logger.debug("Headers: \(headerString)")
|
||||
}
|
||||
|
||||
if isMultipart {
|
||||
logger.debug("Body: [Multipart Form Data]")
|
||||
} else if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) {
|
||||
// Truncate long bodies
|
||||
let truncated = bodyString.prefix(500)
|
||||
logger.debug("Body: \(truncated)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logResponse(_ response: URLResponse, data: Data) {
|
||||
#if DEBUG
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.debug("Response: Non-HTTP response")
|
||||
return
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
let url = httpResponse.url?.absoluteString ?? "nil"
|
||||
|
||||
logger.debug("Response [\(statusCode)] \(url)")
|
||||
|
||||
// Log response body (truncated)
|
||||
if let bodyString = String(data: data, encoding: .utf8) {
|
||||
let truncated = bodyString.prefix(1000)
|
||||
logger.debug("Response Body: \(truncated)")
|
||||
} else {
|
||||
logger.debug("Response Body: \(data.count) bytes (non-UTF8)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logError(_ message: String) {
|
||||
#if DEBUG
|
||||
logger.error("\(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Extension
|
||||
|
||||
private extension Data {
|
||||
mutating func append(_ string: String) {
|
||||
if let data = string.data(using: .utf8) {
|
||||
append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension NetworkService {
|
||||
/// Creates a network service configured for the PlantGuide API.
|
||||
/// - Parameter apiKey: Optional API key for authentication.
|
||||
/// - Returns: A configured NetworkService instance.
|
||||
public static func plantGuideService(apiKey: String? = nil) -> NetworkService {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = 60
|
||||
configuration.timeoutIntervalForResource = 120
|
||||
|
||||
// Configure URL cache for API responses
|
||||
// 10 MB memory cache, 50 MB disk cache
|
||||
let urlCache = URLCache(
|
||||
memoryCapacity: 10 * 1024 * 1024,
|
||||
diskCapacity: 50 * 1024 * 1024,
|
||||
diskPath: "PlantGuideAPICache"
|
||||
)
|
||||
configuration.urlCache = urlCache
|
||||
configuration.requestCachePolicy = .useProtocolCachePolicy
|
||||
|
||||
// Enable HTTP pipelining for better performance on multiple requests
|
||||
configuration.httpShouldUsePipelining = true
|
||||
|
||||
// Allow cellular access but be mindful of data usage
|
||||
configuration.allowsCellularAccess = true
|
||||
|
||||
// Disable waiting for connectivity to fail fast
|
||||
configuration.waitsForConnectivity = false
|
||||
|
||||
// Add default headers
|
||||
var headers: [String: String] = [
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "PlantGuide-iOS/1.0"
|
||||
]
|
||||
|
||||
if let apiKey {
|
||||
headers["Authorization"] = "Bearer \(apiKey)"
|
||||
}
|
||||
|
||||
configuration.httpAdditionalHeaders = headers
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
return NetworkService(
|
||||
configuration: configuration,
|
||||
decoder: decoder,
|
||||
timeoutInterval: 60
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// NetworkServiceProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the network service interface for making API requests.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// All methods are async and throw errors to support modern Swift concurrency.
|
||||
public protocol NetworkServiceProtocol: Sendable {
|
||||
/// Performs a network request and decodes the response into the specified type.
|
||||
///
|
||||
/// - Parameter endpoint: The endpoint configuration for the request.
|
||||
/// - Returns: The decoded response of type `T`.
|
||||
/// - Throws: `NetworkError` if the request fails or decoding fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let plant: Plant = try await networkService.request(endpoint)
|
||||
/// ```
|
||||
func request<T: Decodable & Sendable>(_ endpoint: Endpoint) async throws -> T
|
||||
|
||||
/// Uploads image data using multipart/form-data encoding.
|
||||
///
|
||||
/// This method is designed for plant identification where an image
|
||||
/// needs to be uploaded to the API for analysis.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - endpoint: The endpoint configuration for the upload.
|
||||
/// - imageData: The image data to upload.
|
||||
/// - Returns: The raw response data from the server.
|
||||
/// - Throws: `NetworkError` if the upload fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let responseData = try await networkService.uploadMultipart(endpoint, imageData: jpegData)
|
||||
/// ```
|
||||
func uploadMultipart(_ endpoint: Endpoint, imageData: Data) async throws -> Data
|
||||
}
|
||||
|
||||
// MARK: - Default Implementations
|
||||
|
||||
extension NetworkServiceProtocol {
|
||||
/// Uploads image data and decodes the response into the specified type.
|
||||
///
|
||||
/// This is a convenience method that combines `uploadMultipart` with JSON decoding.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - endpoint: The endpoint configuration for the upload.
|
||||
/// - imageData: The image data to upload.
|
||||
/// - decoder: The JSON decoder to use (defaults to a new instance).
|
||||
/// - Returns: The decoded response of type `T`.
|
||||
/// - Throws: `NetworkError` if the upload or decoding fails.
|
||||
public func uploadMultipart<T: Decodable>(
|
||||
_ endpoint: Endpoint,
|
||||
imageData: Data,
|
||||
decoder: JSONDecoder = JSONDecoder()
|
||||
) async throws -> T {
|
||||
let data = try await uploadMultipart(endpoint, imageData: imageData)
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// PlantNetDTOs.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantNetIdentifyResponseDTO
|
||||
|
||||
/// The root response object from the PlantNet plant identification API.
|
||||
///
|
||||
/// This DTO represents the complete response from the `/v2/identify` endpoint,
|
||||
/// including query metadata, identification results, and API usage information.
|
||||
struct PlantNetIdentifyResponseDTO: Decodable, Sendable {
|
||||
/// The query parameters that were sent to the API.
|
||||
let query: PlantNetQueryDTO
|
||||
|
||||
/// The language code used for common names (e.g., "en", "fr").
|
||||
let language: String
|
||||
|
||||
/// The preferred taxonomic referential used for naming.
|
||||
let preferedReferential: String
|
||||
|
||||
/// The list of identification results, ordered by confidence score (highest first).
|
||||
let results: [PlantNetResultDTO]
|
||||
|
||||
/// The API version date string (e.g., "2023-07-24").
|
||||
/// Optional as it may not always be present in the response.
|
||||
let version: String?
|
||||
|
||||
/// The number of identification requests remaining in the current quota period.
|
||||
/// Optional as it may not always be present in the response.
|
||||
let remainingIdentificationRequests: Int?
|
||||
}
|
||||
|
||||
// MARK: - PlantNetResultDTO
|
||||
|
||||
/// Represents a single identification result from the PlantNet API.
|
||||
///
|
||||
/// Each result contains a confidence score, species details, and optional
|
||||
/// GBIF reference data for the identified plant.
|
||||
struct PlantNetResultDTO: Decodable, Sendable {
|
||||
/// The confidence score for this identification (0.0 to 1.0).
|
||||
let score: Double
|
||||
|
||||
/// The species information for this identification result.
|
||||
let species: PlantNetSpeciesDTO
|
||||
|
||||
/// Optional GBIF (Global Biodiversity Information Facility) reference data.
|
||||
/// May be nil if no GBIF entry exists for this species.
|
||||
let gbif: PlantNetExternalReferenceDTO?
|
||||
|
||||
/// Optional POWO (Plants of the World Online) reference data.
|
||||
/// May be nil if no POWO entry exists for this species.
|
||||
let powo: PlantNetExternalReferenceDTO?
|
||||
}
|
||||
|
||||
// MARK: - PlantNetSpeciesDTO
|
||||
|
||||
/// Represents the species information returned by the PlantNet API.
|
||||
///
|
||||
/// Contains taxonomic details including scientific names, genus, family,
|
||||
/// and common names in the requested language.
|
||||
struct PlantNetSpeciesDTO: Decodable, Sendable {
|
||||
/// The scientific name without the author citation (e.g., "Quercus robur").
|
||||
let scientificNameWithoutAuthor: String
|
||||
|
||||
/// The author citation for the scientific name (e.g., "L.").
|
||||
let scientificNameAuthorship: String
|
||||
|
||||
/// The full scientific name including author citation (e.g., "Quercus robur L.").
|
||||
let scientificName: String
|
||||
|
||||
/// The genus classification of the species.
|
||||
let genus: PlantNetGenusDTO
|
||||
|
||||
/// The family classification of the species.
|
||||
let family: PlantNetFamilyDTO
|
||||
|
||||
/// Common names for the species in the requested language.
|
||||
/// May be empty if no common names are available.
|
||||
let commonNames: [String]
|
||||
}
|
||||
|
||||
// MARK: - PlantNetQueryDTO
|
||||
|
||||
/// Represents the query parameters that were sent to the PlantNet API.
|
||||
struct PlantNetQueryDTO: Decodable, Sendable {
|
||||
/// The project used for identification (e.g., "all", "weurope").
|
||||
let project: String
|
||||
|
||||
/// The image identifiers that were submitted.
|
||||
let images: [String]
|
||||
|
||||
/// The plant organs represented in each image (e.g., "leaf", "flower").
|
||||
let organs: [String]
|
||||
|
||||
/// Whether related images were requested in the response.
|
||||
let includeRelatedImages: Bool
|
||||
}
|
||||
|
||||
// MARK: - PlantNetGenusDTO
|
||||
|
||||
/// Represents the genus classification of a plant species.
|
||||
struct PlantNetGenusDTO: Decodable, Sendable {
|
||||
/// The scientific name without the author citation.
|
||||
let scientificNameWithoutAuthor: String
|
||||
|
||||
/// The author citation for the scientific name.
|
||||
let scientificNameAuthorship: String
|
||||
|
||||
/// The full scientific name including author citation.
|
||||
let scientificName: String
|
||||
}
|
||||
|
||||
// MARK: - PlantNetFamilyDTO
|
||||
|
||||
/// Represents the family classification of a plant species.
|
||||
struct PlantNetFamilyDTO: Decodable, Sendable {
|
||||
/// The scientific name without the author citation.
|
||||
let scientificNameWithoutAuthor: String
|
||||
|
||||
/// The author citation for the scientific name.
|
||||
let scientificNameAuthorship: String
|
||||
|
||||
/// The full scientific name including author citation.
|
||||
let scientificName: String
|
||||
}
|
||||
|
||||
// MARK: - PlantNetExternalReferenceDTO
|
||||
|
||||
/// Represents external reference data (GBIF, POWO, etc.) from identification APIs.
|
||||
/// Used for both GBIF (Global Biodiversity Information Facility) and
|
||||
/// POWO (Plants of the World Online) references.
|
||||
struct PlantNetExternalReferenceDTO: Decodable, Sendable {
|
||||
/// The external reference identifier (returned as string from API).
|
||||
let id: String
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//
|
||||
// PlantNetAPIService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
// MARK: - PlantNetAPIError
|
||||
|
||||
/// Represents errors specific to the PlantNet API service.
|
||||
///
|
||||
/// These errors provide specific context for PlantNet API failures,
|
||||
/// enabling appropriate error handling and user messaging.
|
||||
enum PlantNetAPIError: Error, Sendable {
|
||||
/// The API key is missing or invalid (HTTP 401).
|
||||
case invalidAPIKey
|
||||
|
||||
/// The daily request limit has been exceeded (HTTP 429).
|
||||
case rateLimitExceeded
|
||||
|
||||
/// Failed to upload or process the image.
|
||||
case imageUploadFailed
|
||||
|
||||
/// The server response could not be parsed.
|
||||
case invalidResponse
|
||||
|
||||
/// The server returned an error status code.
|
||||
case serverError(statusCode: Int)
|
||||
|
||||
/// No network connection is available.
|
||||
case networkUnavailable
|
||||
|
||||
/// No identification results were returned.
|
||||
case noResultsFound
|
||||
|
||||
/// The image format is not supported.
|
||||
case unsupportedImageFormat
|
||||
}
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
extension PlantNetAPIError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAPIKey:
|
||||
return "Invalid API key. Please check your PlantNet API configuration."
|
||||
case .rateLimitExceeded:
|
||||
return "Daily identification limit reached. Please try again tomorrow."
|
||||
case .imageUploadFailed:
|
||||
return "Failed to upload the image for identification."
|
||||
case .invalidResponse:
|
||||
return "Received an invalid response from the identification service."
|
||||
case .serverError(let statusCode):
|
||||
return "Server error occurred (code: \(statusCode)). Please try again later."
|
||||
case .networkUnavailable:
|
||||
return "No internet connection. Please check your network and try again."
|
||||
case .noResultsFound:
|
||||
return "No plant matches found. Try with a clearer image."
|
||||
case .unsupportedImageFormat:
|
||||
return "The image format is not supported. Please use JPEG or PNG."
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .invalidAPIKey:
|
||||
return "The PlantNet API key is missing or has been revoked."
|
||||
case .rateLimitExceeded:
|
||||
return "The free tier allows 500 identifications per day."
|
||||
case .imageUploadFailed:
|
||||
return "The image could not be processed by the server."
|
||||
case .invalidResponse:
|
||||
return "The server response format was unexpected."
|
||||
case .serverError(let statusCode):
|
||||
return "HTTP status code: \(statusCode)"
|
||||
case .networkUnavailable:
|
||||
return "The device is not connected to the internet."
|
||||
case .noResultsFound:
|
||||
return "The image may not contain a recognizable plant."
|
||||
case .unsupportedImageFormat:
|
||||
return "PlantNet requires JPEG or PNG images."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .invalidAPIKey:
|
||||
return "Verify your API key in the app settings."
|
||||
case .rateLimitExceeded:
|
||||
return "Wait until midnight UTC for your quota to reset."
|
||||
case .imageUploadFailed:
|
||||
return "Try taking a new photo with better lighting."
|
||||
case .invalidResponse:
|
||||
return "The app may need to be updated."
|
||||
case .serverError:
|
||||
return "Wait a few minutes and try again."
|
||||
case .networkUnavailable:
|
||||
return "Connect to Wi-Fi or enable cellular data."
|
||||
case .noResultsFound:
|
||||
return "Try photographing a different part of the plant (leaf, flower, etc.)."
|
||||
case .unsupportedImageFormat:
|
||||
return "Take a new photo using the camera."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
extension PlantNetAPIError: Equatable {
|
||||
static func == (lhs: PlantNetAPIError, rhs: PlantNetAPIError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.invalidAPIKey, .invalidAPIKey):
|
||||
return true
|
||||
case (.rateLimitExceeded, .rateLimitExceeded):
|
||||
return true
|
||||
case (.imageUploadFailed, .imageUploadFailed):
|
||||
return true
|
||||
case (.invalidResponse, .invalidResponse):
|
||||
return true
|
||||
case (.serverError(let lhsCode), .serverError(let rhsCode)):
|
||||
return lhsCode == rhsCode
|
||||
case (.networkUnavailable, .networkUnavailable):
|
||||
return true
|
||||
case (.noResultsFound, .noResultsFound):
|
||||
return true
|
||||
case (.unsupportedImageFormat, .unsupportedImageFormat):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PlantNetAPIServiceProtocol
|
||||
|
||||
/// Protocol defining the PlantNet API service interface.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
protocol PlantNetAPIServiceProtocol: Sendable {
|
||||
/// Identifies a plant from an image using the PlantNet API.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - imageData: The JPEG image data to identify.
|
||||
/// - organs: The plant organs visible in the image.
|
||||
/// - project: The PlantNet project/flora to search within.
|
||||
/// - Returns: The identification response containing matched species.
|
||||
/// - Throws: `PlantNetAPIError` if the identification fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let response = try await plantNetService.identify(
|
||||
/// imageData: jpegData,
|
||||
/// organs: [.leaf, .flower],
|
||||
/// project: .all
|
||||
/// )
|
||||
/// ```
|
||||
func identify(
|
||||
imageData: Data,
|
||||
organs: [PlantOrgan],
|
||||
project: PlantNetProject
|
||||
) async throws -> PlantNetIdentifyResponseDTO
|
||||
}
|
||||
|
||||
// MARK: - PlantNetAPIService
|
||||
|
||||
/// A service for interacting with the PlantNet plant identification API.
|
||||
///
|
||||
/// This service handles:
|
||||
/// - Multipart form-data image uploads
|
||||
/// - API authentication via API key
|
||||
/// - Response parsing and error handling
|
||||
/// - Rate limit tracking integration
|
||||
/// - Request/response logging in debug builds
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let service = PlantNetAPIService(apiKey: "your-api-key")
|
||||
///
|
||||
/// let response = try await service.identify(
|
||||
/// imageData: imageData,
|
||||
/// organs: [.leaf],
|
||||
/// project: .all
|
||||
/// )
|
||||
///
|
||||
/// for result in response.results {
|
||||
/// print("\(result.species.scientificName): \(result.score)")
|
||||
/// }
|
||||
/// ```
|
||||
final class PlantNetAPIService: PlantNetAPIServiceProtocol, @unchecked Sendable {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let baseURL = "https://my-api.plantnet.org"
|
||||
static let identifyPath = "/v2/identify"
|
||||
static let timeoutInterval: TimeInterval = 30
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let apiKey: String
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let rateLimitTracker: RateLimitTrackerProtocol?
|
||||
private let logger = Logger(subsystem: "com.plantguide.network", category: "PlantNetAPI")
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new PlantNet API service instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - apiKey: The PlantNet API key for authentication.
|
||||
/// - session: The URLSession to use for requests (defaults to shared session).
|
||||
/// - decoder: The JSON decoder for parsing responses.
|
||||
/// - rateLimitTracker: Optional rate limit tracker for monitoring API usage.
|
||||
init(
|
||||
apiKey: String,
|
||||
session: URLSession = .shared,
|
||||
decoder: JSONDecoder = JSONDecoder(),
|
||||
rateLimitTracker: RateLimitTrackerProtocol? = nil
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.session = session
|
||||
self.decoder = decoder
|
||||
self.rateLimitTracker = rateLimitTracker
|
||||
}
|
||||
|
||||
// MARK: - PlantNetAPIServiceProtocol
|
||||
|
||||
func identify(
|
||||
imageData: Data,
|
||||
organs: [PlantOrgan],
|
||||
project: PlantNetProject
|
||||
) async throws -> PlantNetIdentifyResponseDTO {
|
||||
// Check rate limit before making request
|
||||
if let tracker = rateLimitTracker {
|
||||
guard await tracker.canMakeRequest() else {
|
||||
logError("Rate limit exhausted")
|
||||
throw PlantNetAPIError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL with query parameters
|
||||
guard let url = buildIdentifyURL(project: project) else {
|
||||
logError("Failed to build identify URL")
|
||||
throw PlantNetAPIError.invalidResponse
|
||||
}
|
||||
|
||||
// Create multipart request
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = Constants.timeoutInterval
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = createMultipartBody(
|
||||
imageData: imageData,
|
||||
organs: organs,
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
logRequest(request)
|
||||
|
||||
// Perform request
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch let error as URLError {
|
||||
logError("Request failed: \(error.localizedDescription)")
|
||||
if error.code == .notConnectedToInternet || error.code == .networkConnectionLost {
|
||||
throw PlantNetAPIError.networkUnavailable
|
||||
}
|
||||
throw PlantNetAPIError.imageUploadFailed
|
||||
} catch {
|
||||
logError("Request failed: \(error.localizedDescription)")
|
||||
throw PlantNetAPIError.imageUploadFailed
|
||||
}
|
||||
|
||||
// Validate response
|
||||
try await validateResponse(response, data: data)
|
||||
logResponse(response, data: data)
|
||||
|
||||
// Decode response
|
||||
do {
|
||||
let identifyResponse = try decoder.decode(PlantNetIdentifyResponseDTO.self, from: data)
|
||||
|
||||
// Update rate limit tracker with remaining count
|
||||
if let tracker = rateLimitTracker,
|
||||
let remaining = identifyResponse.remainingIdentificationRequests {
|
||||
await tracker.recordUsage(remaining: remaining)
|
||||
}
|
||||
|
||||
return identifyResponse
|
||||
} catch {
|
||||
logError("Decoding failed: \(error)")
|
||||
throw PlantNetAPIError.invalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Builds the identify endpoint URL with query parameters.
|
||||
private func buildIdentifyURL(project: PlantNetProject) -> URL? {
|
||||
guard var components = URLComponents(string: Constants.baseURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.path = "\(Constants.identifyPath)/\(project.rawValue)"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "api-key", value: apiKey),
|
||||
URLQueryItem(name: "include-related-images", value: "false"),
|
||||
URLQueryItem(name: "no-reject", value: "false"),
|
||||
URLQueryItem(name: "lang", value: "en")
|
||||
]
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// Creates the multipart form-data body for the identify request.
|
||||
private func createMultipartBody(
|
||||
imageData: Data,
|
||||
organs: [PlantOrgan],
|
||||
boundary: String
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
let lineBreak = "\r\n"
|
||||
|
||||
// Add image data field
|
||||
body.append("--\(boundary)\(lineBreak)")
|
||||
body.append("Content-Disposition: form-data; name=\"images\"; filename=\"plant.jpg\"\(lineBreak)")
|
||||
body.append("Content-Type: image/jpeg\(lineBreak)\(lineBreak)")
|
||||
body.append(imageData)
|
||||
body.append(lineBreak)
|
||||
|
||||
// Add organ fields - one for each organ corresponding to each image
|
||||
// For a single image, we use the first organ or default to "auto"
|
||||
let organValues = organs.isEmpty ? [PlantOrgan.leaf] : organs
|
||||
for organ in organValues {
|
||||
body.append("--\(boundary)\(lineBreak)")
|
||||
body.append("Content-Disposition: form-data; name=\"organs\"\(lineBreak)\(lineBreak)")
|
||||
body.append("\(organ.rawValue)\(lineBreak)")
|
||||
}
|
||||
|
||||
// Close boundary
|
||||
body.append("--\(boundary)--\(lineBreak)")
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
/// Validates the HTTP response and throws appropriate errors.
|
||||
private func validateResponse(_ response: URLResponse, data: Data) async throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logError("Invalid response type")
|
||||
throw PlantNetAPIError.invalidResponse
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
|
||||
switch statusCode {
|
||||
case 200...299:
|
||||
// Success
|
||||
return
|
||||
case 401:
|
||||
logError("Unauthorized (401) - Invalid API key")
|
||||
throw PlantNetAPIError.invalidAPIKey
|
||||
case 429:
|
||||
logError("Rate limited (429)")
|
||||
// Update tracker if available
|
||||
if let tracker = rateLimitTracker {
|
||||
await tracker.recordUsage(remaining: 0)
|
||||
}
|
||||
throw PlantNetAPIError.rateLimitExceeded
|
||||
case 400:
|
||||
// Check if this is a "no results" error
|
||||
logError("Bad request (400)")
|
||||
throw PlantNetAPIError.imageUploadFailed
|
||||
case 404:
|
||||
logError("Not found (404)")
|
||||
throw PlantNetAPIError.noResultsFound
|
||||
case 415:
|
||||
logError("Unsupported media type (415)")
|
||||
throw PlantNetAPIError.unsupportedImageFormat
|
||||
case 500...599:
|
||||
logError("Server error: \(statusCode)")
|
||||
throw PlantNetAPIError.serverError(statusCode: statusCode)
|
||||
default:
|
||||
logError("Unexpected status code: \(statusCode)")
|
||||
throw PlantNetAPIError.serverError(statusCode: statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "UNKNOWN"
|
||||
let url = request.url?.absoluteString ?? "nil"
|
||||
|
||||
logger.debug("[\(method)] \(url)")
|
||||
|
||||
if let headers = request.allHTTPHeaderFields, !headers.isEmpty {
|
||||
// Filter out sensitive headers
|
||||
let safeHeaders = headers.filter { !$0.key.lowercased().contains("key") }
|
||||
let headerString = safeHeaders.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
|
||||
logger.debug("Headers: \(headerString)")
|
||||
}
|
||||
|
||||
logger.debug("Body: [Multipart Form Data]")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logResponse(_ response: URLResponse, data: Data) {
|
||||
#if DEBUG
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.debug("Response: Non-HTTP response")
|
||||
return
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
let url = httpResponse.url?.absoluteString ?? "nil"
|
||||
|
||||
logger.debug("Response [\(statusCode)] \(url)")
|
||||
|
||||
// Log response body (truncated)
|
||||
if let bodyString = String(data: data, encoding: .utf8) {
|
||||
let truncated = bodyString.prefix(1000)
|
||||
logger.debug("Response Body: \(truncated)")
|
||||
} else {
|
||||
logger.debug("Response Body: \(data.count) bytes (non-UTF8)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logError(_ message: String) {
|
||||
#if DEBUG
|
||||
logger.error("\(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Extension
|
||||
|
||||
private extension Data {
|
||||
mutating func append(_ string: String) {
|
||||
if let data = string.data(using: .utf8) {
|
||||
append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension PlantNetAPIService {
|
||||
/// Creates a PlantNet API service configured with default settings.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - apiKey: The PlantNet API key.
|
||||
/// - rateLimitTracker: Optional rate limit tracker.
|
||||
/// - Returns: A configured PlantNetAPIService instance.
|
||||
public static func configured(
|
||||
apiKey: String,
|
||||
rateLimitTracker: RateLimitTrackerProtocol? = nil
|
||||
) -> PlantNetAPIService {
|
||||
let decoder = JSONDecoder()
|
||||
// PlantNet API uses camelCase
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = Constants.timeoutInterval
|
||||
configuration.timeoutIntervalForResource = 60
|
||||
|
||||
let session = URLSession(configuration: configuration)
|
||||
|
||||
return PlantNetAPIService(
|
||||
apiKey: apiKey,
|
||||
session: session,
|
||||
decoder: decoder,
|
||||
rateLimitTracker: rateLimitTracker
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// PlantNetEndpoints.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantNet Flora Projects
|
||||
|
||||
/// Available flora projects for PlantNet plant identification.
|
||||
///
|
||||
/// Each project represents a specific geographic region or plant category,
|
||||
/// affecting which species the API can identify.
|
||||
public enum PlantNetProject: String, Sendable {
|
||||
/// All flora projects combined for worldwide identification.
|
||||
case all = "all"
|
||||
|
||||
/// Western Europe flora (France, UK, Spain, Portugal, etc.).
|
||||
case westernEurope = "weurope"
|
||||
|
||||
/// Canadian flora.
|
||||
case canada = "canada"
|
||||
|
||||
/// Useful plants (cultivated, edible, medicinal).
|
||||
case useful = "useful"
|
||||
}
|
||||
|
||||
// MARK: - Plant Organ Types
|
||||
|
||||
/// Plant organ types used for identification hints.
|
||||
///
|
||||
/// Specifying the correct organ type helps PlantNet provide
|
||||
/// more accurate identification results.
|
||||
public enum PlantOrgan: String, Sendable {
|
||||
/// A leaf of the plant.
|
||||
case leaf
|
||||
|
||||
/// A flower of the plant.
|
||||
case flower
|
||||
|
||||
/// A fruit of the plant.
|
||||
case fruit
|
||||
|
||||
/// The bark of the plant (for trees and shrubs).
|
||||
case bark
|
||||
|
||||
/// Let the API automatically detect the organ type.
|
||||
case auto
|
||||
}
|
||||
|
||||
// MARK: - PlantNet API Endpoints
|
||||
|
||||
/// Factory for creating PlantNet API endpoint configurations.
|
||||
///
|
||||
/// PlantNet API Documentation: https://my.plantnet.org/doc
|
||||
public enum PlantNetEndpoint {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// The base URL for the PlantNet API.
|
||||
private static let baseURLString = "https://my-api.plantnet.org"
|
||||
|
||||
// MARK: - Endpoint Factory Methods
|
||||
|
||||
/// Creates an endpoint configuration for plant identification.
|
||||
///
|
||||
/// This endpoint accepts multipart/form-data requests with plant images
|
||||
/// and returns identification results.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - project: The flora project to use for identification.
|
||||
/// - organs: Array of organ types corresponding to each uploaded image.
|
||||
/// - language: The language code for results (default: "en").
|
||||
/// - Returns: An `Endpoint` configured for the identification request.
|
||||
///
|
||||
/// - Note: The returned endpoint is configured for multipart upload.
|
||||
/// The actual image data should be appended when building the request.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let endpoint = PlantNetEndpoint.identify(
|
||||
/// project: .westernEurope,
|
||||
/// organs: [.leaf, .flower],
|
||||
/// language: "en"
|
||||
/// )
|
||||
/// ```
|
||||
public static func identify(
|
||||
project: PlantNetProject,
|
||||
organs: [PlantOrgan],
|
||||
language: String = "en"
|
||||
) -> Endpoint {
|
||||
// Build query items
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
// Add organ parameters (one for each image)
|
||||
for organ in organs {
|
||||
queryItems.append(URLQueryItem(name: "organs", value: organ.rawValue))
|
||||
}
|
||||
|
||||
// Add language parameter
|
||||
queryItems.append(URLQueryItem(name: "lang", value: language))
|
||||
|
||||
// Build headers with API key
|
||||
let headers: [String: String] = [
|
||||
"Api-Key": APIKeys.plantNetAPIKey
|
||||
]
|
||||
|
||||
// Construct the base URL
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
// This should never fail with a valid constant URL
|
||||
fatalError("Invalid PlantNet base URL: \(baseURLString)")
|
||||
}
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: "/v2/identify/\(project.rawValue)",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
queryItems: queryItems,
|
||||
body: nil // Body will be set when creating multipart form data
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//
|
||||
// RateLimitTracker.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when the rate limit remaining count is updated.
|
||||
/// The `userInfo` dictionary contains:
|
||||
/// - `"remaining"`: The new remaining request count (Int)
|
||||
static let rateLimitDidUpdate = Notification.Name("rateLimitDidUpdate")
|
||||
}
|
||||
|
||||
// MARK: - WarningLevel
|
||||
|
||||
/// Represents the current rate limit warning level for the PlantNet API.
|
||||
///
|
||||
/// The warning level is determined by the number of remaining requests
|
||||
/// in the current day's quota (500 requests/day for free tier).
|
||||
public enum WarningLevel: Sendable, Equatable {
|
||||
/// More than 100 requests remaining - safe to use freely
|
||||
case none
|
||||
/// 50-100 requests remaining - consider limiting non-essential requests
|
||||
case low
|
||||
/// 10-50 requests remaining - use sparingly
|
||||
case medium
|
||||
/// 1-10 requests remaining - critical, only essential requests
|
||||
case critical
|
||||
/// No requests remaining - API calls will fail until reset
|
||||
case exhausted
|
||||
}
|
||||
|
||||
// MARK: - RateLimitTrackerProtocol
|
||||
|
||||
/// Protocol for rate limit tracking, enabling testability through dependency injection.
|
||||
public protocol RateLimitTrackerProtocol: Sendable {
|
||||
/// The number of remaining API requests for the current period.
|
||||
var remainingRequests: Int { get async }
|
||||
|
||||
/// The date when the rate limit resets (midnight UTC).
|
||||
var resetDate: Date { get async }
|
||||
|
||||
/// Records usage from an API response.
|
||||
/// - Parameter remaining: The remaining request count from the API response header.
|
||||
func recordUsage(remaining: Int) async
|
||||
|
||||
/// Checks if a request can be made without exceeding the rate limit.
|
||||
/// - Returns: `true` if requests are available, `false` if exhausted.
|
||||
func canMakeRequest() async -> Bool
|
||||
|
||||
/// Gets the current warning level based on remaining requests.
|
||||
/// - Returns: The appropriate `WarningLevel` for the current state.
|
||||
func warningLevel() async -> WarningLevel
|
||||
}
|
||||
|
||||
// MARK: - RateLimitTracker
|
||||
|
||||
/// An actor-based rate limit tracker for the PlantNet API.
|
||||
///
|
||||
/// This tracker monitors API usage against the free tier limit of 500 requests/day.
|
||||
/// It persists state to UserDefaults and automatically resets when the daily
|
||||
/// limit period expires (midnight UTC).
|
||||
///
|
||||
/// Thread safety is guaranteed through Swift's actor isolation.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let tracker = RateLimitTracker()
|
||||
///
|
||||
/// // Before making a request
|
||||
/// guard await tracker.canMakeRequest() else {
|
||||
/// // Handle rate limit exhausted
|
||||
/// return
|
||||
/// }
|
||||
///
|
||||
/// // After receiving API response
|
||||
/// if let remaining = response.headers["X-RateLimit-Remaining"] {
|
||||
/// await tracker.recordUsage(remaining: Int(remaining) ?? 0)
|
||||
/// }
|
||||
///
|
||||
/// // Check warning level
|
||||
/// let level = await tracker.warningLevel()
|
||||
/// if level == .critical {
|
||||
/// // Show warning to user
|
||||
/// }
|
||||
/// ```
|
||||
public actor RateLimitTracker: RateLimitTrackerProtocol {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let dailyLimit = 500
|
||||
static let remainingKey = "plantnet_rate_limit_remaining"
|
||||
static let resetDateKey = "plantnet_rate_limit_reset_date"
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The number of remaining API requests for the current period.
|
||||
public private(set) var remainingRequests: Int
|
||||
|
||||
/// The date when the rate limit resets (midnight UTC).
|
||||
public private(set) var resetDate: Date
|
||||
|
||||
private let userDefaults: UserDefaults
|
||||
private let calendar: Calendar
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new rate limit tracker.
|
||||
/// - Parameters:
|
||||
/// - userDefaults: The UserDefaults instance for persistence (defaults to standard).
|
||||
/// - calendar: The calendar for date calculations (defaults to UTC calendar).
|
||||
public init(
|
||||
userDefaults: UserDefaults = .standard,
|
||||
calendar: Calendar = {
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = TimeZone(identifier: "UTC")!
|
||||
return cal
|
||||
}()
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.calendar = calendar
|
||||
|
||||
// Load persisted state or initialize with defaults
|
||||
let storedResetDate = userDefaults.object(forKey: Constants.resetDateKey) as? Date
|
||||
let currentResetDate = Self.calculateNextResetDate(from: Date(), calendar: calendar)
|
||||
|
||||
// Check if stored reset date has passed
|
||||
if let storedDate = storedResetDate, storedDate > Date() {
|
||||
// Reset date hasn't passed, use stored values
|
||||
self.resetDate = storedDate
|
||||
self.remainingRequests = userDefaults.integer(forKey: Constants.remainingKey)
|
||||
|
||||
// Handle case where key didn't exist (returns 0)
|
||||
if self.remainingRequests == 0 && !userDefaults.dictionaryRepresentation().keys.contains(Constants.remainingKey) {
|
||||
self.remainingRequests = Constants.dailyLimit
|
||||
}
|
||||
} else {
|
||||
// Reset date has passed or no stored date, initialize fresh
|
||||
self.resetDate = currentResetDate
|
||||
self.remainingRequests = Constants.dailyLimit
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Records usage from an API response.
|
||||
///
|
||||
/// Call this method after each successful API request with the remaining
|
||||
/// count from the response header (typically `X-RateLimit-Remaining`).
|
||||
///
|
||||
/// - Parameter remaining: The remaining request count from the API response.
|
||||
public func recordUsage(remaining: Int) {
|
||||
checkAndResetIfNeeded()
|
||||
remainingRequests = max(0, remaining)
|
||||
persistState()
|
||||
|
||||
// Notify observers on main thread
|
||||
let newRemaining = remainingRequests
|
||||
Task { @MainActor in
|
||||
NotificationCenter.default.post(
|
||||
name: .rateLimitDidUpdate,
|
||||
object: nil,
|
||||
userInfo: ["remaining": newRemaining]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a request can be made without exceeding the rate limit.
|
||||
///
|
||||
/// This method also handles automatic reset when the limit period expires.
|
||||
///
|
||||
/// - Returns: `true` if at least one request is available, `false` if exhausted.
|
||||
public func canMakeRequest() -> Bool {
|
||||
checkAndResetIfNeeded()
|
||||
return remainingRequests > 0
|
||||
}
|
||||
|
||||
/// Gets the current warning level based on remaining requests.
|
||||
///
|
||||
/// Use this to provide UI feedback to users about their API usage.
|
||||
///
|
||||
/// - Returns: The appropriate `WarningLevel` for the current remaining count.
|
||||
public func warningLevel() -> WarningLevel {
|
||||
checkAndResetIfNeeded()
|
||||
|
||||
switch remainingRequests {
|
||||
case 0:
|
||||
return .exhausted
|
||||
case 1...10:
|
||||
return .critical
|
||||
case 11...50:
|
||||
return .medium
|
||||
case 51...100:
|
||||
return .low
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Checks if the reset date has passed and resets the counter if needed.
|
||||
private func checkAndResetIfNeeded() {
|
||||
if Date() >= resetDate {
|
||||
remainingRequests = Constants.dailyLimit
|
||||
resetDate = Self.calculateNextResetDate(from: Date(), calendar: calendar)
|
||||
persistState()
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the current state to UserDefaults.
|
||||
private func persistState() {
|
||||
userDefaults.set(remainingRequests, forKey: Constants.remainingKey)
|
||||
userDefaults.set(resetDate, forKey: Constants.resetDateKey)
|
||||
}
|
||||
|
||||
/// Calculates the next midnight UTC from the given date.
|
||||
/// - Parameters:
|
||||
/// - date: The reference date.
|
||||
/// - calendar: The calendar to use (should be configured for UTC).
|
||||
/// - Returns: The next midnight UTC date.
|
||||
private static func calculateNextResetDate(from date: Date, calendar: Calendar) -> Date {
|
||||
let startOfToday = calendar.startOfDay(for: date)
|
||||
return calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? date
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//
|
||||
// TrefleDTOs.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - TrefleSearchResponseDTO
|
||||
|
||||
/// The root response object from the Trefle plant search API.
|
||||
///
|
||||
/// This DTO represents the complete response from the `/api/v1/plants/search` endpoint,
|
||||
/// including search results, pagination links, and metadata.
|
||||
struct TrefleSearchResponseDTO: Decodable, Sendable {
|
||||
/// The list of plant summaries matching the search query.
|
||||
let data: [TreflePlantSummaryDTO]
|
||||
|
||||
/// Pagination links for navigating through search results.
|
||||
let links: TrefleLinksDTO
|
||||
|
||||
/// Metadata about the search results, including total count.
|
||||
let meta: TrefleMetaDTO
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpeciesResponseDTO
|
||||
|
||||
/// The root response object for a single species from the Trefle API.
|
||||
///
|
||||
/// This DTO represents the complete response from the `/api/v1/species/{id}` endpoint,
|
||||
/// containing detailed species information and metadata.
|
||||
struct TrefleSpeciesResponseDTO: Decodable, Sendable {
|
||||
/// The detailed species information.
|
||||
let data: TrefleSpeciesDTO
|
||||
|
||||
/// Metadata about the response.
|
||||
let meta: TrefleMetaDTO
|
||||
}
|
||||
|
||||
// MARK: - TreflePlantSummaryDTO
|
||||
|
||||
/// Represents basic plant information returned from a Trefle search query.
|
||||
///
|
||||
/// This DTO contains summarized plant data suitable for displaying in search results
|
||||
/// or lists, with links to more detailed species information.
|
||||
struct TreflePlantSummaryDTO: Decodable, Sendable {
|
||||
/// The unique identifier for this plant in the Trefle database.
|
||||
let id: Int
|
||||
|
||||
/// The common name for this plant (e.g., "European Oak").
|
||||
/// May be nil if no common name is available.
|
||||
let commonName: String?
|
||||
|
||||
/// The URL-friendly slug for this plant (e.g., "quercus-robur").
|
||||
let slug: String
|
||||
|
||||
/// The scientific name for this plant (e.g., "Quercus robur").
|
||||
let scientificName: String
|
||||
|
||||
/// The family name for this plant (e.g., "Fagaceae").
|
||||
/// May be nil if family information is not available.
|
||||
let family: String?
|
||||
|
||||
/// The genus name for this plant (e.g., "Quercus").
|
||||
/// May be nil if genus information is not available.
|
||||
let genus: String?
|
||||
|
||||
/// The URL to the primary image for this plant.
|
||||
/// May be nil if no image is available.
|
||||
let imageUrl: String?
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpeciesDTO
|
||||
|
||||
/// Represents detailed species information from the Trefle API.
|
||||
///
|
||||
/// This DTO contains comprehensive plant data including taxonomic details,
|
||||
/// images, growth requirements, and physical specifications.
|
||||
struct TrefleSpeciesDTO: Decodable, Sendable {
|
||||
/// The unique identifier for this species in the Trefle database.
|
||||
let id: Int
|
||||
|
||||
/// The common name for this species (e.g., "European Oak").
|
||||
/// May be nil if no common name is available.
|
||||
let commonName: String?
|
||||
|
||||
/// The URL-friendly slug for this species (e.g., "quercus-robur").
|
||||
let slug: String
|
||||
|
||||
/// The scientific name for this species (e.g., "Quercus robur").
|
||||
let scientificName: String
|
||||
|
||||
/// The year this species was first formally described.
|
||||
/// May be nil if the year is not known.
|
||||
let year: Int?
|
||||
|
||||
/// The bibliographic reference for the original species description.
|
||||
/// May be nil if not available.
|
||||
let bibliography: String?
|
||||
|
||||
/// The author who first described this species (e.g., "L." for Linnaeus).
|
||||
/// May be nil if not available.
|
||||
let author: String?
|
||||
|
||||
/// The common name of the plant family (e.g., "Beech family").
|
||||
/// May be nil if not available.
|
||||
let familyCommonName: String?
|
||||
|
||||
/// The scientific family name (e.g., "Fagaceae").
|
||||
/// May be nil if not available.
|
||||
let family: String?
|
||||
|
||||
/// The genus name (e.g., "Quercus").
|
||||
/// May be nil if not available.
|
||||
let genus: String?
|
||||
|
||||
/// The unique identifier for the genus in the Trefle database.
|
||||
/// May be nil if not available.
|
||||
let genusId: Int?
|
||||
|
||||
/// The URL to the primary image for this species.
|
||||
/// May be nil if no image is available.
|
||||
let imageUrl: String?
|
||||
|
||||
/// Categorized images of different plant parts.
|
||||
/// May be nil if no images are available.
|
||||
let images: TrefleImagesDTO?
|
||||
|
||||
/// Physical specifications and characteristics of this species.
|
||||
/// May be nil if specifications are not available.
|
||||
let specifications: TrefleSpecificationsDTO?
|
||||
|
||||
/// Growth requirements and seasonal information for this species.
|
||||
/// May be nil if growth data is not available.
|
||||
let growth: TrefleGrowthDTO?
|
||||
}
|
||||
|
||||
// MARK: - TrefleGrowthDTO
|
||||
|
||||
/// Represents growth requirements and seasonal information for a plant species.
|
||||
///
|
||||
/// This DTO contains detailed environmental preferences and growing conditions,
|
||||
/// including light, humidity, temperature ranges, and seasonal activity.
|
||||
struct TrefleGrowthDTO: Decodable, Sendable {
|
||||
/// Light requirement on a scale of 0-10 (0 = full shade, 10 = full sun).
|
||||
/// May be nil if not available.
|
||||
let light: Int?
|
||||
|
||||
/// Atmospheric humidity requirement on a scale of 0-10.
|
||||
/// May be nil if not available.
|
||||
let atmosphericHumidity: Int?
|
||||
|
||||
/// Months during which the plant actively grows (e.g., ["mar", "apr", "may"]).
|
||||
/// May be nil if not available.
|
||||
let growthMonths: [String]?
|
||||
|
||||
/// Months during which the plant blooms (e.g., ["apr", "may"]).
|
||||
/// May be nil if not available.
|
||||
let bloomMonths: [String]?
|
||||
|
||||
/// Months during which the plant produces fruit (e.g., ["sep", "oct"]).
|
||||
/// May be nil if not available.
|
||||
let fruitMonths: [String]?
|
||||
|
||||
/// The minimum precipitation requirement for this species.
|
||||
/// May be nil if not available.
|
||||
let minimumPrecipitation: TrefleMeasurementDTO?
|
||||
|
||||
/// The maximum precipitation tolerance for this species.
|
||||
/// May be nil if not available.
|
||||
let maximumPrecipitation: TrefleMeasurementDTO?
|
||||
|
||||
/// The minimum temperature tolerance for this species.
|
||||
/// May be nil if not available.
|
||||
let minimumTemperature: TrefleMeasurementDTO?
|
||||
|
||||
/// The maximum temperature tolerance for this species.
|
||||
/// May be nil if not available.
|
||||
let maximumTemperature: TrefleMeasurementDTO?
|
||||
|
||||
/// Soil nutrient requirement on a scale of 0-10 (0 = low, 10 = high).
|
||||
/// May be nil if not available.
|
||||
let soilNutriments: Int?
|
||||
|
||||
/// Soil humidity requirement on a scale of 0-10 (0 = dry, 10 = wet).
|
||||
/// May be nil if not available.
|
||||
let soilHumidity: Int?
|
||||
|
||||
/// The minimum soil pH tolerance for this species.
|
||||
/// May be nil if not available.
|
||||
let phMinimum: Double?
|
||||
|
||||
/// The maximum soil pH tolerance for this species.
|
||||
/// May be nil if not available.
|
||||
let phMaximum: Double?
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpecificationsDTO
|
||||
|
||||
/// Represents physical specifications and characteristics of a plant species.
|
||||
///
|
||||
/// This DTO contains information about growth rate, toxicity, and size dimensions.
|
||||
struct TrefleSpecificationsDTO: Decodable, Sendable {
|
||||
/// The growth rate classification (e.g., "slow", "moderate", "rapid").
|
||||
/// May be nil if not available.
|
||||
let growthRate: String?
|
||||
|
||||
/// Toxicity information (e.g., "none", "low", "medium", "high").
|
||||
/// May be nil if not available.
|
||||
let toxicity: String?
|
||||
|
||||
/// The average height of mature plants.
|
||||
/// May be nil if not available.
|
||||
let averageHeight: TrefleMeasurementDTO?
|
||||
|
||||
/// The maximum height of mature plants.
|
||||
/// May be nil if not available.
|
||||
let maximumHeight: TrefleMeasurementDTO?
|
||||
}
|
||||
|
||||
// MARK: - TrefleMeasurementDTO
|
||||
|
||||
/// Represents a measurement value with various unit representations.
|
||||
///
|
||||
/// This DTO contains the same measurement expressed in different units,
|
||||
/// supporting heights (cm), precipitation (mm), and temperatures (Celsius/Fahrenheit).
|
||||
struct TrefleMeasurementDTO: Decodable, Sendable {
|
||||
/// The measurement in centimeters (used for heights).
|
||||
/// May be nil if not applicable.
|
||||
let cm: Double?
|
||||
|
||||
/// The measurement in millimeters (used for precipitation).
|
||||
/// May be nil if not applicable.
|
||||
let mm: Double?
|
||||
|
||||
/// The measurement in degrees Celsius (used for temperatures).
|
||||
/// May be nil if not applicable.
|
||||
let degC: Double?
|
||||
|
||||
/// The measurement in degrees Fahrenheit (used for temperatures).
|
||||
/// May be nil if not applicable.
|
||||
let degF: Double?
|
||||
}
|
||||
|
||||
// MARK: - TrefleImagesDTO
|
||||
|
||||
/// Represents categorized images of different plant parts.
|
||||
///
|
||||
/// This DTO organizes plant images by the part of the plant they depict,
|
||||
/// making it easy to find specific types of reference images.
|
||||
struct TrefleImagesDTO: Decodable, Sendable {
|
||||
/// Images of the plant's flowers.
|
||||
/// May be nil or empty if no flower images are available.
|
||||
let flower: [TrefleImageDTO]?
|
||||
|
||||
/// Images of the plant's leaves.
|
||||
/// May be nil or empty if no leaf images are available.
|
||||
let leaf: [TrefleImageDTO]?
|
||||
|
||||
/// Images of the plant's bark.
|
||||
/// May be nil or empty if no bark images are available.
|
||||
let bark: [TrefleImageDTO]?
|
||||
|
||||
/// Images of the plant's fruit.
|
||||
/// May be nil or empty if no fruit images are available.
|
||||
let fruit: [TrefleImageDTO]?
|
||||
|
||||
/// Images of the plant's overall habit/form.
|
||||
/// May be nil or empty if no habit images are available.
|
||||
let habit: [TrefleImageDTO]?
|
||||
}
|
||||
|
||||
// MARK: - TrefleImageDTO
|
||||
|
||||
/// Represents a single plant image from the Trefle database.
|
||||
struct TrefleImageDTO: Decodable, Sendable {
|
||||
/// The unique identifier for this image in the Trefle database.
|
||||
let id: Int
|
||||
|
||||
/// The URL to access this image.
|
||||
let imageUrl: String
|
||||
}
|
||||
|
||||
// MARK: - TrefleLinksDTO
|
||||
|
||||
/// Represents pagination links for navigating through Trefle API results.
|
||||
///
|
||||
/// This DTO contains URLs for traversing paginated search results,
|
||||
/// following the HATEOAS pattern for RESTful APIs.
|
||||
struct TrefleLinksDTO: Decodable, Sendable {
|
||||
/// The URL for the current page of results.
|
||||
/// Note: Maps from "self" in JSON (reserved keyword in Swift).
|
||||
let selfLink: String
|
||||
|
||||
/// The URL for the first page of results.
|
||||
let first: String
|
||||
|
||||
/// The URL for the last page of results.
|
||||
/// May be nil if not available.
|
||||
let last: String?
|
||||
|
||||
/// The URL for the next page of results.
|
||||
/// May be nil if this is the last page.
|
||||
let next: String?
|
||||
|
||||
/// The URL for the previous page of results.
|
||||
/// May be nil if this is the first page.
|
||||
let prev: String?
|
||||
|
||||
// CodingKeys required because "self" is a reserved keyword
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfLink = "self"
|
||||
case first
|
||||
case last
|
||||
case next
|
||||
case prev
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrefleMetaDTO
|
||||
|
||||
/// Represents metadata about a Trefle API response.
|
||||
///
|
||||
/// This DTO contains supplementary information about the response,
|
||||
/// such as the total number of results or when the data was last updated.
|
||||
struct TrefleMetaDTO: Decodable, Sendable {
|
||||
/// The total number of results matching the query.
|
||||
/// May be nil for single-resource responses.
|
||||
let total: Int?
|
||||
|
||||
/// The timestamp when the data was last modified.
|
||||
/// May be nil if not available.
|
||||
let lastModified: String?
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//
|
||||
// TrefleAPIService.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
// MARK: - TrefleAPIError
|
||||
|
||||
/// Represents errors specific to the Trefle API service.
|
||||
///
|
||||
/// These errors provide specific context for Trefle API failures,
|
||||
/// enabling appropriate error handling and user messaging throughout
|
||||
/// the plant data retrieval workflow.
|
||||
enum TrefleAPIError: Error, Sendable {
|
||||
/// The API token is missing or invalid (HTTP 401).
|
||||
case invalidToken
|
||||
|
||||
/// The rate limit has been exceeded (HTTP 429).
|
||||
case rateLimitExceeded
|
||||
|
||||
/// The requested species was not found (HTTP 404).
|
||||
case speciesNotFound(query: String)
|
||||
|
||||
/// The server returned an error status code.
|
||||
case serverError(statusCode: Int)
|
||||
|
||||
/// No network connection is available.
|
||||
case networkUnavailable
|
||||
|
||||
/// The request timed out.
|
||||
case timeout
|
||||
|
||||
/// The server response could not be parsed as valid JSON.
|
||||
case invalidResponse
|
||||
|
||||
/// Failed to decode the response into the expected type.
|
||||
case decodingFailed(Error)
|
||||
}
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
extension TrefleAPIError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidToken:
|
||||
return "Invalid API token. Please check your Trefle API configuration."
|
||||
case .rateLimitExceeded:
|
||||
return "Request limit reached. Please try again later."
|
||||
case .speciesNotFound(let query):
|
||||
return "No species found matching '\(query)'."
|
||||
case .serverError(let statusCode):
|
||||
return "Server error occurred (code: \(statusCode)). Please try again later."
|
||||
case .networkUnavailable:
|
||||
return "No internet connection. Please check your network and try again."
|
||||
case .timeout:
|
||||
return "The request timed out. Please try again."
|
||||
case .invalidResponse:
|
||||
return "Received an invalid response from the Trefle API."
|
||||
case .decodingFailed:
|
||||
return "Failed to process the server response."
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .invalidToken:
|
||||
return "The Trefle API token is missing or has been revoked."
|
||||
case .rateLimitExceeded:
|
||||
return "Too many requests have been made in a short period."
|
||||
case .speciesNotFound(let query):
|
||||
return "No results for query: \(query)"
|
||||
case .serverError(let statusCode):
|
||||
return "HTTP status code: \(statusCode)"
|
||||
case .networkUnavailable:
|
||||
return "The device is not connected to the internet."
|
||||
case .timeout:
|
||||
return "The server did not respond within the timeout period."
|
||||
case .invalidResponse:
|
||||
return "The server response format was unexpected."
|
||||
case .decodingFailed(let error):
|
||||
return "Decoding error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .invalidToken:
|
||||
return "Verify your Trefle API token in the app configuration."
|
||||
case .rateLimitExceeded:
|
||||
return "Wait a few minutes before making another request."
|
||||
case .speciesNotFound:
|
||||
return "Try a different search term or check the spelling."
|
||||
case .serverError:
|
||||
return "Wait a few minutes and try again."
|
||||
case .networkUnavailable:
|
||||
return "Connect to Wi-Fi or enable cellular data."
|
||||
case .timeout:
|
||||
return "Check your internet connection and try again."
|
||||
case .invalidResponse:
|
||||
return "The app may need to be updated."
|
||||
case .decodingFailed:
|
||||
return "Please update the app to the latest version."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable
|
||||
|
||||
extension TrefleAPIError: Equatable {
|
||||
static func == (lhs: TrefleAPIError, rhs: TrefleAPIError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.invalidToken, .invalidToken):
|
||||
return true
|
||||
case (.rateLimitExceeded, .rateLimitExceeded):
|
||||
return true
|
||||
case (.speciesNotFound(let lhsQuery), .speciesNotFound(let rhsQuery)):
|
||||
return lhsQuery == rhsQuery
|
||||
case (.serverError(let lhsCode), .serverError(let rhsCode)):
|
||||
return lhsCode == rhsCode
|
||||
case (.networkUnavailable, .networkUnavailable):
|
||||
return true
|
||||
case (.timeout, .timeout):
|
||||
return true
|
||||
case (.invalidResponse, .invalidResponse):
|
||||
return true
|
||||
case (.decodingFailed(let lhsError), .decodingFailed(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrefleAPIServiceProtocol
|
||||
|
||||
/// Protocol defining the Trefle API service interface.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Trefle provides comprehensive plant data including species information,
|
||||
/// growth requirements, and botanical details.
|
||||
protocol TrefleAPIServiceProtocol: Sendable {
|
||||
/// Searches for plants matching the provided query.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - query: The search query (plant name, scientific name, etc.).
|
||||
/// - page: The page number for paginated results (1-indexed).
|
||||
/// - Returns: The search response containing matching plants and pagination info.
|
||||
/// - Throws: `TrefleAPIError` if the search fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let response = try await trefleService.searchPlants(query: "rose", page: 1)
|
||||
/// for plant in response.data {
|
||||
/// print("\(plant.scientificName): \(plant.commonName ?? "Unknown")")
|
||||
/// }
|
||||
/// ```
|
||||
func searchPlants(query: String, page: Int) async throws -> TrefleSearchResponseDTO
|
||||
|
||||
/// Retrieves detailed species information by slug.
|
||||
///
|
||||
/// - Parameter slug: The URL-friendly slug identifier for the species
|
||||
/// (e.g., "rosa-gallica").
|
||||
/// - Returns: The species response containing detailed botanical data.
|
||||
/// - Throws: `TrefleAPIError` if the retrieval fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let response = try await trefleService.getSpecies(slug: "rosa-gallica")
|
||||
/// print("Scientific name: \(response.data.scientificName)")
|
||||
/// ```
|
||||
func getSpecies(slug: String) async throws -> TrefleSpeciesResponseDTO
|
||||
|
||||
/// Retrieves detailed species information by numeric ID.
|
||||
///
|
||||
/// - Parameter id: The numeric identifier for the species.
|
||||
/// - Returns: The species response containing detailed botanical data.
|
||||
/// - Throws: `TrefleAPIError` if the retrieval fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let response = try await trefleService.getSpeciesById(id: 123456)
|
||||
/// print("Scientific name: \(response.data.scientificName)")
|
||||
/// ```
|
||||
func getSpeciesById(id: Int) async throws -> TrefleSpeciesResponseDTO
|
||||
}
|
||||
|
||||
// MARK: - TrefleAPIService
|
||||
|
||||
/// A service for interacting with the Trefle botanical data API.
|
||||
///
|
||||
/// This service handles:
|
||||
/// - Plant search queries with pagination
|
||||
/// - Species retrieval by slug or numeric ID
|
||||
/// - Response parsing and error handling
|
||||
/// - Automatic retry on transient failures
|
||||
/// - Request/response logging in debug builds
|
||||
///
|
||||
/// The service uses an `actor` to ensure thread-safe access to mutable state
|
||||
/// and proper synchronization of network requests.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let service = TrefleAPIService.configured()
|
||||
///
|
||||
/// // Search for plants
|
||||
/// let searchResponse = try await service.searchPlants(query: "rose", page: 1)
|
||||
///
|
||||
/// // Get species details
|
||||
/// let speciesResponse = try await service.getSpecies(slug: "rosa-gallica")
|
||||
/// ```
|
||||
///
|
||||
/// Trefle API Documentation: https://docs.trefle.io/
|
||||
actor TrefleAPIService: TrefleAPIServiceProtocol {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let timeoutInterval: TimeInterval = 15
|
||||
static let maxRetryAttempts = 1
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let logger = Logger(subsystem: "com.plantguide.network", category: "TrefleAPI")
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new Trefle API service instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - session: The URLSession to use for requests (defaults to shared session).
|
||||
/// - decoder: The JSON decoder for parsing responses.
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
decoder: JSONDecoder = JSONDecoder()
|
||||
) {
|
||||
self.session = session
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
// MARK: - TrefleAPIServiceProtocol
|
||||
|
||||
func searchPlants(query: String, page: Int) async throws -> TrefleSearchResponseDTO {
|
||||
let endpoint = TrefleEndpoint.searchPlants(query: query, page: page)
|
||||
return try await performRequest(endpoint: endpoint, notFoundQuery: query)
|
||||
}
|
||||
|
||||
func getSpecies(slug: String) async throws -> TrefleSpeciesResponseDTO {
|
||||
let endpoint = TrefleEndpoint.getSpecies(slug: slug)
|
||||
return try await performRequest(endpoint: endpoint, notFoundQuery: slug)
|
||||
}
|
||||
|
||||
func getSpeciesById(id: Int) async throws -> TrefleSpeciesResponseDTO {
|
||||
let endpoint = TrefleEndpoint.getSpeciesById(id: id)
|
||||
return try await performRequest(endpoint: endpoint, notFoundQuery: String(id))
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Performs a network request with retry logic for transient failures.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - endpoint: The endpoint configuration for the request.
|
||||
/// - notFoundQuery: The query string to include in not-found errors.
|
||||
/// - Returns: The decoded response of type `T`.
|
||||
/// - Throws: `TrefleAPIError` if the request fails after all retry attempts.
|
||||
private func performRequest<T: Decodable>(
|
||||
endpoint: Endpoint,
|
||||
notFoundQuery: String
|
||||
) async throws -> T {
|
||||
var lastError: Error?
|
||||
|
||||
for attempt in 0...Constants.maxRetryAttempts {
|
||||
do {
|
||||
return try await executeRequest(endpoint: endpoint, notFoundQuery: notFoundQuery)
|
||||
} catch let error as TrefleAPIError {
|
||||
lastError = error
|
||||
|
||||
// Only retry on transient failures
|
||||
let shouldRetry = isTransientError(error) && attempt < Constants.maxRetryAttempts
|
||||
if shouldRetry {
|
||||
logDebug("Retrying request (attempt \(attempt + 1)/\(Constants.maxRetryAttempts + 1))")
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
} catch {
|
||||
lastError = error
|
||||
throw TrefleAPIError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but throw last error if we do
|
||||
if let lastError = lastError as? TrefleAPIError {
|
||||
throw lastError
|
||||
}
|
||||
throw TrefleAPIError.invalidResponse
|
||||
}
|
||||
|
||||
/// Determines if an error is transient and should be retried.
|
||||
///
|
||||
/// - Parameter error: The error to evaluate.
|
||||
/// - Returns: `true` if the error is transient, `false` otherwise.
|
||||
private func isTransientError(_ error: TrefleAPIError) -> Bool {
|
||||
switch error {
|
||||
case .timeout, .networkUnavailable:
|
||||
return true
|
||||
case .serverError(let statusCode):
|
||||
// Retry on 5xx server errors
|
||||
return (500...599).contains(statusCode)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a single network request.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - endpoint: The endpoint configuration for the request.
|
||||
/// - notFoundQuery: The query string to include in not-found errors.
|
||||
/// - Returns: The decoded response of type `T`.
|
||||
/// - Throws: `TrefleAPIError` if the request fails.
|
||||
private func executeRequest<T: Decodable>(
|
||||
endpoint: Endpoint,
|
||||
notFoundQuery: String
|
||||
) async throws -> T {
|
||||
guard let request = endpoint.urlRequest(timeoutInterval: Constants.timeoutInterval) else {
|
||||
logError("Invalid URL for endpoint: \(endpoint)")
|
||||
throw TrefleAPIError.invalidResponse
|
||||
}
|
||||
|
||||
logRequest(request)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch let error as URLError {
|
||||
logError("Request failed: \(error.localizedDescription)")
|
||||
throw mapURLError(error)
|
||||
} catch {
|
||||
logError("Request failed: \(error.localizedDescription)")
|
||||
throw TrefleAPIError.networkUnavailable
|
||||
}
|
||||
|
||||
try validateResponse(response, data: data, notFoundQuery: notFoundQuery)
|
||||
logResponse(response, data: data)
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
logError("Decoding failed: \(error)")
|
||||
throw TrefleAPIError.decodingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps URLError codes to TrefleAPIError cases.
|
||||
///
|
||||
/// - Parameter error: The URLError to map.
|
||||
/// - Returns: The corresponding TrefleAPIError.
|
||||
private func mapURLError(_ error: URLError) -> TrefleAPIError {
|
||||
switch error.code {
|
||||
case .notConnectedToInternet, .networkConnectionLost, .dataNotAllowed:
|
||||
return .networkUnavailable
|
||||
case .timedOut:
|
||||
return .timeout
|
||||
default:
|
||||
return .networkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the HTTP response and throws appropriate errors.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - response: The URL response to validate.
|
||||
/// - data: The response data (for potential error message extraction).
|
||||
/// - notFoundQuery: The query string to include in not-found errors.
|
||||
/// - Throws: `TrefleAPIError` if the response indicates an error.
|
||||
private func validateResponse(
|
||||
_ response: URLResponse,
|
||||
data: Data,
|
||||
notFoundQuery: String
|
||||
) throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logError("Invalid response type")
|
||||
throw TrefleAPIError.invalidResponse
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
|
||||
switch statusCode {
|
||||
case 200...299:
|
||||
// Success
|
||||
return
|
||||
case 401:
|
||||
logError("Unauthorized (401) - Invalid token")
|
||||
throw TrefleAPIError.invalidToken
|
||||
case 404:
|
||||
logError("Not found (404) - Query: \(notFoundQuery)")
|
||||
throw TrefleAPIError.speciesNotFound(query: notFoundQuery)
|
||||
case 429:
|
||||
logError("Rate limited (429)")
|
||||
throw TrefleAPIError.rateLimitExceeded
|
||||
case 500...599:
|
||||
logError("Server error: \(statusCode)")
|
||||
throw TrefleAPIError.serverError(statusCode: statusCode)
|
||||
default:
|
||||
logError("Unexpected status code: \(statusCode)")
|
||||
throw TrefleAPIError.serverError(statusCode: statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
#if DEBUG
|
||||
let method = request.httpMethod ?? "UNKNOWN"
|
||||
let url = request.url?.absoluteString ?? "nil"
|
||||
|
||||
// Mask the token in the URL for security
|
||||
let maskedURL = maskToken(in: url)
|
||||
logger.debug("[\(method)] \(maskedURL)")
|
||||
|
||||
if let headers = request.allHTTPHeaderFields, !headers.isEmpty {
|
||||
let safeHeaders = headers.filter { !$0.key.lowercased().contains("token") }
|
||||
let headerString = safeHeaders.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
|
||||
logger.debug("Headers: \(headerString)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logResponse(_ response: URLResponse, data: Data) {
|
||||
#if DEBUG
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
logger.debug("Response: Non-HTTP response")
|
||||
return
|
||||
}
|
||||
|
||||
let statusCode = httpResponse.statusCode
|
||||
let url = httpResponse.url?.absoluteString ?? "nil"
|
||||
let maskedURL = maskToken(in: url)
|
||||
|
||||
logger.debug("Response [\(statusCode)] \(maskedURL)")
|
||||
|
||||
// Log response body (truncated)
|
||||
if let bodyString = String(data: data, encoding: .utf8) {
|
||||
let truncated = bodyString.prefix(1000)
|
||||
logger.debug("Response Body: \(truncated)")
|
||||
} else {
|
||||
logger.debug("Response Body: \(data.count) bytes (non-UTF8)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logError(_ message: String) {
|
||||
#if DEBUG
|
||||
logger.error("\(message)")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logDebug(_ message: String) {
|
||||
#if DEBUG
|
||||
logger.debug("\(message)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Masks the API token in a URL string for secure logging.
|
||||
///
|
||||
/// - Parameter url: The URL string potentially containing a token.
|
||||
/// - Returns: The URL string with the token value masked.
|
||||
private func maskToken(in url: String) -> String {
|
||||
guard let range = url.range(of: "token=") else {
|
||||
return url
|
||||
}
|
||||
|
||||
let tokenStart = range.upperBound
|
||||
if let tokenEnd = url[tokenStart...].firstIndex(of: "&") {
|
||||
return url.replacingCharacters(in: tokenStart..<tokenEnd, with: "***")
|
||||
} else {
|
||||
return String(url[..<tokenStart]) + "***"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension TrefleAPIService {
|
||||
/// Creates a Trefle API service configured with default settings.
|
||||
///
|
||||
/// This factory method configures the service with:
|
||||
/// - 15-second request timeout
|
||||
/// - Snake case to camelCase JSON key decoding
|
||||
/// - Proper URLSession configuration
|
||||
///
|
||||
/// - Returns: A configured TrefleAPIService instance ready for use.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let service = TrefleAPIService.configured()
|
||||
/// let response = try await service.searchPlants(query: "rose", page: 1)
|
||||
/// ```
|
||||
static func configured() -> TrefleAPIService {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = Constants.timeoutInterval
|
||||
configuration.timeoutIntervalForResource = 60
|
||||
|
||||
let session = URLSession(configuration: configuration)
|
||||
|
||||
return TrefleAPIService(
|
||||
session: session,
|
||||
decoder: decoder
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// TrefleEndpoints.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Trefle API Endpoints
|
||||
|
||||
/// Factory for creating Trefle API endpoint configurations.
|
||||
///
|
||||
/// Trefle is a botanical data API that provides access to an extensive database
|
||||
/// of plant species information including scientific names, common names,
|
||||
/// images, and detailed botanical data.
|
||||
///
|
||||
/// Trefle API Documentation: https://docs.trefle.io/
|
||||
enum TrefleEndpoint {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// The base URL for the Trefle API.
|
||||
private static let baseURLString = "https://trefle.io"
|
||||
|
||||
// MARK: - Endpoint Factory Methods
|
||||
|
||||
/// Creates an endpoint configuration for searching plants by name.
|
||||
///
|
||||
/// This endpoint searches the Trefle plant database for plants matching
|
||||
/// the provided query string. Results include basic plant information
|
||||
/// such as scientific name, common name, and thumbnail images.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - query: The search query (plant name, scientific name, etc.).
|
||||
/// - page: The page number for paginated results (1-indexed).
|
||||
/// - Returns: An `Endpoint` configured for the plant search request.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let endpoint = TrefleEndpoint.searchPlants(query: "rose", page: 1)
|
||||
/// ```
|
||||
static func searchPlants(query: String, page: Int) -> Endpoint {
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
// Add authentication token
|
||||
queryItems.append(URLQueryItem(name: "token", value: APIKeys.trefleAPIToken))
|
||||
|
||||
// Add search query
|
||||
queryItems.append(URLQueryItem(name: "q", value: query))
|
||||
|
||||
// Add pagination
|
||||
queryItems.append(URLQueryItem(name: "page", value: String(page)))
|
||||
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
fatalError("Invalid Trefle base URL: \(baseURLString)")
|
||||
}
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: "/api/v1/plants/search",
|
||||
method: .get,
|
||||
headers: nil,
|
||||
queryItems: queryItems,
|
||||
body: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an endpoint configuration for retrieving a species by its slug.
|
||||
///
|
||||
/// This endpoint retrieves detailed species information using the unique
|
||||
/// URL-friendly slug identifier. Species data includes comprehensive
|
||||
/// botanical information, growth requirements, and distribution data.
|
||||
///
|
||||
/// - Parameter slug: The URL-friendly slug identifier for the species
|
||||
/// (e.g., "rosa-gallica").
|
||||
/// - Returns: An `Endpoint` configured for the species retrieval request.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let endpoint = TrefleEndpoint.getSpecies(slug: "rosa-gallica")
|
||||
/// ```
|
||||
static func getSpecies(slug: String) -> Endpoint {
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
// Add authentication token
|
||||
queryItems.append(URLQueryItem(name: "token", value: APIKeys.trefleAPIToken))
|
||||
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
fatalError("Invalid Trefle base URL: \(baseURLString)")
|
||||
}
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: "/api/v1/species/\(slug)",
|
||||
method: .get,
|
||||
headers: nil,
|
||||
queryItems: queryItems,
|
||||
body: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an endpoint configuration for retrieving a species by its numeric ID.
|
||||
///
|
||||
/// This endpoint retrieves detailed species information using the unique
|
||||
/// numeric identifier. This is useful when you have stored species IDs
|
||||
/// from previous API responses.
|
||||
///
|
||||
/// - Parameter id: The numeric identifier for the species.
|
||||
/// - Returns: An `Endpoint` configured for the species retrieval request.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let endpoint = TrefleEndpoint.getSpeciesById(id: 123456)
|
||||
/// ```
|
||||
static func getSpeciesById(id: Int) -> Endpoint {
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
// Add authentication token
|
||||
queryItems.append(URLQueryItem(name: "token", value: APIKeys.trefleAPIToken))
|
||||
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
fatalError("Invalid Trefle base URL: \(baseURLString)")
|
||||
}
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: "/api/v1/species/\(id)",
|
||||
method: .get,
|
||||
headers: nil,
|
||||
queryItems: queryItems,
|
||||
body: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates an endpoint configuration for retrieving a plant by its numeric ID.
|
||||
///
|
||||
/// This endpoint retrieves plant information using the unique numeric
|
||||
/// identifier. Plants in Trefle represent broader taxonomic groups,
|
||||
/// while species provide more specific botanical details.
|
||||
///
|
||||
/// - Parameter id: The numeric identifier for the plant.
|
||||
/// - Returns: An `Endpoint` configured for the plant retrieval request.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let endpoint = TrefleEndpoint.getPlant(id: 123456)
|
||||
/// ```
|
||||
static func getPlant(id: Int) -> Endpoint {
|
||||
var queryItems: [URLQueryItem] = []
|
||||
|
||||
// Add authentication token
|
||||
queryItems.append(URLQueryItem(name: "token", value: APIKeys.trefleAPIToken))
|
||||
|
||||
guard let baseURL = URL(string: baseURLString) else {
|
||||
fatalError("Invalid Trefle base URL: \(baseURLString)")
|
||||
}
|
||||
|
||||
return Endpoint(
|
||||
baseURL: baseURL,
|
||||
path: "/api/v1/plants/\(id)",
|
||||
method: .get,
|
||||
headers: nil,
|
||||
queryItems: queryItems,
|
||||
body: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// PlantNetMapper.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantNetMapper
|
||||
|
||||
/// Maps PlantNet API DTOs to domain entities and view-layer models.
|
||||
///
|
||||
/// This mapper provides conversion functions for transforming PlantNet API
|
||||
/// responses into the appropriate application layer types.
|
||||
struct PlantNetMapper {
|
||||
|
||||
// MARK: - View Layer Mapping
|
||||
|
||||
/// Maps a PlantNet API response to an array of view-layer predictions.
|
||||
///
|
||||
/// - Parameter response: The complete response from the PlantNet identification API.
|
||||
/// - Returns: An array of `ViewPlantPrediction` objects for display, sorted by confidence.
|
||||
/// Returns an empty array if the response contains no results.
|
||||
static func mapToPredictions(
|
||||
from response: PlantNetIdentifyResponseDTO
|
||||
) -> [ViewPlantPrediction] {
|
||||
guard !response.results.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.results.map { result in
|
||||
mapToPrediction(from: result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a single PlantNet result to a view-layer prediction.
|
||||
///
|
||||
/// - Parameter result: A single identification result from the PlantNet API.
|
||||
/// - Returns: A `ViewPlantPrediction` for display.
|
||||
static func mapToPrediction(
|
||||
from result: PlantNetResultDTO
|
||||
) -> ViewPlantPrediction {
|
||||
ViewPlantPrediction(
|
||||
id: UUID(),
|
||||
speciesName: result.species.scientificNameWithoutAuthor,
|
||||
commonName: result.species.commonNames.first,
|
||||
confidence: result.score,
|
||||
genusName: result.species.genus.scientificNameWithoutAuthor,
|
||||
familyName: result.species.family.scientificNameWithoutAuthor,
|
||||
identificationSource: .plantNetAPI
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Domain Layer Mapping
|
||||
|
||||
/// Maps a PlantNet result to a domain-layer plant identification.
|
||||
///
|
||||
/// - Parameter result: A single identification result from the PlantNet API.
|
||||
/// - Returns: A `PlantIdentification` domain entity with the source set to `.plantNetAPI`.
|
||||
static func mapToIdentification(
|
||||
from result: PlantNetResultDTO
|
||||
) -> PlantIdentification {
|
||||
PlantIdentification(
|
||||
id: UUID(),
|
||||
species: result.species.scientificNameWithoutAuthor,
|
||||
confidence: result.score,
|
||||
source: .plantNetAPI,
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps a PlantNet API response to an array of domain-layer identifications.
|
||||
///
|
||||
/// - Parameter response: The complete response from the PlantNet identification API.
|
||||
/// - Returns: An array of `PlantIdentification` domain entities.
|
||||
/// Returns an empty array if the response contains no results.
|
||||
static func mapToIdentifications(
|
||||
from response: PlantNetIdentifyResponseDTO
|
||||
) -> [PlantIdentification] {
|
||||
guard !response.results.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return response.results.map { result in
|
||||
mapToIdentification(from: result)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plant Entity Mapping
|
||||
|
||||
/// Maps a PlantNet result to a complete Plant domain entity.
|
||||
///
|
||||
/// Use this when you need the full plant details including family and genus information.
|
||||
///
|
||||
/// - Parameter result: A single identification result from the PlantNet API.
|
||||
/// - Returns: A `Plant` domain entity populated with taxonomic details from the API response.
|
||||
static func mapToPlant(
|
||||
from result: PlantNetResultDTO
|
||||
) -> Plant {
|
||||
Plant(
|
||||
id: UUID(),
|
||||
scientificName: result.species.scientificNameWithoutAuthor,
|
||||
commonNames: result.species.commonNames,
|
||||
family: result.species.family.scientificNameWithoutAuthor,
|
||||
genus: result.species.genus.scientificNameWithoutAuthor,
|
||||
imageURLs: [],
|
||||
dateIdentified: Date(),
|
||||
identificationSource: .plantNetAPI
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// PredictionToPlantMapper.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - PredictionToPlantMapper
|
||||
|
||||
/// Maps ViewPlantPrediction to Plant domain entity for saving to collection.
|
||||
///
|
||||
/// This mapper converts the view-layer prediction model into a full Plant entity
|
||||
/// suitable for persistence. It handles optional fields gracefully by extracting
|
||||
/// genus from the scientific name when not explicitly provided.
|
||||
struct PredictionToPlantMapper {
|
||||
|
||||
// MARK: - Mapping
|
||||
|
||||
/// Converts a ViewPlantPrediction to a Plant domain entity.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - prediction: The prediction to convert.
|
||||
/// - localDatabaseMatch: Optional local database entry for enrichment.
|
||||
/// - Returns: A Plant entity ready for persistence.
|
||||
static func mapToPlant(
|
||||
from prediction: ViewPlantPrediction,
|
||||
localDatabaseMatch: LocalPlantEntry? = nil
|
||||
) -> Plant {
|
||||
// Use local database match for family/genus if available
|
||||
let family = prediction.familyName
|
||||
?? localDatabaseMatch?.family
|
||||
?? "Unknown"
|
||||
|
||||
let genus = prediction.genusName
|
||||
?? localDatabaseMatch?.genus
|
||||
?? extractGenus(from: prediction.speciesName)
|
||||
|
||||
// Build common names array
|
||||
var commonNames: [String] = []
|
||||
if let commonName = prediction.commonName {
|
||||
commonNames.append(commonName)
|
||||
}
|
||||
// Add additional common names from local database if available
|
||||
if let matchedNames = localDatabaseMatch?.commonNames {
|
||||
for name in matchedNames where !commonNames.contains(name) {
|
||||
commonNames.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
return Plant(
|
||||
id: UUID(),
|
||||
scientificName: prediction.speciesName,
|
||||
commonNames: commonNames,
|
||||
family: family,
|
||||
genus: genus,
|
||||
imageURLs: [],
|
||||
dateIdentified: Date(),
|
||||
identificationSource: prediction.identificationSource,
|
||||
localImagePaths: [],
|
||||
dateAdded: Date(),
|
||||
confidenceScore: prediction.confidence,
|
||||
notes: nil,
|
||||
isFavorite: false,
|
||||
customName: nil,
|
||||
location: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Extracts the genus from a scientific name (first word).
|
||||
///
|
||||
/// - Parameter scientificName: The full scientific name.
|
||||
/// - Returns: The genus (first word) or the full name if no space found.
|
||||
private static func extractGenus(from scientificName: String) -> String {
|
||||
scientificName.components(separatedBy: " ").first ?? scientificName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//
|
||||
// TrefleMapper.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - TrefleMapper
|
||||
|
||||
/// Maps Trefle API DTOs to domain entities.
|
||||
///
|
||||
/// This mapper provides conversion functions for transforming Trefle API
|
||||
/// responses into PlantCareInfo domain entities and related care requirement types.
|
||||
/// All mapping functions handle nil/missing data gracefully with sensible defaults.
|
||||
struct TrefleMapper {
|
||||
|
||||
// MARK: - Primary Mapping
|
||||
|
||||
/// Maps a Trefle species DTO to a PlantCareInfo domain entity.
|
||||
///
|
||||
/// This function extracts all available care information from the Trefle species data,
|
||||
/// including growth requirements, environmental preferences, and blooming seasons.
|
||||
///
|
||||
/// - Parameter species: The detailed species information from the Trefle API.
|
||||
/// - Returns: A `PlantCareInfo` domain entity populated with care requirements.
|
||||
static func mapToPlantCareInfo(from species: TrefleSpeciesDTO) -> PlantCareInfo {
|
||||
let growth = species.growth
|
||||
let specifications = species.specifications
|
||||
|
||||
return PlantCareInfo(
|
||||
id: UUID(),
|
||||
scientificName: species.scientificName,
|
||||
commonName: species.commonName,
|
||||
lightRequirement: mapToLightRequirement(from: growth?.light),
|
||||
wateringSchedule: mapToWateringSchedule(from: growth),
|
||||
temperatureRange: mapToTemperatureRange(from: growth),
|
||||
fertilizerSchedule: mapToFertilizerSchedule(from: growth),
|
||||
humidity: mapToHumidityLevel(from: growth?.atmosphericHumidity),
|
||||
growthRate: mapToGrowthRate(from: specifications?.growthRate),
|
||||
bloomingSeason: mapToBloomingSeason(from: growth?.bloomMonths),
|
||||
additionalNotes: buildAdditionalNotes(from: species),
|
||||
sourceURL: URL(string: "https://trefle.io/api/v1/species/\(species.id)"),
|
||||
trefleID: species.id
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Light Requirement Mapping
|
||||
|
||||
/// Maps a Trefle light scale value to a LightRequirement enum.
|
||||
///
|
||||
/// Trefle uses a 0-10 scale where 0 is full shade and 10 is full sun.
|
||||
/// This function maps that scale to the app's LightRequirement categories.
|
||||
///
|
||||
/// - Parameter light: The Trefle light value (0-10 scale), or nil if not available.
|
||||
/// - Returns: The corresponding `LightRequirement` enum value.
|
||||
/// Defaults to `.partialShade` if the input is nil.
|
||||
///
|
||||
/// Mapping:
|
||||
/// - 0-2: `.fullShade` - Very low light conditions
|
||||
/// - 3-4: `.lowLight` - Low light but not complete shade
|
||||
/// - 5-6: `.partialShade` - Moderate, indirect light
|
||||
/// - 7-10: `.fullSun` - Direct sunlight for most of the day
|
||||
static func mapToLightRequirement(from light: Int?) -> LightRequirement {
|
||||
guard let light = light else {
|
||||
return .partialShade
|
||||
}
|
||||
|
||||
switch light {
|
||||
case 0...2:
|
||||
return .fullShade
|
||||
case 3...4:
|
||||
return .lowLight
|
||||
case 5...6:
|
||||
return .partialShade
|
||||
case 7...10:
|
||||
return .fullSun
|
||||
default:
|
||||
return .partialShade
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watering Schedule Mapping
|
||||
|
||||
/// Maps Trefle growth data to a WateringSchedule.
|
||||
///
|
||||
/// This function determines watering frequency and amount based on the plant's
|
||||
/// atmospheric and soil humidity requirements. Plants with higher humidity needs
|
||||
/// typically require more frequent but lighter watering, while those with lower
|
||||
/// needs benefit from less frequent but thorough watering.
|
||||
///
|
||||
/// - Parameter growth: The Trefle growth data containing humidity requirements.
|
||||
/// - Returns: A `WateringSchedule` with appropriate frequency and amount.
|
||||
/// Defaults to weekly/moderate if growth data is nil.
|
||||
///
|
||||
/// Mapping based on humidity levels (0-10 scale):
|
||||
/// - High humidity (7-10): Weekly frequency with light watering
|
||||
/// - Medium humidity (4-6): Twice weekly with moderate watering
|
||||
/// - Low humidity (0-3): Weekly with thorough watering
|
||||
static func mapToWateringSchedule(from growth: TrefleGrowthDTO?) -> WateringSchedule {
|
||||
guard let growth = growth else {
|
||||
return WateringSchedule(frequency: .weekly, amount: .moderate)
|
||||
}
|
||||
|
||||
// Use atmospheric humidity as primary indicator, fall back to soil humidity
|
||||
let humidityLevel = growth.atmosphericHumidity ?? growth.soilHumidity
|
||||
|
||||
guard let humidity = humidityLevel else {
|
||||
return WateringSchedule(frequency: .weekly, amount: .moderate)
|
||||
}
|
||||
|
||||
switch humidity {
|
||||
case 7...10:
|
||||
// High humidity plants need frequent, light watering
|
||||
return WateringSchedule(frequency: .weekly, amount: .light)
|
||||
case 4...6:
|
||||
// Medium humidity plants need regular, moderate watering
|
||||
return WateringSchedule(frequency: .twiceWeekly, amount: .moderate)
|
||||
case 0...3:
|
||||
// Low humidity plants (often drought-tolerant) need less frequent but thorough watering
|
||||
return WateringSchedule(frequency: .weekly, amount: .thorough)
|
||||
default:
|
||||
return WateringSchedule(frequency: .weekly, amount: .moderate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Temperature Range Mapping
|
||||
|
||||
/// Maps Trefle growth data to a TemperatureRange.
|
||||
///
|
||||
/// This function extracts minimum and maximum temperature tolerances from the
|
||||
/// Trefle growth data and determines frost tolerance based on whether the plant
|
||||
/// can survive temperatures below 0 degrees Celsius.
|
||||
///
|
||||
/// - Parameter growth: The Trefle growth data containing temperature information.
|
||||
/// - Returns: A `TemperatureRange` with min/max values and frost tolerance.
|
||||
/// Defaults to 15-30 degrees Celsius if growth data is nil.
|
||||
static func mapToTemperatureRange(from growth: TrefleGrowthDTO?) -> TemperatureRange {
|
||||
guard let growth = growth else {
|
||||
return TemperatureRange(
|
||||
minimumCelsius: 15.0,
|
||||
maximumCelsius: 30.0,
|
||||
optimalCelsius: nil,
|
||||
frostTolerant: false
|
||||
)
|
||||
}
|
||||
|
||||
let minTemp = growth.minimumTemperature?.degC ?? 15.0
|
||||
let maxTemp = growth.maximumTemperature?.degC ?? 30.0
|
||||
|
||||
// Calculate optimal as midpoint between min and max if both are available
|
||||
let optimalTemp: Double?
|
||||
if growth.minimumTemperature?.degC != nil && growth.maximumTemperature?.degC != nil {
|
||||
optimalTemp = (minTemp + maxTemp) / 2.0
|
||||
} else {
|
||||
optimalTemp = nil
|
||||
}
|
||||
|
||||
// Plant is frost tolerant if it can survive temperatures below 0 degrees Celsius
|
||||
let frostTolerant = minTemp < 0.0
|
||||
|
||||
return TemperatureRange(
|
||||
minimumCelsius: minTemp,
|
||||
maximumCelsius: maxTemp,
|
||||
optimalCelsius: optimalTemp,
|
||||
frostTolerant: frostTolerant
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Fertilizer Schedule Mapping
|
||||
|
||||
/// Maps Trefle growth data to a FertilizerSchedule.
|
||||
///
|
||||
/// This function determines fertilizer frequency and type based on the plant's
|
||||
/// soil nutrient requirements. Plants with higher nutrient needs require more
|
||||
/// frequent fertilization, while those with lower needs can use organic fertilizers
|
||||
/// applied less frequently.
|
||||
///
|
||||
/// - Parameter growth: The Trefle growth data containing soil nutrient requirements.
|
||||
/// - Returns: A `FertilizerSchedule` with appropriate frequency and type,
|
||||
/// or nil if soil nutrient data is not available.
|
||||
///
|
||||
/// Mapping based on soil nutriment levels (0-10 scale):
|
||||
/// - High needs (7-10): Biweekly with balanced fertilizer
|
||||
/// - Medium needs (4-6): Monthly with balanced fertilizer
|
||||
/// - Low needs (0-3): Quarterly with organic fertilizer
|
||||
static func mapToFertilizerSchedule(from growth: TrefleGrowthDTO?) -> FertilizerSchedule? {
|
||||
guard let soilNutriments = growth?.soilNutriments else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch soilNutriments {
|
||||
case 7...10:
|
||||
// High nutrient needs - frequent balanced fertilization
|
||||
return FertilizerSchedule(frequency: .biweekly, type: .balanced)
|
||||
case 4...6:
|
||||
// Medium nutrient needs - monthly balanced fertilization
|
||||
return FertilizerSchedule(frequency: .monthly, type: .balanced)
|
||||
case 0...3:
|
||||
// Low nutrient needs - occasional organic fertilization
|
||||
return FertilizerSchedule(frequency: .quarterly, type: .organic)
|
||||
default:
|
||||
return FertilizerSchedule(frequency: .monthly, type: .balanced)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Humidity Level Mapping
|
||||
|
||||
/// Maps a Trefle atmospheric humidity value to a HumidityLevel enum.
|
||||
///
|
||||
/// Trefle uses a 0-10 scale for atmospheric humidity requirements.
|
||||
/// This function maps that scale to the app's HumidityLevel categories.
|
||||
///
|
||||
/// - Parameter humidity: The Trefle atmospheric humidity value (0-10 scale),
|
||||
/// or nil if not available.
|
||||
/// - Returns: The corresponding `HumidityLevel` enum value, or nil if input is nil.
|
||||
///
|
||||
/// Mapping:
|
||||
/// - 0-2: `.low` - Below 30% humidity
|
||||
/// - 3-5: `.moderate` - 30-50% humidity
|
||||
/// - 6-8: `.high` - 50-70% humidity
|
||||
/// - 9-10: `.veryHigh` - Above 70% humidity
|
||||
static func mapToHumidityLevel(from humidity: Int?) -> HumidityLevel? {
|
||||
guard let humidity = humidity else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch humidity {
|
||||
case 0...2:
|
||||
return .low
|
||||
case 3...5:
|
||||
return .moderate
|
||||
case 6...8:
|
||||
return .high
|
||||
case 9...10:
|
||||
return .veryHigh
|
||||
default:
|
||||
return .moderate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Growth Rate Mapping
|
||||
|
||||
/// Maps a Trefle growth rate string to a GrowthRate enum.
|
||||
///
|
||||
/// Trefle provides growth rate as a string (e.g., "slow", "moderate", "rapid").
|
||||
/// This function maps those values to the app's GrowthRate enum.
|
||||
///
|
||||
/// - Parameter growthRate: The Trefle growth rate string, or nil if not available.
|
||||
/// - Returns: The corresponding `GrowthRate` enum value, or nil if input is nil
|
||||
/// or doesn't match a known value.
|
||||
static func mapToGrowthRate(from growthRate: String?) -> GrowthRate? {
|
||||
guard let growthRate = growthRate?.lowercased() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch growthRate {
|
||||
case "slow":
|
||||
return .slow
|
||||
case "moderate", "medium":
|
||||
return .moderate
|
||||
case "rapid", "fast":
|
||||
return .fast
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blooming Season Mapping
|
||||
|
||||
/// Maps Trefle bloom months to an array of Season values.
|
||||
///
|
||||
/// Trefle provides bloom months as an array of three-letter month abbreviations
|
||||
/// (e.g., ["mar", "apr", "may"]). This function converts those months to the
|
||||
/// corresponding seasons based on Northern Hemisphere conventions.
|
||||
///
|
||||
/// - Parameter bloomMonths: An array of month abbreviations, or nil if not available.
|
||||
/// - Returns: An array of unique `Season` values when the plant blooms,
|
||||
/// or nil if bloom month data is not available.
|
||||
///
|
||||
/// Month to Season Mapping (Northern Hemisphere):
|
||||
/// - December, January, February: `.winter`
|
||||
/// - March, April, May: `.spring`
|
||||
/// - June, July, August: `.summer`
|
||||
/// - September, October, November: `.fall`
|
||||
static func mapToBloomingSeason(from bloomMonths: [String]?) -> [Season]? {
|
||||
guard let bloomMonths = bloomMonths, !bloomMonths.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var seasons = Set<Season>()
|
||||
|
||||
for month in bloomMonths {
|
||||
if let season = monthToSeason(month.lowercased()) {
|
||||
seasons.insert(season)
|
||||
}
|
||||
}
|
||||
|
||||
guard !seasons.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return seasons in natural order: spring, summer, fall, winter
|
||||
let orderedSeasons: [Season] = [.spring, .summer, .fall, .winter]
|
||||
return orderedSeasons.filter { seasons.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Converts a month abbreviation to its corresponding season.
|
||||
///
|
||||
/// - Parameter month: A three-letter month abbreviation (lowercase).
|
||||
/// - Returns: The corresponding `Season`, or nil if the abbreviation is not recognized.
|
||||
private static func monthToSeason(_ month: String) -> Season? {
|
||||
switch month {
|
||||
case "dec", "jan", "feb":
|
||||
return .winter
|
||||
case "mar", "apr", "may":
|
||||
return .spring
|
||||
case "jun", "jul", "aug":
|
||||
return .summer
|
||||
case "sep", "oct", "nov":
|
||||
return .fall
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds additional care notes from species data.
|
||||
///
|
||||
/// This function compiles relevant information that doesn't fit into other
|
||||
/// structured fields, such as pH requirements and toxicity warnings.
|
||||
///
|
||||
/// - Parameter species: The Trefle species DTO.
|
||||
/// - Returns: A string containing additional care notes, or nil if no relevant data.
|
||||
private static func buildAdditionalNotes(from species: TrefleSpeciesDTO) -> String? {
|
||||
var notes: [String] = []
|
||||
|
||||
// Add pH range information if available
|
||||
if let phMin = species.growth?.phMinimum, let phMax = species.growth?.phMaximum {
|
||||
notes.append("Soil pH: \(String(format: "%.1f", phMin)) - \(String(format: "%.1f", phMax))")
|
||||
} else if let phMin = species.growth?.phMinimum {
|
||||
notes.append("Minimum soil pH: \(String(format: "%.1f", phMin))")
|
||||
} else if let phMax = species.growth?.phMaximum {
|
||||
notes.append("Maximum soil pH: \(String(format: "%.1f", phMax))")
|
||||
}
|
||||
|
||||
// Add toxicity warning if available
|
||||
if let toxicity = species.specifications?.toxicity?.lowercased(), toxicity != "none" {
|
||||
notes.append("Toxicity: \(toxicity.capitalized)")
|
||||
}
|
||||
|
||||
// Add family information for reference
|
||||
if let family = species.family {
|
||||
notes.append("Family: \(family)")
|
||||
}
|
||||
|
||||
return notes.isEmpty ? nil : notes.joined(separator: ". ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// InMemoryCareScheduleRepository.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// In-memory implementation of CareScheduleRepositoryProtocol.
|
||||
/// Stores care schedules in memory for development and testing purposes.
|
||||
/// Can be replaced with a persistent implementation (Core Data, SwiftData, etc.) later.
|
||||
actor InMemoryCareScheduleRepository: CareScheduleRepositoryProtocol {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = InMemoryCareScheduleRepository()
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
private var schedules: [UUID: PlantCareSchedule] = [:]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - CareScheduleRepositoryProtocol
|
||||
|
||||
func save(_ schedule: PlantCareSchedule) async throws {
|
||||
schedules[schedule.plantID] = schedule
|
||||
}
|
||||
|
||||
func fetch(for plantID: UUID) async throws -> PlantCareSchedule? {
|
||||
schedules[plantID]
|
||||
}
|
||||
|
||||
func fetchAll() async throws -> [PlantCareSchedule] {
|
||||
Array(schedules.values)
|
||||
}
|
||||
|
||||
func fetchAllTasks() async throws -> [CareTask] {
|
||||
schedules.values.flatMap { $0.tasks }
|
||||
}
|
||||
|
||||
func updateTask(_ task: CareTask) async throws {
|
||||
guard var schedule = schedules[task.plantID] else {
|
||||
return
|
||||
}
|
||||
|
||||
if let taskIndex = schedule.tasks.firstIndex(where: { $0.id == task.id }) {
|
||||
schedule.tasks[taskIndex] = task
|
||||
schedules[task.plantID] = schedule
|
||||
}
|
||||
}
|
||||
|
||||
func delete(for plantID: UUID) async throws {
|
||||
schedules.removeValue(forKey: plantID)
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
func reset() {
|
||||
schedules.removeAll()
|
||||
}
|
||||
|
||||
func seedWithSampleData(plants: [Plant]) {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
for plant in plants {
|
||||
let tasks = [
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .watering,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: -2, to: now) ?? now,
|
||||
notes: "Check soil moisture first"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .fertilizing,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: -1, to: now) ?? now,
|
||||
notes: "Use half-strength fertilizer"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .watering,
|
||||
scheduledDate: now,
|
||||
notes: "Morning watering preferred"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .pruning,
|
||||
scheduledDate: now,
|
||||
notes: "Remove dead leaves"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .watering,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: 1, to: now) ?? now,
|
||||
notes: ""
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .repotting,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: 3, to: now) ?? now,
|
||||
notes: "Prepare larger pot"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .pestControl,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: 5, to: now) ?? now,
|
||||
notes: "Check for aphids"
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .watering,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: 7, to: now) ?? now,
|
||||
notes: ""
|
||||
),
|
||||
CareTask(
|
||||
plantID: plant.id,
|
||||
type: .fertilizing,
|
||||
scheduledDate: calendar.date(byAdding: .day, value: 7, to: now) ?? now,
|
||||
notes: "Monthly feeding"
|
||||
)
|
||||
]
|
||||
|
||||
let schedule = PlantCareSchedule(
|
||||
plantID: plant.id,
|
||||
lightRequirement: .partialShade,
|
||||
wateringSchedule: "Every 3 days",
|
||||
temperatureRange: 60...75,
|
||||
fertilizerSchedule: "Monthly during growing season",
|
||||
tasks: tasks
|
||||
)
|
||||
|
||||
schedules[plant.id] = schedule
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//
|
||||
// InMemoryPlantRepository.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// In-memory implementation of PlantRepositoryProtocol, PlantCollectionRepositoryProtocol,
|
||||
/// and FavoritePlantRepositoryProtocol.
|
||||
/// Stores plants in memory for development and testing purposes.
|
||||
/// Can be replaced with a persistent implementation (Core Data, SwiftData, etc.) later.
|
||||
actor InMemoryPlantRepository: PlantRepositoryProtocol, PlantCollectionRepositoryProtocol, FavoritePlantRepositoryProtocol {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = InMemoryPlantRepository()
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
private var plants: [UUID: Plant] = [:]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
// Seed with sample data for development
|
||||
seedWithSampleData()
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - PlantRepositoryProtocol
|
||||
|
||||
func save(_ plant: Plant) async throws {
|
||||
plants[plant.id] = plant
|
||||
}
|
||||
|
||||
func fetch(id: UUID) async throws -> Plant? {
|
||||
plants[id]
|
||||
}
|
||||
|
||||
func fetchAll() async throws -> [Plant] {
|
||||
Array(plants.values).sorted { $0.dateIdentified > $1.dateIdentified }
|
||||
}
|
||||
|
||||
func delete(id: UUID) async throws {
|
||||
plants.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
// MARK: - PlantCollectionRepositoryProtocol
|
||||
|
||||
func searchPlants(query: String) async throws -> [Plant] {
|
||||
let lowercasedQuery = query.lowercased()
|
||||
return Array(plants.values).filter { plant in
|
||||
plant.displayName.lowercased().contains(lowercasedQuery) ||
|
||||
plant.scientificName.lowercased().contains(lowercasedQuery) ||
|
||||
plant.family.lowercased().contains(lowercasedQuery) ||
|
||||
plant.commonNames.contains { $0.lowercased().contains(lowercasedQuery) } ||
|
||||
(plant.notes?.lowercased().contains(lowercasedQuery) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
func filterPlants(by filter: PlantFilter) async throws -> [Plant] {
|
||||
var result = Array(plants.values)
|
||||
|
||||
// Apply search query filter
|
||||
if let searchQuery = filter.searchQuery, !searchQuery.isEmpty {
|
||||
let lowercasedQuery = searchQuery.lowercased()
|
||||
result = result.filter { plant in
|
||||
plant.displayName.lowercased().contains(lowercasedQuery) ||
|
||||
plant.scientificName.lowercased().contains(lowercasedQuery) ||
|
||||
plant.family.lowercased().contains(lowercasedQuery) ||
|
||||
plant.commonNames.contains { $0.lowercased().contains(lowercasedQuery) }
|
||||
}
|
||||
}
|
||||
|
||||
// Apply family filter
|
||||
if let families = filter.families, !families.isEmpty {
|
||||
result = result.filter { families.contains($0.family) }
|
||||
}
|
||||
|
||||
// Apply favorite filter
|
||||
if let isFavorite = filter.isFavorite {
|
||||
result = result.filter { $0.isFavorite == isFavorite }
|
||||
}
|
||||
|
||||
// Apply identification source filter
|
||||
if let source = filter.identificationSource {
|
||||
result = result.filter { $0.identificationSource == source }
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
result = sortPlants(result, by: filter.sortOrder)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func getFavorites() async throws -> [Plant] {
|
||||
Array(plants.values)
|
||||
.filter { $0.isFavorite }
|
||||
.sorted { $0.dateIdentified > $1.dateIdentified }
|
||||
}
|
||||
|
||||
func setFavorite(plantID: UUID, isFavorite: Bool) async throws {
|
||||
guard var plant = plants[plantID] else {
|
||||
throw RepositoryError.notFound(id: plantID)
|
||||
}
|
||||
plant.isFavorite = isFavorite
|
||||
plants[plantID] = plant
|
||||
}
|
||||
|
||||
func updatePlant(_ plant: Plant) async throws {
|
||||
guard plants[plant.id] != nil else {
|
||||
throw RepositoryError.notFound(id: plant.id)
|
||||
}
|
||||
plants[plant.id] = plant
|
||||
}
|
||||
|
||||
func getCollectionStatistics() async throws -> CollectionStatistics {
|
||||
let allPlants = Array(plants.values)
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now
|
||||
|
||||
var familyDistribution: [String: Int] = [:]
|
||||
var sourceBreakdown: [IdentificationSource: Int] = [:]
|
||||
|
||||
for plant in allPlants {
|
||||
familyDistribution[plant.family, default: 0] += 1
|
||||
sourceBreakdown[plant.identificationSource, default: 0] += 1
|
||||
}
|
||||
|
||||
let plantsAddedThisMonth = allPlants.filter {
|
||||
if let dateAdded = $0.dateAdded {
|
||||
return dateAdded >= startOfMonth
|
||||
}
|
||||
return $0.dateIdentified >= startOfMonth
|
||||
}.count
|
||||
|
||||
return CollectionStatistics(
|
||||
totalPlants: allPlants.count,
|
||||
favoriteCount: allPlants.filter { $0.isFavorite }.count,
|
||||
familyDistribution: familyDistribution,
|
||||
identificationSourceBreakdown: sourceBreakdown,
|
||||
plantsAddedThisMonth: plantsAddedThisMonth,
|
||||
upcomingTasksCount: 0,
|
||||
overdueTasksCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - FavoritePlantRepositoryProtocol
|
||||
|
||||
func exists(id: UUID) async throws -> Bool {
|
||||
plants[id] != nil
|
||||
}
|
||||
|
||||
func isFavorite(plantID: UUID) async throws -> Bool {
|
||||
guard let plant = plants[plantID] else {
|
||||
throw RepositoryError.notFound(id: plantID)
|
||||
}
|
||||
return plant.isFavorite
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sortPlants(_ plants: [Plant], by sortOrder: PlantSortOrder) -> [Plant] {
|
||||
switch sortOrder {
|
||||
case .dateAddedDescending:
|
||||
return plants.sorted { $0.dateIdentified > $1.dateIdentified }
|
||||
case .dateAddedAscending:
|
||||
return plants.sorted { $0.dateIdentified < $1.dateIdentified }
|
||||
case .nameAscending:
|
||||
return plants.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
case .nameDescending:
|
||||
return plants.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedDescending }
|
||||
case .familyAscending:
|
||||
return plants.sorted { $0.family.localizedCaseInsensitiveCompare($1.family) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
|
||||
func reset() {
|
||||
plants.removeAll()
|
||||
}
|
||||
|
||||
func seedWithSampleData() {
|
||||
let samplePlants = [
|
||||
Plant(
|
||||
scientificName: "Monstera deliciosa",
|
||||
commonNames: ["Swiss Cheese Plant", "Split-Leaf Philodendron"],
|
||||
family: "Araceae",
|
||||
genus: "Monstera",
|
||||
imageURLs: [URL(string: "https://bs.plantnet.org/image/o/1a1c5b4e8a0bdbca4a4c4e8d5c5f6a7b8c9d0e1f")!],
|
||||
identificationSource: .onDeviceML,
|
||||
isFavorite: true
|
||||
),
|
||||
Plant(
|
||||
scientificName: "Ficus lyrata",
|
||||
commonNames: ["Fiddle Leaf Fig"],
|
||||
family: "Moraceae",
|
||||
genus: "Ficus",
|
||||
imageURLs: [URL(string: "https://bs.plantnet.org/image/o/2b2d6c5f9b1cecca5b5d5f9e6d6g7b8c9d0e1f2g")!],
|
||||
identificationSource: .onDeviceML,
|
||||
isFavorite: false
|
||||
),
|
||||
Plant(
|
||||
scientificName: "Epipremnum aureum",
|
||||
commonNames: ["Pothos", "Devil's Ivy"],
|
||||
family: "Araceae",
|
||||
genus: "Epipremnum",
|
||||
imageURLs: [URL(string: "https://bs.plantnet.org/image/o/3c3e7d6g0c2dfddb6c6e6g0f7e7h8c9d0e1f2g3h")!],
|
||||
identificationSource: .plantNetAPI,
|
||||
isFavorite: true
|
||||
),
|
||||
Plant(
|
||||
scientificName: "Sansevieria trifasciata",
|
||||
commonNames: ["Snake Plant", "Mother-in-Law's Tongue"],
|
||||
family: "Asparagaceae",
|
||||
genus: "Sansevieria",
|
||||
imageURLs: [URL(string: "https://bs.plantnet.org/image/o/4d4f8e7h1d3egec7d7f7h1g8f8i9d0e1f2g3h4i")!],
|
||||
identificationSource: .userManual,
|
||||
isFavorite: false
|
||||
),
|
||||
Plant(
|
||||
scientificName: "Calathea orbifolia",
|
||||
commonNames: ["Prayer Plant", "Round-Leaf Calathea"],
|
||||
family: "Marantaceae",
|
||||
genus: "Calathea",
|
||||
imageURLs: [URL(string: "https://bs.plantnet.org/image/o/5e5g9f8i2e4fhfd8e8g8i2h9g9j0e1f2g3h4i5j")!],
|
||||
identificationSource: .plantNetAPI,
|
||||
isFavorite: false
|
||||
)
|
||||
]
|
||||
|
||||
for plant in samplePlants {
|
||||
plants[plant.id] = plant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Repository Errors
|
||||
|
||||
/// Errors that can occur during repository operations.
|
||||
enum RepositoryError: Error, LocalizedError {
|
||||
/// The requested entity was not found.
|
||||
case notFound(id: UUID)
|
||||
|
||||
/// A general storage error occurred.
|
||||
case storageFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notFound(let id):
|
||||
return "Entity with ID \(id) was not found."
|
||||
case .storageFailed(let error):
|
||||
return "Storage operation failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// CareNotificationPreferences.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Per-plant notification preferences for care task reminders.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CareNotificationPreferences
|
||||
|
||||
/// User preferences for which care task types should trigger notifications.
|
||||
///
|
||||
/// Each plant can have its own notification preferences, allowing users to
|
||||
/// enable or disable reminders for specific types of care tasks.
|
||||
struct CareNotificationPreferences: Codable, Sendable, Equatable {
|
||||
/// Whether watering reminder notifications are enabled
|
||||
var wateringEnabled: Bool
|
||||
|
||||
/// Whether fertilizing reminder notifications are enabled
|
||||
var fertilizingEnabled: Bool
|
||||
|
||||
/// Whether repotting reminder notifications are enabled
|
||||
var repottingEnabled: Bool
|
||||
|
||||
/// Whether pruning reminder notifications are enabled
|
||||
var pruningEnabled: Bool
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new CareNotificationPreferences instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - wateringEnabled: Whether watering reminders are enabled. Defaults to true.
|
||||
/// - fertilizingEnabled: Whether fertilizing reminders are enabled. Defaults to true.
|
||||
/// - repottingEnabled: Whether repotting reminders are enabled. Defaults to false.
|
||||
/// - pruningEnabled: Whether pruning reminders are enabled. Defaults to false.
|
||||
init(
|
||||
wateringEnabled: Bool = true,
|
||||
fertilizingEnabled: Bool = true,
|
||||
repottingEnabled: Bool = false,
|
||||
pruningEnabled: Bool = false
|
||||
) {
|
||||
self.wateringEnabled = wateringEnabled
|
||||
self.fertilizingEnabled = fertilizingEnabled
|
||||
self.repottingEnabled = repottingEnabled
|
||||
self.pruningEnabled = pruningEnabled
|
||||
}
|
||||
|
||||
// MARK: - Convenience Methods
|
||||
|
||||
/// Returns whether notifications are enabled for the given task type.
|
||||
///
|
||||
/// - Parameter taskType: The type of care task to check.
|
||||
/// - Returns: `true` if notifications are enabled for this task type.
|
||||
func isEnabled(for taskType: CareTaskType) -> Bool {
|
||||
switch taskType {
|
||||
case .watering:
|
||||
return wateringEnabled
|
||||
case .fertilizing:
|
||||
return fertilizingEnabled
|
||||
case .repotting:
|
||||
return repottingEnabled
|
||||
case .pruning:
|
||||
return pruningEnabled
|
||||
case .pestControl:
|
||||
return false // Not currently supported
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a copy with the notification preference updated for the given task type.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - taskType: The type of care task to update.
|
||||
/// - enabled: Whether notifications should be enabled.
|
||||
/// - Returns: A new `CareNotificationPreferences` with the updated value.
|
||||
func setting(_ taskType: CareTaskType, enabled: Bool) -> CareNotificationPreferences {
|
||||
var updated = self
|
||||
switch taskType {
|
||||
case .watering:
|
||||
updated.wateringEnabled = enabled
|
||||
case .fertilizing:
|
||||
updated.fertilizingEnabled = enabled
|
||||
case .repotting:
|
||||
updated.repottingEnabled = enabled
|
||||
case .pruning:
|
||||
updated.pruningEnabled = enabled
|
||||
case .pestControl:
|
||||
break // Not currently supported
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// UserDefaults key prefix for storing per-plant notification preferences
|
||||
private static let keyPrefix = "care_notification_prefs_"
|
||||
|
||||
/// Saves the preferences to UserDefaults for the given plant ID.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
func save(for plantID: UUID) {
|
||||
let key = Self.keyPrefix + plantID.uuidString
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the preferences from UserDefaults for the given plant ID.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
/// - Returns: The stored preferences, or a default instance if none exist.
|
||||
static func load(for plantID: UUID) -> CareNotificationPreferences {
|
||||
let key = keyPrefix + plantID.uuidString
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let preferences = try? JSONDecoder().decode(CareNotificationPreferences.self, from: data) else {
|
||||
return CareNotificationPreferences()
|
||||
}
|
||||
return preferences
|
||||
}
|
||||
|
||||
/// Removes the stored preferences for the given plant ID.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
static func remove(for plantID: UUID) {
|
||||
let key = keyPrefix + plantID.uuidString
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CareTask
|
||||
|
||||
/// Represents a scheduled care task for a plant
|
||||
struct CareTask: Identifiable, Sendable, Equatable {
|
||||
/// Unique identifier for the care task
|
||||
let id: UUID
|
||||
|
||||
/// The ID of the plant this task belongs to
|
||||
let plantID: UUID
|
||||
|
||||
/// The type of care task to be performed
|
||||
let type: CareTaskType
|
||||
|
||||
/// The date when the task is scheduled
|
||||
let scheduledDate: Date
|
||||
|
||||
/// The date when the task was completed, nil if not yet completed
|
||||
var completedDate: Date?
|
||||
|
||||
/// Additional notes or instructions for the task
|
||||
let notes: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
plantID: UUID,
|
||||
type: CareTaskType,
|
||||
scheduledDate: Date,
|
||||
completedDate: Date? = nil,
|
||||
notes: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.plantID = plantID
|
||||
self.type = type
|
||||
self.scheduledDate = scheduledDate
|
||||
self.completedDate = completedDate
|
||||
self.notes = notes
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension CareTask {
|
||||
/// Returns true if the task has been completed
|
||||
var isCompleted: Bool {
|
||||
completedDate != nil
|
||||
}
|
||||
|
||||
/// Returns true if the task is overdue (scheduled date has passed and not completed)
|
||||
var isOverdue: Bool {
|
||||
!isCompleted && scheduledDate < Date()
|
||||
}
|
||||
|
||||
/// Returns a new CareTask marked as completed with the current date
|
||||
func completed() -> CareTask {
|
||||
CareTask(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
type: type,
|
||||
scheduledDate: scheduledDate,
|
||||
completedDate: Date(),
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new CareTask with an updated scheduled date
|
||||
func rescheduled(to newDate: Date) -> CareTask {
|
||||
CareTask(
|
||||
id: id,
|
||||
plantID: plantID,
|
||||
type: type,
|
||||
scheduledDate: newDate,
|
||||
completedDate: nil,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - IdentificationSource
|
||||
|
||||
/// Represents the source used to identify a plant
|
||||
enum IdentificationSource: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Identification performed using on-device machine learning
|
||||
case onDeviceML
|
||||
/// Identification performed using the PlantNet API
|
||||
case plantNetAPI
|
||||
/// Identification entered manually by the user
|
||||
case userManual
|
||||
}
|
||||
|
||||
// MARK: - LightRequirement
|
||||
|
||||
/// Represents the light requirements for plant care
|
||||
enum LightRequirement: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Requires direct sunlight for most of the day
|
||||
case fullSun
|
||||
/// Requires a mix of direct and indirect sunlight
|
||||
case partialShade
|
||||
/// Thrives in shaded areas with no direct sunlight
|
||||
case fullShade
|
||||
/// Can survive in low light conditions
|
||||
case lowLight
|
||||
}
|
||||
|
||||
// MARK: - CareTaskType
|
||||
|
||||
/// Represents the type of care task for a plant
|
||||
enum CareTaskType: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Watering the plant
|
||||
case watering
|
||||
/// Applying fertilizer
|
||||
case fertilizing
|
||||
/// Transferring to a new pot
|
||||
case repotting
|
||||
/// Trimming and shaping the plant
|
||||
case pruning
|
||||
/// Treating for pests or diseases
|
||||
case pestControl
|
||||
}
|
||||
|
||||
// MARK: - WateringFrequency
|
||||
|
||||
/// Represents how often a plant should be watered
|
||||
enum WateringFrequency: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Water every day
|
||||
case daily
|
||||
/// Water every other day
|
||||
case everyOtherDay
|
||||
/// Water twice per week
|
||||
case twiceWeekly
|
||||
/// Water once per week
|
||||
case weekly
|
||||
/// Water every two weeks
|
||||
case biweekly
|
||||
/// Water once per month
|
||||
case monthly
|
||||
|
||||
/// The number of days between watering sessions
|
||||
var intervalDays: Int {
|
||||
switch self {
|
||||
case .daily: return 1
|
||||
case .everyOtherDay: return 2
|
||||
case .twiceWeekly: return 3
|
||||
case .weekly: return 7
|
||||
case .biweekly: return 14
|
||||
case .monthly: return 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WateringAmount
|
||||
|
||||
/// Represents the amount of water to give a plant
|
||||
enum WateringAmount: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Light watering, just enough to moisten the top layer
|
||||
case light
|
||||
/// Moderate watering, moisten throughout
|
||||
case moderate
|
||||
/// Thorough watering, water until it drains from the bottom
|
||||
case thorough
|
||||
/// Deep soak, submerge or saturate completely
|
||||
case soak
|
||||
}
|
||||
|
||||
// MARK: - FertilizerFrequency
|
||||
|
||||
/// Represents how often a plant should be fertilized
|
||||
enum FertilizerFrequency: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Fertilize once per week
|
||||
case weekly
|
||||
/// Fertilize every two weeks
|
||||
case biweekly
|
||||
/// Fertilize once per month
|
||||
case monthly
|
||||
/// Fertilize once every three months
|
||||
case quarterly
|
||||
/// Fertilize twice per year
|
||||
case biannually
|
||||
}
|
||||
|
||||
// MARK: - FertilizerType
|
||||
|
||||
/// Represents the type of fertilizer to use
|
||||
enum FertilizerType: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Balanced NPK fertilizer (equal nitrogen, phosphorus, potassium)
|
||||
case balanced
|
||||
/// High nitrogen fertilizer for foliage growth
|
||||
case highNitrogen
|
||||
/// High phosphorus fertilizer for root and flower development
|
||||
case highPhosphorus
|
||||
/// High potassium fertilizer for overall plant health
|
||||
case highPotassium
|
||||
/// Organic fertilizer from natural sources
|
||||
case organic
|
||||
}
|
||||
|
||||
// MARK: - HumidityLevel
|
||||
|
||||
/// Represents the humidity level preferred by a plant
|
||||
enum HumidityLevel: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Low humidity (below 30%)
|
||||
case low
|
||||
/// Moderate humidity (30-50%)
|
||||
case moderate
|
||||
/// High humidity (50-70%)
|
||||
case high
|
||||
/// Very high humidity (above 70%)
|
||||
case veryHigh
|
||||
}
|
||||
|
||||
// MARK: - GrowthRate
|
||||
|
||||
/// Represents how fast a plant grows
|
||||
enum GrowthRate: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Slow growing plant
|
||||
case slow
|
||||
/// Moderate growth rate
|
||||
case moderate
|
||||
/// Fast growing plant
|
||||
case fast
|
||||
}
|
||||
|
||||
// MARK: - Season
|
||||
|
||||
/// Represents a season of the year
|
||||
enum Season: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Spring season (March-May in Northern Hemisphere)
|
||||
case spring
|
||||
/// Summer season (June-August in Northern Hemisphere)
|
||||
case summer
|
||||
/// Fall/Autumn season (September-November in Northern Hemisphere)
|
||||
case fall
|
||||
/// Winter season (December-February in Northern Hemisphere)
|
||||
case winter
|
||||
}
|
||||
|
||||
// MARK: - PlantLocation
|
||||
|
||||
/// Represents where a plant is located
|
||||
enum PlantLocation: String, Sendable, Equatable, CaseIterable, Codable {
|
||||
/// Plant is kept indoors
|
||||
case indoor
|
||||
/// Plant is kept outdoors
|
||||
case outdoor
|
||||
/// Plant is kept in a greenhouse
|
||||
case greenhouse
|
||||
/// Plant is kept on a balcony or patio
|
||||
case balcony
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Plant
|
||||
|
||||
/// Represents a plant that has been identified and saved to the user's collection.
|
||||
/// Contains both identification data and user-customizable fields for collection management.
|
||||
///
|
||||
/// Conforms to Hashable for efficient use in SwiftUI ForEach and NavigationLink.
|
||||
/// The hash is based only on the immutable `id` property for stable identity,
|
||||
/// while Equatable compares all properties for change detection.
|
||||
struct Plant: Identifiable, Sendable, Equatable, Hashable {
|
||||
|
||||
// MARK: - Core Identification Properties (Immutable)
|
||||
|
||||
/// Unique identifier for the plant
|
||||
let id: UUID
|
||||
|
||||
/// The scientific (Latin) name of the plant
|
||||
let scientificName: String
|
||||
|
||||
/// Common names for the plant in various languages or regions
|
||||
let commonNames: [String]
|
||||
|
||||
/// The botanical family the plant belongs to
|
||||
let family: String
|
||||
|
||||
/// The genus classification of the plant
|
||||
let genus: String
|
||||
|
||||
/// URLs to images of the plant (remote/API sources)
|
||||
let imageURLs: [URL]
|
||||
|
||||
/// The date when the plant was identified
|
||||
let dateIdentified: Date
|
||||
|
||||
/// The source used to identify the plant
|
||||
let identificationSource: IdentificationSource
|
||||
|
||||
// MARK: - Collection & User Properties (Mutable)
|
||||
|
||||
/// Paths to locally cached images on the device
|
||||
var localImagePaths: [String]
|
||||
|
||||
/// The date when the plant was added to the user's collection
|
||||
var dateAdded: Date?
|
||||
|
||||
/// The confidence score from the identification process (0.0 to 1.0)
|
||||
var confidenceScore: Double?
|
||||
|
||||
/// User-entered notes about this plant
|
||||
var notes: String?
|
||||
|
||||
/// Whether this plant is marked as a favorite
|
||||
var isFavorite: Bool
|
||||
|
||||
/// A custom name the user has given to this plant
|
||||
var customName: String?
|
||||
|
||||
/// Description of where the plant is located (e.g., "Living room window", "Backyard garden")
|
||||
var location: String?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Returns the best display name for the plant.
|
||||
/// Priority: customName > first common name > scientific name
|
||||
var displayName: String {
|
||||
customName ?? commonNames.first ?? scientificName
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new Plant instance.
|
||||
/// - Parameters:
|
||||
/// - id: Unique identifier for the plant. Defaults to a new UUID.
|
||||
/// - scientificName: The scientific (Latin) name of the plant.
|
||||
/// - commonNames: Common names for the plant.
|
||||
/// - family: The botanical family the plant belongs to.
|
||||
/// - genus: The genus classification of the plant.
|
||||
/// - imageURLs: URLs to remote images of the plant. Defaults to empty array.
|
||||
/// - dateIdentified: When the plant was identified. Defaults to current date.
|
||||
/// - identificationSource: The source used to identify the plant.
|
||||
/// - localImagePaths: Paths to locally cached images. Defaults to empty array.
|
||||
/// - dateAdded: When added to the collection. Defaults to nil.
|
||||
/// - confidenceScore: Identification confidence (0.0-1.0). Defaults to nil.
|
||||
/// - notes: User notes about the plant. Defaults to nil.
|
||||
/// - isFavorite: Whether marked as favorite. Defaults to false.
|
||||
/// - customName: User's custom name for the plant. Defaults to nil.
|
||||
/// - location: Where the plant is located. Defaults to nil.
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
scientificName: String,
|
||||
commonNames: [String],
|
||||
family: String,
|
||||
genus: String,
|
||||
imageURLs: [URL] = [],
|
||||
dateIdentified: Date = Date(),
|
||||
identificationSource: IdentificationSource,
|
||||
localImagePaths: [String] = [],
|
||||
dateAdded: Date? = nil,
|
||||
confidenceScore: Double? = nil,
|
||||
notes: String? = nil,
|
||||
isFavorite: Bool = false,
|
||||
customName: String? = nil,
|
||||
location: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.scientificName = scientificName
|
||||
self.commonNames = commonNames
|
||||
self.family = family
|
||||
self.genus = genus
|
||||
self.imageURLs = imageURLs
|
||||
self.dateIdentified = dateIdentified
|
||||
self.identificationSource = identificationSource
|
||||
self.localImagePaths = localImagePaths
|
||||
self.dateAdded = dateAdded
|
||||
self.confidenceScore = confidenceScore
|
||||
self.notes = notes
|
||||
self.isFavorite = isFavorite
|
||||
self.customName = customName
|
||||
self.location = location
|
||||
}
|
||||
|
||||
// MARK: - Hashable
|
||||
|
||||
/// Custom hash implementation using only the id for stable identity.
|
||||
/// This ensures consistent behavior in SwiftUI collections and navigation,
|
||||
/// where identity should remain stable even when mutable properties change.
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantCareInfo
|
||||
|
||||
/// Comprehensive care information for a plant species.
|
||||
///
|
||||
/// This struct contains all the essential care details needed to properly
|
||||
/// maintain a plant, including watering, temperature, fertilizer, and
|
||||
/// environmental requirements.
|
||||
struct PlantCareInfo: Identifiable, Sendable, Equatable {
|
||||
/// Unique identifier for the care information
|
||||
let id: UUID
|
||||
|
||||
/// The scientific (botanical) name of the plant species
|
||||
let scientificName: String
|
||||
|
||||
/// The common name of the plant, if available
|
||||
let commonName: String?
|
||||
|
||||
/// The light requirements for optimal growth
|
||||
let lightRequirement: LightRequirement
|
||||
|
||||
/// The watering schedule including frequency and amount
|
||||
let wateringSchedule: WateringSchedule
|
||||
|
||||
/// The acceptable temperature range for the plant
|
||||
let temperatureRange: TemperatureRange
|
||||
|
||||
/// The fertilizer schedule, if applicable
|
||||
let fertilizerSchedule: FertilizerSchedule?
|
||||
|
||||
/// The preferred humidity level for the plant
|
||||
let humidity: HumidityLevel?
|
||||
|
||||
/// The growth rate of the plant
|
||||
let growthRate: GrowthRate?
|
||||
|
||||
/// The seasons when the plant typically blooms
|
||||
let bloomingSeason: [Season]?
|
||||
|
||||
/// Additional care notes or special instructions
|
||||
let additionalNotes: String?
|
||||
|
||||
/// URL to the source of the care information
|
||||
let sourceURL: URL?
|
||||
|
||||
/// The Trefle API identifier for the plant, if available
|
||||
let trefleID: Int?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new PlantCareInfo instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: Unique identifier for the care information. Defaults to a new UUID.
|
||||
/// - scientificName: The scientific (botanical) name of the plant species.
|
||||
/// - commonName: The common name of the plant, if available.
|
||||
/// - lightRequirement: The light requirements for optimal growth.
|
||||
/// - wateringSchedule: The watering schedule including frequency and amount.
|
||||
/// - temperatureRange: The acceptable temperature range for the plant.
|
||||
/// - fertilizerSchedule: The fertilizer schedule, if applicable.
|
||||
/// - humidity: The preferred humidity level for the plant.
|
||||
/// - growthRate: The growth rate of the plant.
|
||||
/// - bloomingSeason: The seasons when the plant typically blooms.
|
||||
/// - additionalNotes: Additional care notes or special instructions.
|
||||
/// - sourceURL: URL to the source of the care information.
|
||||
/// - trefleID: The Trefle API identifier for the plant, if available.
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
scientificName: String,
|
||||
commonName: String? = nil,
|
||||
lightRequirement: LightRequirement,
|
||||
wateringSchedule: WateringSchedule,
|
||||
temperatureRange: TemperatureRange,
|
||||
fertilizerSchedule: FertilizerSchedule? = nil,
|
||||
humidity: HumidityLevel? = nil,
|
||||
growthRate: GrowthRate? = nil,
|
||||
bloomingSeason: [Season]? = nil,
|
||||
additionalNotes: String? = nil,
|
||||
sourceURL: URL? = nil,
|
||||
trefleID: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.scientificName = scientificName
|
||||
self.commonName = commonName
|
||||
self.lightRequirement = lightRequirement
|
||||
self.wateringSchedule = wateringSchedule
|
||||
self.temperatureRange = temperatureRange
|
||||
self.fertilizerSchedule = fertilizerSchedule
|
||||
self.humidity = humidity
|
||||
self.growthRate = growthRate
|
||||
self.bloomingSeason = bloomingSeason
|
||||
self.additionalNotes = additionalNotes
|
||||
self.sourceURL = sourceURL
|
||||
self.trefleID = trefleID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WateringSchedule
|
||||
|
||||
/// Watering schedule details for a plant.
|
||||
///
|
||||
/// Contains the base watering frequency and amount, along with optional
|
||||
/// seasonal adjustments for plants that require different watering patterns
|
||||
/// throughout the year.
|
||||
struct WateringSchedule: Codable, Sendable, Equatable {
|
||||
/// The base watering frequency
|
||||
let frequency: WateringFrequency
|
||||
|
||||
/// The amount of water to provide during each watering
|
||||
let amount: WateringAmount
|
||||
|
||||
/// Optional seasonal adjustments to the watering frequency
|
||||
let seasonalAdjustments: [Season: WateringFrequency]?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new WateringSchedule instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - frequency: The base watering frequency.
|
||||
/// - amount: The amount of water to provide during each watering.
|
||||
/// - seasonalAdjustments: Optional seasonal adjustments to the watering frequency.
|
||||
init(frequency: WateringFrequency, amount: WateringAmount, seasonalAdjustments: [Season: WateringFrequency]? = nil) {
|
||||
self.frequency = frequency
|
||||
self.amount = amount
|
||||
self.seasonalAdjustments = seasonalAdjustments
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TemperatureRange
|
||||
|
||||
/// Temperature range for plant care.
|
||||
///
|
||||
/// Defines the minimum, maximum, and optional optimal temperatures
|
||||
/// for a plant in Celsius, with computed properties for Fahrenheit conversion.
|
||||
struct TemperatureRange: Codable, Sendable, Equatable {
|
||||
/// The minimum acceptable temperature in Celsius
|
||||
let minimumCelsius: Double
|
||||
|
||||
/// The maximum acceptable temperature in Celsius
|
||||
let maximumCelsius: Double
|
||||
|
||||
/// The optimal temperature in Celsius, if known
|
||||
let optimalCelsius: Double?
|
||||
|
||||
/// Whether the plant can tolerate frost
|
||||
let frostTolerant: Bool
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new TemperatureRange instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - minimumCelsius: The minimum acceptable temperature in Celsius.
|
||||
/// - maximumCelsius: The maximum acceptable temperature in Celsius.
|
||||
/// - optimalCelsius: The optimal temperature in Celsius, if known.
|
||||
/// - frostTolerant: Whether the plant can tolerate frost. Defaults to false.
|
||||
init(minimumCelsius: Double, maximumCelsius: Double, optimalCelsius: Double? = nil, frostTolerant: Bool = false) {
|
||||
self.minimumCelsius = minimumCelsius
|
||||
self.maximumCelsius = maximumCelsius
|
||||
self.optimalCelsius = optimalCelsius
|
||||
self.frostTolerant = frostTolerant
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The minimum acceptable temperature in Fahrenheit
|
||||
var minimumFahrenheit: Double { minimumCelsius * 9/5 + 32 }
|
||||
|
||||
/// The maximum acceptable temperature in Fahrenheit
|
||||
var maximumFahrenheit: Double { maximumCelsius * 9/5 + 32 }
|
||||
|
||||
/// The optimal temperature in Fahrenheit, if known
|
||||
var optimalFahrenheit: Double? { optimalCelsius.map { $0 * 9/5 + 32 } }
|
||||
}
|
||||
|
||||
// MARK: - FertilizerSchedule
|
||||
|
||||
/// Fertilizer schedule details for a plant.
|
||||
///
|
||||
/// Contains the fertilizer frequency, type, and optional active months
|
||||
/// when fertilizing should occur.
|
||||
struct FertilizerSchedule: Codable, Sendable, Equatable {
|
||||
/// How often to fertilize
|
||||
let frequency: FertilizerFrequency
|
||||
|
||||
/// The type of fertilizer to use
|
||||
let type: FertilizerType
|
||||
|
||||
/// The months (1-12) when fertilizing should occur, if not year-round
|
||||
let activeMonths: [Int]?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new FertilizerSchedule instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - frequency: How often to fertilize.
|
||||
/// - type: The type of fertilizer to use.
|
||||
/// - activeMonths: The months (1-12) when fertilizing should occur, if not year-round.
|
||||
init(frequency: FertilizerFrequency, type: FertilizerType, activeMonths: [Int]? = nil) {
|
||||
self.frequency = frequency
|
||||
self.type = type
|
||||
self.activeMonths = activeMonths
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CarePreferences
|
||||
|
||||
/// User preferences for care scheduling.
|
||||
///
|
||||
/// Contains customizable settings for how care reminders and schedules
|
||||
/// should be generated and displayed to the user.
|
||||
struct CarePreferences: Codable, Sendable, Equatable {
|
||||
/// The preferred hour for watering reminders (0-23)
|
||||
let preferredWateringHour: Int
|
||||
|
||||
/// The preferred minute for watering reminders (0-59)
|
||||
let preferredWateringMinute: Int
|
||||
|
||||
/// Number of days before a task to send a reminder
|
||||
let reminderDaysBefore: Int
|
||||
|
||||
/// Whether to automatically adjust care schedules based on the current season
|
||||
let adjustForSeason: Bool
|
||||
|
||||
/// The location where plants are kept
|
||||
let location: PlantLocation
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new CarePreferences instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - preferredWateringHour: The preferred hour for watering reminders (0-23). Defaults to 8.
|
||||
/// - preferredWateringMinute: The preferred minute for watering reminders (0-59). Defaults to 0.
|
||||
/// - reminderDaysBefore: Number of days before a task to send a reminder. Defaults to 0.
|
||||
/// - adjustForSeason: Whether to automatically adjust care schedules based on the current season. Defaults to true.
|
||||
/// - location: The location where plants are kept. Defaults to indoor.
|
||||
init(
|
||||
preferredWateringHour: Int = 8,
|
||||
preferredWateringMinute: Int = 0,
|
||||
reminderDaysBefore: Int = 0,
|
||||
adjustForSeason: Bool = true,
|
||||
location: PlantLocation = .indoor
|
||||
) {
|
||||
self.preferredWateringHour = preferredWateringHour
|
||||
self.preferredWateringMinute = preferredWateringMinute
|
||||
self.reminderDaysBefore = reminderDaysBefore
|
||||
self.adjustForSeason = adjustForSeason
|
||||
self.location = location
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantCareSchedule
|
||||
|
||||
/// Represents the care schedule and requirements for a plant
|
||||
struct PlantCareSchedule: Identifiable, Sendable, Equatable {
|
||||
/// Unique identifier for the care schedule
|
||||
let id: UUID
|
||||
|
||||
/// The ID of the plant this schedule belongs to
|
||||
let plantID: UUID
|
||||
|
||||
/// The light requirements for the plant
|
||||
let lightRequirement: LightRequirement
|
||||
|
||||
/// Description of the watering schedule (e.g., "Every 3 days", "Weekly")
|
||||
let wateringSchedule: String
|
||||
|
||||
/// The acceptable temperature range in degrees (Fahrenheit or Celsius based on user preference)
|
||||
let temperatureRange: ClosedRange<Int>
|
||||
|
||||
/// Description of the fertilizer schedule (e.g., "Monthly during growing season")
|
||||
let fertilizerSchedule: String
|
||||
|
||||
/// List of scheduled care tasks for this plant
|
||||
var tasks: [CareTask]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
plantID: UUID,
|
||||
lightRequirement: LightRequirement,
|
||||
wateringSchedule: String,
|
||||
temperatureRange: ClosedRange<Int>,
|
||||
fertilizerSchedule: String,
|
||||
tasks: [CareTask] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.plantID = plantID
|
||||
self.lightRequirement = lightRequirement
|
||||
self.wateringSchedule = wateringSchedule
|
||||
self.temperatureRange = temperatureRange
|
||||
self.fertilizerSchedule = fertilizerSchedule
|
||||
self.tasks = tasks
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension PlantCareSchedule {
|
||||
/// Returns all pending (incomplete) tasks sorted by scheduled date
|
||||
var pendingTasks: [CareTask] {
|
||||
tasks
|
||||
.filter { !$0.isCompleted }
|
||||
.sorted { $0.scheduledDate < $1.scheduledDate }
|
||||
}
|
||||
|
||||
/// Returns all overdue tasks
|
||||
var overdueTasks: [CareTask] {
|
||||
tasks.filter { $0.isOverdue }
|
||||
}
|
||||
|
||||
/// Returns all completed tasks sorted by completion date (most recent first)
|
||||
var completedTasks: [CareTask] {
|
||||
tasks
|
||||
.filter { $0.isCompleted }
|
||||
.sorted { ($0.completedDate ?? .distantPast) > ($1.completedDate ?? .distantPast) }
|
||||
}
|
||||
|
||||
/// Returns the next upcoming task, if any
|
||||
var nextTask: CareTask? {
|
||||
pendingTasks.first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantIdentification
|
||||
|
||||
/// Represents the result of a plant identification attempt
|
||||
struct PlantIdentification: Identifiable, Sendable, Equatable {
|
||||
/// Unique identifier for the identification result
|
||||
let id: UUID
|
||||
|
||||
/// The identified species name
|
||||
let species: String
|
||||
|
||||
/// Confidence level of the identification (0.0 to 1.0)
|
||||
let confidence: Double
|
||||
|
||||
/// The source that performed the identification
|
||||
let source: IdentificationSource
|
||||
|
||||
/// When the identification was performed
|
||||
let timestamp: Date
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
species: String,
|
||||
confidence: Double,
|
||||
source: IdentificationSource,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.species = species
|
||||
self.confidence = min(max(confidence, 0.0), 1.0) // Clamp to valid range
|
||||
self.source = source
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension PlantIdentification {
|
||||
/// Returns true if the identification has high confidence (>= 80%)
|
||||
var isHighConfidence: Bool {
|
||||
confidence >= 0.8
|
||||
}
|
||||
|
||||
/// Returns the confidence as a percentage string
|
||||
var confidencePercentage: String {
|
||||
String(format: "%.1f%%", confidence * 100)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// CareScheduleRepositoryProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Repository protocol defining the data access contract for PlantCareSchedule entities.
|
||||
/// Implementations handle persistence operations for plant care scheduling data.
|
||||
protocol CareScheduleRepositoryProtocol: Sendable {
|
||||
|
||||
/// Saves a care schedule to the repository.
|
||||
/// - Parameter schedule: The care schedule entity to save.
|
||||
/// - Throws: An error if the save operation fails.
|
||||
func save(_ schedule: PlantCareSchedule) async throws
|
||||
|
||||
/// Fetches the care schedule for a specific plant.
|
||||
/// - Parameter plantID: The unique identifier of the plant whose schedule to fetch.
|
||||
/// - Returns: The care schedule if found, or nil if no schedule exists for the given plant.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetch(for plantID: UUID) async throws -> PlantCareSchedule?
|
||||
|
||||
/// Fetches all care schedules from the repository.
|
||||
/// - Returns: An array of all stored care schedules.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetchAll() async throws -> [PlantCareSchedule]
|
||||
|
||||
/// Fetches all care tasks across all schedules.
|
||||
/// - Returns: An array of all care tasks.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetchAllTasks() async throws -> [CareTask]
|
||||
|
||||
/// Updates a specific care task in the repository.
|
||||
/// - Parameter task: The updated care task.
|
||||
/// - Throws: An error if the update operation fails.
|
||||
func updateTask(_ task: CareTask) async throws
|
||||
|
||||
/// Deletes the care schedule for a specific plant.
|
||||
/// - Parameter plantID: The unique identifier of the plant whose schedule to delete.
|
||||
/// - Throws: An error if the delete operation fails.
|
||||
func delete(for plantID: UUID) async throws
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// IdentificationRepositoryProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Repository protocol defining the data access contract for PlantIdentification entities.
|
||||
/// Implementations handle persistence operations for plant identification history and results.
|
||||
protocol IdentificationRepositoryProtocol: Sendable {
|
||||
|
||||
/// Saves a plant identification to the repository.
|
||||
/// - Parameter identification: The plant identification entity to save.
|
||||
/// - Throws: An error if the save operation fails.
|
||||
func save(_ identification: PlantIdentification) async throws
|
||||
|
||||
/// Fetches the most recent plant identifications.
|
||||
/// - Parameter limit: The maximum number of identifications to return.
|
||||
/// - Returns: An array of recent plant identifications, ordered by most recent first.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetchRecent(limit: Int) async throws -> [PlantIdentification]
|
||||
|
||||
/// Fetches all plant identifications from the repository.
|
||||
/// - Returns: An array of all stored plant identifications.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetchAll() async throws -> [PlantIdentification]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// PlantCareInfoRepositoryProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Repository protocol for caching PlantCareInfo from Trefle API.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantCareInfoRepositoryProtocol
|
||||
|
||||
/// Repository protocol for managing cached PlantCareInfo data.
|
||||
///
|
||||
/// This protocol defines operations for persisting and retrieving plant care information
|
||||
/// that has been fetched from the Trefle API. Caching reduces unnecessary API calls
|
||||
/// and preserves care timing data for notification scheduling.
|
||||
protocol PlantCareInfoRepositoryProtocol: Sendable {
|
||||
|
||||
/// Fetches cached care info by scientific name.
|
||||
///
|
||||
/// - Parameter scientificName: The scientific (botanical) name of the plant.
|
||||
/// - Returns: The cached PlantCareInfo if found, nil otherwise.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetch(scientificName: String) async throws -> PlantCareInfo?
|
||||
|
||||
/// Fetches cached care info by Trefle API ID.
|
||||
///
|
||||
/// - Parameter trefleID: The numeric identifier for the species in the Trefle database.
|
||||
/// - Returns: The cached PlantCareInfo if found, nil otherwise.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetch(trefleID: Int) async throws -> PlantCareInfo?
|
||||
|
||||
/// Fetches cached care info associated with a specific plant.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
/// - Returns: The cached PlantCareInfo if the plant has associated care info, nil otherwise.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetch(for plantID: UUID) async throws -> PlantCareInfo?
|
||||
|
||||
/// Saves care info to the cache, optionally associating it with a plant.
|
||||
///
|
||||
/// If care info with the same scientific name already exists, it will be updated.
|
||||
/// If plantID is provided and found, the care info will be linked to that plant.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - careInfo: The PlantCareInfo to cache.
|
||||
/// - plantID: Optional plant ID to associate the care info with.
|
||||
/// - Throws: An error if the save operation fails.
|
||||
func save(_ careInfo: PlantCareInfo, for plantID: UUID?) async throws
|
||||
|
||||
/// Checks if the cached care info for a plant is stale.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - scientificName: The scientific name of the plant to check.
|
||||
/// - cacheExpiration: The maximum age of the cache in seconds before it's considered stale.
|
||||
/// - Returns: true if the cache is stale or doesn't exist, false if the cache is fresh.
|
||||
/// - Throws: An error if the check operation fails.
|
||||
func isCacheStale(scientificName: String, cacheExpiration: TimeInterval) async throws -> Bool
|
||||
|
||||
/// Deletes cached care info associated with a plant.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant whose care info should be deleted.
|
||||
/// - Throws: An error if the delete operation fails.
|
||||
func delete(for plantID: UUID) async throws
|
||||
}
|
||||
|
||||
// MARK: - Default Cache Expiration
|
||||
|
||||
extension PlantCareInfoRepositoryProtocol {
|
||||
|
||||
/// Default cache expiration of 7 days in seconds.
|
||||
static var defaultCacheExpiration: TimeInterval {
|
||||
7 * 24 * 60 * 60 // 7 days
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// PlantCollectionRepositoryProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - PlantSortOrder
|
||||
|
||||
/// Sort order options for plant collections.
|
||||
/// Combines sort field and direction into a single enum for convenience.
|
||||
enum PlantSortOrder: Sendable, Equatable {
|
||||
case dateAddedDescending
|
||||
case dateAddedAscending
|
||||
case nameAscending
|
||||
case nameDescending
|
||||
case familyAscending
|
||||
}
|
||||
|
||||
// MARK: - PlantFilter
|
||||
|
||||
/// Filter configuration for querying plants in the collection.
|
||||
/// Supports multiple filter criteria that can be combined for advanced searching.
|
||||
struct PlantFilter: Sendable, Equatable {
|
||||
|
||||
/// Text query to search in plant names, scientific names, and notes
|
||||
var searchQuery: String?
|
||||
|
||||
/// Filter by specific botanical families
|
||||
var families: Set<String>?
|
||||
|
||||
/// Filter by light requirements
|
||||
var lightRequirements: Set<LightRequirement>?
|
||||
|
||||
/// Filter to show only favorites (true), only non-favorites (false), or all (nil)
|
||||
var isFavorite: Bool?
|
||||
|
||||
/// Filter by the source used for identification
|
||||
var identificationSource: IdentificationSource?
|
||||
|
||||
/// The field to sort results by
|
||||
var sortBy: SortOption = .dateAdded
|
||||
|
||||
/// Whether to sort in ascending order (false = descending/newest first)
|
||||
var sortAscending: Bool = false
|
||||
|
||||
// MARK: - SortOption
|
||||
|
||||
/// Options for sorting plant collection results
|
||||
enum SortOption: String, CaseIterable, Sendable {
|
||||
/// Sort by the date the plant was added to the collection
|
||||
case dateAdded
|
||||
/// Sort by the date the plant was identified
|
||||
case dateIdentified
|
||||
/// Sort by the display name of the plant
|
||||
case name
|
||||
/// Sort by the botanical family name
|
||||
case family
|
||||
|
||||
/// Human-readable name for display in the UI
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .dateAdded: return "Date Added"
|
||||
case .dateIdentified: return "Date Identified"
|
||||
case .name: return "Name"
|
||||
case .family: return "Family"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Converts sortBy and sortAscending into a PlantSortOrder enum value.
|
||||
var sortOrder: PlantSortOrder {
|
||||
switch (sortBy, sortAscending) {
|
||||
case (.dateAdded, false), (.dateIdentified, false):
|
||||
return .dateAddedDescending
|
||||
case (.dateAdded, true), (.dateIdentified, true):
|
||||
return .dateAddedAscending
|
||||
case (.name, true):
|
||||
return .nameAscending
|
||||
case (.name, false):
|
||||
return .nameDescending
|
||||
case (.family, _):
|
||||
return .familyAscending
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default Filter
|
||||
|
||||
/// Default filter configuration showing all plants sorted by date added (newest first)
|
||||
static let `default` = PlantFilter()
|
||||
}
|
||||
|
||||
// MARK: - CollectionStatistics
|
||||
|
||||
/// Statistics about the user's plant collection.
|
||||
/// Provides aggregate data for dashboard displays and insights.
|
||||
struct CollectionStatistics: Sendable, Equatable {
|
||||
|
||||
/// Total number of plants in the collection
|
||||
let totalPlants: Int
|
||||
|
||||
/// Number of plants marked as favorites
|
||||
let favoriteCount: Int
|
||||
|
||||
/// Distribution of plants by botanical family (family name -> count)
|
||||
let familyDistribution: [String: Int]
|
||||
|
||||
/// Breakdown of plants by how they were identified
|
||||
let identificationSourceBreakdown: [IdentificationSource: Int]
|
||||
|
||||
/// Number of plants added in the current calendar month
|
||||
let plantsAddedThisMonth: Int
|
||||
|
||||
/// Number of care tasks scheduled in the near future
|
||||
let upcomingTasksCount: Int
|
||||
|
||||
/// Number of care tasks that are past their due date
|
||||
let overdueTasksCount: Int
|
||||
}
|
||||
|
||||
// MARK: - PlantCollectionRepositoryProtocol
|
||||
|
||||
/// Extended repository protocol for managing a user's plant collection.
|
||||
/// Inherits all basic CRUD operations from PlantRepositoryProtocol and adds
|
||||
/// advanced querying, filtering, and collection management capabilities.
|
||||
protocol PlantCollectionRepositoryProtocol: PlantRepositoryProtocol {
|
||||
|
||||
// MARK: - Search & Filter
|
||||
|
||||
/// Searches plants by a text query.
|
||||
/// Searches across plant names, scientific names, common names, and notes.
|
||||
/// - Parameter query: The search text to match against plant data.
|
||||
/// - Returns: An array of plants matching the search query.
|
||||
/// - Throws: An error if the search operation fails.
|
||||
func searchPlants(query: String) async throws -> [Plant]
|
||||
|
||||
/// Filters plants based on the provided filter configuration.
|
||||
/// Supports combining multiple filter criteria and sorting options.
|
||||
/// - Parameter filter: The filter configuration to apply.
|
||||
/// - Returns: An array of plants matching all specified filter criteria.
|
||||
/// - Throws: An error if the filter operation fails.
|
||||
func filterPlants(by filter: PlantFilter) async throws -> [Plant]
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
/// Retrieves all plants marked as favorites.
|
||||
/// - Returns: An array of favorite plants, sorted by date added (newest first).
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func getFavorites() async throws -> [Plant]
|
||||
|
||||
/// Updates the favorite status of a plant.
|
||||
/// - Parameters:
|
||||
/// - plantID: The unique identifier of the plant to update.
|
||||
/// - isFavorite: The new favorite status (true = favorite, false = not favorite).
|
||||
/// - Throws: An error if the update operation fails or if the plant is not found.
|
||||
func setFavorite(plantID: UUID, isFavorite: Bool) async throws
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
/// Updates an existing plant in the repository.
|
||||
/// Use this method to save changes to mutable plant properties such as
|
||||
/// notes, custom name, location, or local image paths.
|
||||
/// - Parameter plant: The plant with updated values to save.
|
||||
/// - Throws: An error if the update operation fails or if the plant is not found.
|
||||
func updatePlant(_ plant: Plant) async throws
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
/// Retrieves aggregate statistics about the plant collection.
|
||||
/// Provides data for dashboard displays and collection insights.
|
||||
/// - Returns: A CollectionStatistics object containing aggregate data.
|
||||
/// - Throws: An error if the statistics calculation fails.
|
||||
func getCollectionStatistics() async throws -> CollectionStatistics
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// PlantRepositoryProtocol.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created for PlantGuide plant identification app.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Repository protocol defining the data access contract for Plant entities.
|
||||
/// Implementations handle persistence operations for plant data.
|
||||
protocol PlantRepositoryProtocol: Sendable {
|
||||
|
||||
/// Saves a plant to the repository.
|
||||
/// - Parameter plant: The plant entity to save.
|
||||
/// - Throws: An error if the save operation fails.
|
||||
func save(_ plant: Plant) async throws
|
||||
|
||||
/// Fetches a plant by its unique identifier.
|
||||
/// - Parameter id: The unique identifier of the plant to fetch.
|
||||
/// - Returns: The plant if found, or nil if no plant exists with the given ID.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetch(id: UUID) async throws -> Plant?
|
||||
|
||||
/// Fetches all plants from the repository.
|
||||
/// - Returns: An array of all stored plants.
|
||||
/// - Throws: An error if the fetch operation fails.
|
||||
func fetchAll() async throws -> [Plant]
|
||||
|
||||
/// Deletes a plant by its unique identifier.
|
||||
/// - Parameter id: The unique identifier of the plant to delete.
|
||||
/// - Throws: An error if the delete operation fails.
|
||||
func delete(id: UUID) async throws
|
||||
|
||||
/// Checks if a plant exists with the given identifier.
|
||||
/// - Parameter id: The unique identifier of the plant to check.
|
||||
/// - Returns: True if the plant exists, false otherwise.
|
||||
/// - Throws: An error if the check operation fails.
|
||||
func exists(id: UUID) async throws -> Bool
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// DeletePlantUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - DeletePlantUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for deleting plants from the user's collection.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations coordinate the complete deletion workflow including cleanup
|
||||
/// of associated data (images, care schedules, notifications).
|
||||
protocol DeletePlantUseCaseProtocol: Sendable {
|
||||
/// Deletes a plant and all associated data from the collection.
|
||||
///
|
||||
/// This method performs the following cleanup operations:
|
||||
/// 1. Cancels all scheduled notifications for the plant
|
||||
/// 2. Deletes cached/stored images associated with the plant
|
||||
/// 3. Deletes the plant from the repository (which cascades to care schedule)
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant to delete.
|
||||
/// - Throws: `DeletePlantError` if the deletion fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// try await useCase.execute(plantID: plant.id)
|
||||
/// print("Plant deleted successfully")
|
||||
/// ```
|
||||
func execute(plantID: UUID) async throws
|
||||
}
|
||||
|
||||
// MARK: - DeletePlantError
|
||||
|
||||
/// Errors that can occur when deleting a plant from the collection.
|
||||
///
|
||||
/// These errors provide specific context for deletion failures,
|
||||
/// enabling appropriate error handling and user messaging.
|
||||
enum DeletePlantError: Error, LocalizedError {
|
||||
/// The plant with the specified ID was not found.
|
||||
case plantNotFound(plantID: UUID)
|
||||
|
||||
/// Failed to delete the plant from the repository.
|
||||
case repositoryDeleteFailed(Error)
|
||||
|
||||
/// Failed to cancel notifications for the plant.
|
||||
case notificationCancellationFailed(Error)
|
||||
|
||||
/// Failed to delete cached images for the plant.
|
||||
case imageDeletionFailed(Error)
|
||||
|
||||
/// Failed to delete the care schedule for the plant.
|
||||
case careScheduleDeletionFailed(Error)
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .plantNotFound(let plantID):
|
||||
return "Plant not found: \(plantID)"
|
||||
case .repositoryDeleteFailed(let error):
|
||||
return "Failed to delete plant: \(error.localizedDescription)"
|
||||
case .notificationCancellationFailed(let error):
|
||||
return "Failed to cancel reminders: \(error.localizedDescription)"
|
||||
case .imageDeletionFailed(let error):
|
||||
return "Failed to delete plant images: \(error.localizedDescription)"
|
||||
case .careScheduleDeletionFailed(let error):
|
||||
return "Failed to delete care schedule: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "The plant may have already been deleted."
|
||||
case .repositoryDeleteFailed:
|
||||
return "The plant data could not be removed from storage."
|
||||
case .notificationCancellationFailed:
|
||||
return "Scheduled reminders could not be cancelled."
|
||||
case .imageDeletionFailed:
|
||||
return "Plant images could not be removed from the device."
|
||||
case .careScheduleDeletionFailed:
|
||||
return "The care schedule could not be removed."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "Refresh the collection to see current plants."
|
||||
case .repositoryDeleteFailed:
|
||||
return "Please try again. If the problem persists, restart the app."
|
||||
case .notificationCancellationFailed:
|
||||
return "You may need to manually dismiss any remaining notifications."
|
||||
case .imageDeletionFailed:
|
||||
return "Images may need to be cleaned up manually in Settings."
|
||||
case .careScheduleDeletionFailed:
|
||||
return "Please try deleting the plant again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DeletePlantUseCase
|
||||
|
||||
/// Use case for deleting plants from the user's collection.
|
||||
///
|
||||
/// This use case coordinates the complete plant deletion workflow:
|
||||
/// 1. Validates that the plant exists
|
||||
/// 2. Cancels all scheduled notifications for the plant
|
||||
/// 3. Deletes cached images from local storage
|
||||
/// 4. Deletes the care schedule
|
||||
/// 5. Deletes the plant entity from the repository
|
||||
///
|
||||
/// The deletion is performed in order to ensure proper cleanup even if
|
||||
/// some operations fail. The plant is deleted last to ensure all associated
|
||||
/// data is cleaned up first.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = DeletePlantUseCase(
|
||||
/// plantRepository: plantRepository,
|
||||
/// imageStorage: imageStorage,
|
||||
/// notificationService: notificationService,
|
||||
/// careScheduleRepository: careScheduleRepository
|
||||
/// )
|
||||
///
|
||||
/// try await useCase.execute(plantID: plant.id)
|
||||
/// ```
|
||||
final class DeletePlantUseCase: DeletePlantUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let plantRepository: PlantCollectionRepositoryProtocol
|
||||
private let imageStorage: ImageStorageProtocol
|
||||
private let notificationService: NotificationServiceProtocol
|
||||
private let careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new DeletePlantUseCase instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantRepository: Repository for accessing and deleting plant entities.
|
||||
/// - imageStorage: Service for deleting stored plant images.
|
||||
/// - notificationService: Service for cancelling scheduled notifications.
|
||||
/// - careScheduleRepository: Repository for deleting care schedules.
|
||||
init(
|
||||
plantRepository: PlantCollectionRepositoryProtocol,
|
||||
imageStorage: ImageStorageProtocol,
|
||||
notificationService: NotificationServiceProtocol,
|
||||
careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
) {
|
||||
self.plantRepository = plantRepository
|
||||
self.imageStorage = imageStorage
|
||||
self.notificationService = notificationService
|
||||
self.careScheduleRepository = careScheduleRepository
|
||||
}
|
||||
|
||||
// MARK: - DeletePlantUseCaseProtocol
|
||||
|
||||
func execute(plantID: UUID) async throws {
|
||||
// Step 1: Verify the plant exists
|
||||
guard try await plantRepository.exists(id: plantID) else {
|
||||
throw DeletePlantError.plantNotFound(plantID: plantID)
|
||||
}
|
||||
|
||||
// Step 2: Cancel all notifications for the plant
|
||||
// This is non-blocking - we don't want notification failures to prevent deletion
|
||||
await cancelNotifications(for: plantID)
|
||||
|
||||
// Step 3: Delete cached images
|
||||
// Log errors but continue with deletion
|
||||
await deleteImages(for: plantID)
|
||||
|
||||
// Step 4: Delete care schedule
|
||||
// This should cascade from the repository but we explicitly delete for safety
|
||||
await deleteCareSchedule(for: plantID)
|
||||
|
||||
// Step 5: Delete the plant from repository
|
||||
do {
|
||||
try await plantRepository.delete(id: plantID)
|
||||
} catch {
|
||||
throw DeletePlantError.repositoryDeleteFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Cancels all scheduled notifications for a plant.
|
||||
///
|
||||
/// This method is intentionally non-throwing to prevent notification
|
||||
/// failures from blocking plant deletion.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
private func cancelNotifications(for plantID: UUID) async {
|
||||
await notificationService.cancelAllReminders(for: plantID)
|
||||
}
|
||||
|
||||
/// Deletes all cached images for a plant.
|
||||
///
|
||||
/// Errors are logged but do not prevent plant deletion.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
private func deleteImages(for plantID: UUID) async {
|
||||
do {
|
||||
try await imageStorage.deleteAll(for: plantID)
|
||||
} catch {
|
||||
// Log the error but don't prevent deletion
|
||||
print("Warning: Failed to delete images for plant \(plantID): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the care schedule for a plant.
|
||||
///
|
||||
/// Errors are logged but do not prevent plant deletion.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
private func deleteCareSchedule(for plantID: UUID) async {
|
||||
do {
|
||||
try await careScheduleRepository.delete(for: plantID)
|
||||
} catch {
|
||||
// Log the error but don't prevent deletion
|
||||
print("Warning: Failed to delete care schedule for plant \(plantID): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// FetchCollectionUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - FetchCollectionUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for fetching plants from the user's collection.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations retrieve plants from the repository with support for filtering
|
||||
/// and statistics aggregation.
|
||||
protocol FetchCollectionUseCaseProtocol: Sendable {
|
||||
/// Fetches all plants in the user's collection.
|
||||
///
|
||||
/// Returns plants sorted by date added (most recent first).
|
||||
///
|
||||
/// - Returns: An array of all plants in the collection.
|
||||
/// - Throws: `FetchCollectionError` if the fetch operation fails.
|
||||
func execute() async throws -> [Plant]
|
||||
|
||||
/// Fetches plants matching the specified filter criteria.
|
||||
///
|
||||
/// - Parameter filter: The filter criteria to apply (favorites, search, date range, etc.).
|
||||
/// - Returns: An array of plants matching the filter, sorted appropriately.
|
||||
/// - Throws: `FetchCollectionError` if the fetch operation fails.
|
||||
func execute(filter: PlantFilter) async throws -> [Plant]
|
||||
|
||||
/// Fetches aggregate statistics about the plant collection.
|
||||
///
|
||||
/// Statistics include total count, favorites count, plants needing care, etc.
|
||||
///
|
||||
/// - Returns: A `CollectionStatistics` object containing aggregate data.
|
||||
/// - Throws: `FetchCollectionError` if the calculation fails.
|
||||
func fetchStatistics() async throws -> CollectionStatistics
|
||||
}
|
||||
|
||||
// MARK: - FetchCollectionError
|
||||
|
||||
/// Errors that can occur when fetching plants from the collection.
|
||||
enum FetchCollectionError: Error, LocalizedError {
|
||||
/// Failed to fetch plants from the repository.
|
||||
case repositoryFetchFailed(Error)
|
||||
|
||||
/// Failed to calculate collection statistics.
|
||||
case statisticsCalculationFailed(Error)
|
||||
|
||||
/// The filter criteria is invalid.
|
||||
case invalidFilter(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .repositoryFetchFailed(let error):
|
||||
return "Failed to load plants: \(error.localizedDescription)"
|
||||
case .statisticsCalculationFailed(let error):
|
||||
return "Failed to calculate statistics: \(error.localizedDescription)"
|
||||
case .invalidFilter(let reason):
|
||||
return "Invalid filter: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FetchCollectionUseCase
|
||||
|
||||
/// Use case for fetching plants from the user's collection.
|
||||
///
|
||||
/// This use case provides various methods to retrieve plants from the collection:
|
||||
/// - Fetch all plants sorted by date added
|
||||
/// - Fetch plants matching specific filter criteria
|
||||
/// - Calculate aggregate statistics about the collection
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = FetchCollectionUseCase(
|
||||
/// plantRepository: plantRepository,
|
||||
/// careScheduleRepository: careScheduleRepository
|
||||
/// )
|
||||
///
|
||||
/// // Fetch all plants
|
||||
/// let allPlants = try await useCase.execute()
|
||||
///
|
||||
/// // Fetch favorites only
|
||||
/// let favorites = try await useCase.execute(filter: .favorites)
|
||||
///
|
||||
/// // Get statistics
|
||||
/// let stats = try await useCase.fetchStatistics()
|
||||
/// ```
|
||||
final class FetchCollectionUseCase: FetchCollectionUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let plantRepository: PlantCollectionRepositoryProtocol
|
||||
private let careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new FetchCollectionUseCase instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantRepository: Repository for accessing plant entities.
|
||||
/// - careScheduleRepository: Repository for accessing care schedules.
|
||||
init(
|
||||
plantRepository: PlantCollectionRepositoryProtocol,
|
||||
careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
) {
|
||||
self.plantRepository = plantRepository
|
||||
self.careScheduleRepository = careScheduleRepository
|
||||
}
|
||||
|
||||
// MARK: - FetchCollectionUseCaseProtocol
|
||||
|
||||
func execute() async throws -> [Plant] {
|
||||
do {
|
||||
let plants = try await plantRepository.fetchAll()
|
||||
return sortPlants(plants, by: .dateAddedDescending)
|
||||
} catch {
|
||||
throw FetchCollectionError.repositoryFetchFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
func execute(filter: PlantFilter) async throws -> [Plant] {
|
||||
do {
|
||||
// Fetch filtered plants from repository
|
||||
let plants = try await plantRepository.filterPlants(by: filter)
|
||||
return sortPlants(plants, by: filter.sortOrder)
|
||||
} catch let error as FetchCollectionError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchCollectionError.repositoryFetchFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStatistics() async throws -> CollectionStatistics {
|
||||
do {
|
||||
// Delegate to repository for statistics calculation
|
||||
return try await plantRepository.getCollectionStatistics()
|
||||
} catch {
|
||||
throw FetchCollectionError.statisticsCalculationFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Sorts plants according to the specified sort order.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plants: The plants to sort.
|
||||
/// - sortOrder: The sort order to apply.
|
||||
/// - Returns: The sorted array of plants.
|
||||
private func sortPlants(_ plants: [Plant], by sortOrder: PlantSortOrder) -> [Plant] {
|
||||
switch sortOrder {
|
||||
case .dateAddedDescending:
|
||||
return plants.sorted { $0.dateIdentified > $1.dateIdentified }
|
||||
case .dateAddedAscending:
|
||||
return plants.sorted { $0.dateIdentified < $1.dateIdentified }
|
||||
case .nameAscending:
|
||||
return plants.sorted { $0.scientificName.localizedCaseInsensitiveCompare($1.scientificName) == .orderedAscending }
|
||||
case .nameDescending:
|
||||
return plants.sorted { $0.scientificName.localizedCaseInsensitiveCompare($1.scientificName) == .orderedDescending }
|
||||
case .familyAscending:
|
||||
return plants.sorted { $0.family.localizedCaseInsensitiveCompare($1.family) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// SavePlantUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - SavePlantUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for saving plants to the user's collection.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations coordinate between repositories and services to persist
|
||||
/// plant data along with associated images, care schedules, and notifications.
|
||||
protocol SavePlantUseCaseProtocol: Sendable {
|
||||
/// Saves a plant to the user's collection with optional associated data.
|
||||
///
|
||||
/// This method performs the following operations:
|
||||
/// 1. Sets the dateAdded to the current date
|
||||
/// 2. Saves the captured image to local storage if provided
|
||||
/// 3. Adds the plant to the repository
|
||||
/// 4. Creates a care schedule if careInfo is provided
|
||||
/// 5. Schedules notifications for upcoming care tasks
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plant: The plant entity to save to the collection.
|
||||
/// - capturedImage: Optional image captured during identification to store locally.
|
||||
/// - careInfo: Optional care information for generating a care schedule.
|
||||
/// - preferences: Optional user preferences for scheduling care tasks.
|
||||
/// - Returns: The saved plant with updated properties (e.g., dateAdded, local image URL).
|
||||
/// - Throws: `SavePlantError` if any step of the save process fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let savedPlant = try await useCase.execute(
|
||||
/// plant: identifiedPlant,
|
||||
/// capturedImage: capturedPhoto,
|
||||
/// careInfo: plantCareInfo,
|
||||
/// preferences: userPreferences
|
||||
/// )
|
||||
/// ```
|
||||
func execute(
|
||||
plant: Plant,
|
||||
capturedImage: UIImage?,
|
||||
careInfo: PlantCareInfo?,
|
||||
preferences: CarePreferences?
|
||||
) async throws -> Plant
|
||||
}
|
||||
|
||||
// MARK: - SavePlantError
|
||||
|
||||
/// Errors that can occur when saving a plant to the collection.
|
||||
///
|
||||
/// These errors provide specific context for save operation failures,
|
||||
/// enabling appropriate error handling and user messaging.
|
||||
enum SavePlantError: Error, LocalizedError {
|
||||
/// Failed to save the plant to the repository.
|
||||
case repositorySaveFailed(Error)
|
||||
|
||||
/// Failed to save the captured image to local storage.
|
||||
case imageSaveFailed(Error)
|
||||
|
||||
/// Failed to create the care schedule.
|
||||
case careScheduleCreationFailed(Error)
|
||||
|
||||
/// Failed to schedule notifications for care tasks.
|
||||
case notificationSchedulingFailed(Error)
|
||||
|
||||
/// The plant already exists in the collection.
|
||||
case plantAlreadyExists(plantID: UUID)
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .repositorySaveFailed(let error):
|
||||
return "Failed to save plant: \(error.localizedDescription)"
|
||||
case .imageSaveFailed(let error):
|
||||
return "Failed to save plant image: \(error.localizedDescription)"
|
||||
case .careScheduleCreationFailed(let error):
|
||||
return "Failed to create care schedule: \(error.localizedDescription)"
|
||||
case .notificationSchedulingFailed(let error):
|
||||
return "Failed to schedule reminders: \(error.localizedDescription)"
|
||||
case .plantAlreadyExists:
|
||||
return "This plant is already in your collection."
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .repositorySaveFailed:
|
||||
return "The plant data could not be persisted to storage."
|
||||
case .imageSaveFailed:
|
||||
return "The plant image could not be saved to the device."
|
||||
case .careScheduleCreationFailed:
|
||||
return "The care schedule could not be generated."
|
||||
case .notificationSchedulingFailed:
|
||||
return "Care reminders could not be scheduled."
|
||||
case .plantAlreadyExists:
|
||||
return "A plant with this ID already exists in your collection."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .repositorySaveFailed:
|
||||
return "Please try again. If the problem persists, restart the app."
|
||||
case .imageSaveFailed:
|
||||
return "Check available storage space and try again."
|
||||
case .careScheduleCreationFailed:
|
||||
return "The plant was saved but care schedule is unavailable."
|
||||
case .notificationSchedulingFailed:
|
||||
return "Enable notifications in Settings to receive care reminders."
|
||||
case .plantAlreadyExists:
|
||||
return "View your existing plant in the Collection tab."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SavePlantUseCase
|
||||
|
||||
/// Use case for saving plants to the user's collection.
|
||||
///
|
||||
/// This use case coordinates the complete plant save workflow:
|
||||
/// 1. Validates that the plant doesn't already exist
|
||||
/// 2. Saves any captured image to local storage
|
||||
/// 3. Persists the plant entity with updated metadata
|
||||
/// 4. Creates a care schedule if care information is provided
|
||||
/// 5. Schedules notifications for upcoming care tasks
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = SavePlantUseCase(
|
||||
/// plantRepository: plantRepository,
|
||||
/// imageStorage: imageStorage,
|
||||
/// notificationService: notificationService,
|
||||
/// createCareScheduleUseCase: createCareScheduleUseCase,
|
||||
/// careScheduleRepository: careScheduleRepository
|
||||
/// )
|
||||
///
|
||||
/// let savedPlant = try await useCase.execute(
|
||||
/// plant: identifiedPlant,
|
||||
/// capturedImage: photo,
|
||||
/// careInfo: careInfo,
|
||||
/// preferences: preferences
|
||||
/// )
|
||||
/// ```
|
||||
final class SavePlantUseCase: SavePlantUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let plantRepository: PlantCollectionRepositoryProtocol
|
||||
private let imageStorage: ImageStorageProtocol
|
||||
private let notificationService: NotificationServiceProtocol
|
||||
private let createCareScheduleUseCase: CreateCareScheduleUseCaseProtocol
|
||||
private let careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new SavePlantUseCase instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantRepository: Repository for persisting plant entities.
|
||||
/// - imageStorage: Service for storing plant images locally.
|
||||
/// - notificationService: Service for scheduling care reminders.
|
||||
/// - createCareScheduleUseCase: Use case for generating care schedules.
|
||||
/// - careScheduleRepository: Repository for persisting care schedules.
|
||||
init(
|
||||
plantRepository: PlantCollectionRepositoryProtocol,
|
||||
imageStorage: ImageStorageProtocol,
|
||||
notificationService: NotificationServiceProtocol,
|
||||
createCareScheduleUseCase: CreateCareScheduleUseCaseProtocol,
|
||||
careScheduleRepository: CareScheduleRepositoryProtocol
|
||||
) {
|
||||
self.plantRepository = plantRepository
|
||||
self.imageStorage = imageStorage
|
||||
self.notificationService = notificationService
|
||||
self.createCareScheduleUseCase = createCareScheduleUseCase
|
||||
self.careScheduleRepository = careScheduleRepository
|
||||
}
|
||||
|
||||
// MARK: - SavePlantUseCaseProtocol
|
||||
|
||||
func execute(
|
||||
plant: Plant,
|
||||
capturedImage: UIImage?,
|
||||
careInfo: PlantCareInfo?,
|
||||
preferences: CarePreferences?
|
||||
) async throws -> Plant {
|
||||
// Step 1: Check if plant already exists
|
||||
if try await plantRepository.exists(id: plant.id) {
|
||||
throw SavePlantError.plantAlreadyExists(plantID: plant.id)
|
||||
}
|
||||
|
||||
// Step 2: Save captured image if provided
|
||||
var localImagePaths = plant.localImagePaths
|
||||
if let image = capturedImage {
|
||||
do {
|
||||
let localPath = try await imageStorage.save(image, for: plant.id)
|
||||
localImagePaths.insert(localPath, at: 0) // Local image takes priority
|
||||
} catch {
|
||||
throw SavePlantError.imageSaveFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Create updated plant with current date and local image paths
|
||||
let now = Date()
|
||||
let plantToSave = Plant(
|
||||
id: plant.id,
|
||||
scientificName: plant.scientificName,
|
||||
commonNames: plant.commonNames,
|
||||
family: plant.family,
|
||||
genus: plant.genus,
|
||||
imageURLs: plant.imageURLs, // Keep original remote URLs
|
||||
dateIdentified: now,
|
||||
identificationSource: plant.identificationSource,
|
||||
localImagePaths: localImagePaths,
|
||||
dateAdded: now // Required for collection sorting
|
||||
)
|
||||
|
||||
// Step 4: Save plant to repository
|
||||
do {
|
||||
try await plantRepository.save(plantToSave)
|
||||
} catch {
|
||||
// Clean up saved image if repository save fails
|
||||
if capturedImage != nil {
|
||||
try? await imageStorage.deleteAll(for: plant.id)
|
||||
}
|
||||
throw SavePlantError.repositorySaveFailed(error)
|
||||
}
|
||||
|
||||
// Step 5: Create care schedule if careInfo is provided
|
||||
if let careInfo = careInfo {
|
||||
do {
|
||||
let schedule = try await createCareScheduleUseCase.execute(
|
||||
for: plantToSave,
|
||||
careInfo: careInfo,
|
||||
preferences: preferences
|
||||
)
|
||||
|
||||
// Save the care schedule
|
||||
try await careScheduleRepository.save(schedule)
|
||||
|
||||
// Step 6: Schedule notifications for care tasks
|
||||
await scheduleNotifications(
|
||||
for: schedule,
|
||||
plantName: plantToSave.commonNames.first ?? plantToSave.scientificName
|
||||
)
|
||||
} catch {
|
||||
// Log but don't fail the save - plant is already persisted
|
||||
// Care schedule can be recreated later
|
||||
print("Warning: Failed to create care schedule: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
return plantToSave
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Schedules notifications for all future care tasks in a schedule.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - schedule: The care schedule containing tasks to schedule.
|
||||
/// - plantName: The display name of the plant for notification content.
|
||||
private func scheduleNotifications(for schedule: PlantCareSchedule, plantName: String) async {
|
||||
let futureTasks = schedule.tasks.filter { $0.scheduledDate > Date() }
|
||||
|
||||
for task in futureTasks {
|
||||
do {
|
||||
try await notificationService.scheduleReminder(
|
||||
for: task,
|
||||
plantName: plantName,
|
||||
plantID: schedule.plantID
|
||||
)
|
||||
} catch {
|
||||
// Log notification failures but continue with other tasks
|
||||
print("Warning: Failed to schedule notification for task \(task.id): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// ToggleFavoriteUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - ToggleFavoriteUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for toggling a plant's favorite status.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations handle the business logic of updating a plant's favorite
|
||||
/// state in the repository.
|
||||
protocol ToggleFavoriteUseCaseProtocol: Sendable {
|
||||
/// Toggles the favorite status of a plant.
|
||||
///
|
||||
/// If the plant is currently a favorite, it will be unfavorited.
|
||||
/// If the plant is not a favorite, it will be marked as a favorite.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant to toggle.
|
||||
/// - Returns: The new favorite state (`true` if now favorited, `false` if unfavorited).
|
||||
/// - Throws: `ToggleFavoriteError` if the operation fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let isNowFavorite = try await useCase.execute(plantID: plant.id)
|
||||
/// print(isNowFavorite ? "Added to favorites" : "Removed from favorites")
|
||||
/// ```
|
||||
func execute(plantID: UUID) async throws -> Bool
|
||||
}
|
||||
|
||||
// MARK: - ToggleFavoriteError
|
||||
|
||||
/// Errors that can occur when toggling a plant's favorite status.
|
||||
///
|
||||
/// These errors provide specific context for toggle operation failures,
|
||||
/// enabling appropriate error handling and user messaging.
|
||||
enum ToggleFavoriteError: Error, LocalizedError {
|
||||
/// The plant with the specified ID was not found.
|
||||
case plantNotFound(plantID: UUID)
|
||||
|
||||
/// Failed to update the plant's favorite status.
|
||||
case updateFailed(Error)
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .plantNotFound(let plantID):
|
||||
return "Plant not found: \(plantID)"
|
||||
case .updateFailed(let error):
|
||||
return "Failed to update favorite status: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "The plant may have been deleted."
|
||||
case .updateFailed:
|
||||
return "The favorite status could not be saved."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "Refresh the collection to see current plants."
|
||||
case .updateFailed:
|
||||
return "Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FavoritePlantRepositoryProtocol
|
||||
|
||||
/// Protocol extension for plant repositories that support favorites.
|
||||
///
|
||||
/// This protocol adds favorite-specific operations to the base repository.
|
||||
protocol FavoritePlantRepositoryProtocol: PlantCollectionRepositoryProtocol {
|
||||
/// Checks if a plant is marked as a favorite.
|
||||
///
|
||||
/// - Parameter plantID: The unique identifier of the plant.
|
||||
/// - Returns: `true` if the plant is a favorite, `false` otherwise.
|
||||
/// - Throws: An error if the check fails.
|
||||
func isFavorite(plantID: UUID) async throws -> Bool
|
||||
|
||||
/// Sets the favorite status of a plant.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantID: The unique identifier of the plant.
|
||||
/// - isFavorite: The new favorite status.
|
||||
/// - Throws: An error if the update fails.
|
||||
func setFavorite(plantID: UUID, isFavorite: Bool) async throws
|
||||
}
|
||||
|
||||
// MARK: - ToggleFavoriteUseCase
|
||||
|
||||
/// Use case for toggling a plant's favorite status.
|
||||
///
|
||||
/// This use case provides a simple interface for favoriting and unfavoriting
|
||||
/// plants in the collection. It handles the toggle logic and persists the
|
||||
/// change through the repository.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = ToggleFavoriteUseCase(
|
||||
/// plantRepository: favoritePlantRepository
|
||||
/// )
|
||||
///
|
||||
/// // Toggle favorite status
|
||||
/// let isNowFavorite = try await useCase.execute(plantID: plant.id)
|
||||
///
|
||||
/// if isNowFavorite {
|
||||
/// showFavoriteAddedConfirmation()
|
||||
/// } else {
|
||||
/// showFavoriteRemovedConfirmation()
|
||||
/// }
|
||||
/// ```
|
||||
final class ToggleFavoriteUseCase: ToggleFavoriteUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let plantRepository: FavoritePlantRepositoryProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new ToggleFavoriteUseCase instance.
|
||||
///
|
||||
/// - Parameter plantRepository: Repository supporting favorite operations.
|
||||
init(plantRepository: FavoritePlantRepositoryProtocol) {
|
||||
self.plantRepository = plantRepository
|
||||
}
|
||||
|
||||
// MARK: - ToggleFavoriteUseCaseProtocol
|
||||
|
||||
func execute(plantID: UUID) async throws -> Bool {
|
||||
// Step 1: Verify the plant exists
|
||||
guard try await plantRepository.exists(id: plantID) else {
|
||||
throw ToggleFavoriteError.plantNotFound(plantID: plantID)
|
||||
}
|
||||
|
||||
do {
|
||||
// Step 2: Get current favorite status
|
||||
let currentStatus = try await plantRepository.isFavorite(plantID: plantID)
|
||||
|
||||
// Step 3: Toggle the status
|
||||
let newStatus = !currentStatus
|
||||
|
||||
// Step 4: Persist the new status
|
||||
try await plantRepository.setFavorite(plantID: plantID, isFavorite: newStatus)
|
||||
|
||||
return newStatus
|
||||
} catch let error as ToggleFavoriteError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ToggleFavoriteError.updateFailed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// UpdatePlantUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - UpdatePlantUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for updating plants in the user's collection.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations handle updating plant metadata like notes, custom name,
|
||||
/// location, and favorite status.
|
||||
protocol UpdatePlantUseCaseProtocol: Sendable {
|
||||
/// Updates an existing plant in the collection.
|
||||
///
|
||||
/// This method updates the mutable properties of a plant:
|
||||
/// - Custom name
|
||||
/// - Notes
|
||||
/// - Location
|
||||
/// - Favorite status
|
||||
///
|
||||
/// - Parameter plant: The plant entity with updated values.
|
||||
/// - Returns: The updated plant.
|
||||
/// - Throws: `UpdatePlantError` if the update fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// var updatedPlant = plant
|
||||
/// updatedPlant.notes = "Needs more sunlight"
|
||||
/// let result = try await useCase.execute(plant: updatedPlant)
|
||||
/// ```
|
||||
func execute(plant: Plant) async throws -> Plant
|
||||
}
|
||||
|
||||
// MARK: - UpdatePlantError
|
||||
|
||||
/// Errors that can occur when updating a plant in the collection.
|
||||
///
|
||||
/// These errors provide specific context for update failures,
|
||||
/// enabling appropriate error handling and user messaging.
|
||||
enum UpdatePlantError: Error, LocalizedError {
|
||||
/// The plant with the specified ID was not found.
|
||||
case plantNotFound(plantID: UUID)
|
||||
|
||||
/// Failed to update the plant in the repository.
|
||||
case repositoryUpdateFailed(Error)
|
||||
|
||||
/// The plant data is invalid.
|
||||
case invalidPlantData(reason: String)
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .plantNotFound(let plantID):
|
||||
return "Plant not found: \(plantID)"
|
||||
case .repositoryUpdateFailed(let error):
|
||||
return "Failed to update plant: \(error.localizedDescription)"
|
||||
case .invalidPlantData(let reason):
|
||||
return "Invalid plant data: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "The plant may have been deleted."
|
||||
case .repositoryUpdateFailed:
|
||||
return "The plant data could not be saved to storage."
|
||||
case .invalidPlantData:
|
||||
return "The provided plant data is not valid."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .plantNotFound:
|
||||
return "Refresh the collection to see current plants."
|
||||
case .repositoryUpdateFailed:
|
||||
return "Please try again. If the problem persists, restart the app."
|
||||
case .invalidPlantData:
|
||||
return "Please check the plant information and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UpdatePlantUseCase
|
||||
|
||||
/// Use case for updating plants in the user's collection.
|
||||
///
|
||||
/// This use case handles updating plant metadata while preserving
|
||||
/// immutable identification data. It validates the plant exists
|
||||
/// before attempting the update.
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = UpdatePlantUseCase(
|
||||
/// plantRepository: plantRepository
|
||||
/// )
|
||||
///
|
||||
/// var updatedPlant = existingPlant
|
||||
/// updatedPlant.customName = "My Favorite Fern"
|
||||
/// updatedPlant.notes = "Needs water every 3 days"
|
||||
/// let result = try await useCase.execute(plant: updatedPlant)
|
||||
/// ```
|
||||
final class UpdatePlantUseCase: UpdatePlantUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let plantRepository: PlantCollectionRepositoryProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new UpdatePlantUseCase instance.
|
||||
///
|
||||
/// - Parameter plantRepository: Repository for accessing and updating plant entities.
|
||||
init(plantRepository: PlantCollectionRepositoryProtocol) {
|
||||
self.plantRepository = plantRepository
|
||||
}
|
||||
|
||||
// MARK: - UpdatePlantUseCaseProtocol
|
||||
|
||||
func execute(plant: Plant) async throws -> Plant {
|
||||
// Step 1: Verify the plant exists
|
||||
guard try await plantRepository.exists(id: plant.id) else {
|
||||
throw UpdatePlantError.plantNotFound(plantID: plant.id)
|
||||
}
|
||||
|
||||
// Step 2: Validate plant data
|
||||
guard !plant.scientificName.isEmpty else {
|
||||
throw UpdatePlantError.invalidPlantData(reason: "Scientific name cannot be empty")
|
||||
}
|
||||
|
||||
// Step 3: Update the plant in repository
|
||||
do {
|
||||
try await plantRepository.updatePlant(plant)
|
||||
} catch {
|
||||
throw UpdatePlantError.repositoryUpdateFailed(error)
|
||||
}
|
||||
|
||||
return plant
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// HybridIdentificationUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 1/21/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - HybridStrategy
|
||||
|
||||
/// Strategy for hybrid plant identification
|
||||
enum HybridStrategy: Sendable {
|
||||
/// Use only on-device ML for identification
|
||||
case onDeviceOnly
|
||||
/// Use only online API for identification (requires network)
|
||||
case onlineOnly
|
||||
/// Run on-device first, use API if confidence is below threshold
|
||||
case onDeviceFirst(apiThreshold: Double)
|
||||
/// Run both concurrently, prefer online results if available
|
||||
case parallel
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationResult
|
||||
|
||||
/// Result of a hybrid identification operation
|
||||
struct HybridIdentificationResult: Sendable {
|
||||
/// The plant predictions from identification
|
||||
let predictions: [ViewPlantPrediction]
|
||||
/// The source that provided the final results
|
||||
let source: IdentificationSource
|
||||
/// Whether on-device identification was available
|
||||
let onDeviceAvailable: Bool
|
||||
/// Whether online identification was available
|
||||
let onlineAvailable: Bool
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationError
|
||||
|
||||
/// Errors that can occur during hybrid identification
|
||||
enum HybridIdentificationError: Error, LocalizedError {
|
||||
/// Online-only strategy was requested but no network is available
|
||||
case noNetworkForOnlineOnly
|
||||
/// Both on-device and online identification failed
|
||||
case bothSourcesFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noNetworkForOnlineOnly:
|
||||
return "No network connection available for online identification"
|
||||
case .bothSourcesFailed:
|
||||
return "Unable to identify plant using either on-device or online identification"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationUseCaseProtocol
|
||||
|
||||
/// Protocol for hybrid plant identification combining on-device and online sources
|
||||
protocol HybridIdentificationUseCaseProtocol: Sendable {
|
||||
/// Identifies a plant using the specified hybrid strategy
|
||||
/// - Parameters:
|
||||
/// - image: The UIImage containing the plant to identify
|
||||
/// - strategy: The hybrid strategy to use for identification
|
||||
/// - Returns: The result containing predictions and source information
|
||||
/// - Throws: HybridIdentificationError or underlying identification errors
|
||||
func execute(
|
||||
image: UIImage,
|
||||
strategy: HybridStrategy
|
||||
) async throws -> HybridIdentificationResult
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationUseCase
|
||||
|
||||
/// Use case for hybrid plant identification combining on-device ML and online API
|
||||
struct HybridIdentificationUseCase: HybridIdentificationUseCaseProtocol {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let onDeviceUseCase: IdentifyPlantUseCaseProtocol
|
||||
private let onlineUseCase: IdentifyPlantOnlineUseCaseProtocol
|
||||
private let networkMonitor: NetworkMonitor
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new HybridIdentificationUseCase
|
||||
/// - Parameters:
|
||||
/// - onDeviceUseCase: The use case for on-device ML identification
|
||||
/// - onlineUseCase: The use case for online API identification
|
||||
/// - networkMonitor: The network monitor for checking connectivity
|
||||
init(
|
||||
onDeviceUseCase: IdentifyPlantUseCaseProtocol,
|
||||
onlineUseCase: IdentifyPlantOnlineUseCaseProtocol,
|
||||
networkMonitor: NetworkMonitor
|
||||
) {
|
||||
self.onDeviceUseCase = onDeviceUseCase
|
||||
self.onlineUseCase = onlineUseCase
|
||||
self.networkMonitor = networkMonitor
|
||||
}
|
||||
|
||||
// MARK: - HybridIdentificationUseCaseProtocol
|
||||
|
||||
/// Executes hybrid plant identification using the specified strategy
|
||||
func execute(
|
||||
image: UIImage,
|
||||
strategy: HybridStrategy
|
||||
) async throws -> HybridIdentificationResult {
|
||||
switch strategy {
|
||||
case .onDeviceOnly:
|
||||
return try await executeOnDeviceOnly(image: image)
|
||||
|
||||
case .onlineOnly:
|
||||
return try await executeOnlineOnly(image: image)
|
||||
|
||||
case .onDeviceFirst(let apiThreshold):
|
||||
return try await executeOnDeviceFirst(image: image, apiThreshold: apiThreshold)
|
||||
|
||||
case .parallel:
|
||||
return try await executeParallel(image: image)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Strategy Implementations
|
||||
|
||||
/// Executes on-device only identification
|
||||
private func executeOnDeviceOnly(image: UIImage) async throws -> HybridIdentificationResult {
|
||||
let predictions = try await onDeviceUseCase.execute(image: image)
|
||||
|
||||
return HybridIdentificationResult(
|
||||
predictions: predictions,
|
||||
source: .onDeviceML,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: networkMonitor.isConnected
|
||||
)
|
||||
}
|
||||
|
||||
/// Executes online only identification
|
||||
private func executeOnlineOnly(image: UIImage) async throws -> HybridIdentificationResult {
|
||||
guard networkMonitor.isConnected else {
|
||||
throw HybridIdentificationError.noNetworkForOnlineOnly
|
||||
}
|
||||
|
||||
let predictions = try await onlineUseCase.execute(image: image)
|
||||
|
||||
return HybridIdentificationResult(
|
||||
predictions: predictions,
|
||||
source: .plantNetAPI,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Executes on-device first, falling back to online if confidence is below threshold
|
||||
private func executeOnDeviceFirst(
|
||||
image: UIImage,
|
||||
apiThreshold: Double
|
||||
) async throws -> HybridIdentificationResult {
|
||||
// Always run on-device first
|
||||
let onDevicePredictions = try await onDeviceUseCase.execute(image: image)
|
||||
|
||||
// Check if top prediction confidence meets threshold
|
||||
let topConfidence = onDevicePredictions.first?.confidence ?? 0.0
|
||||
let isOnlineAvailable = networkMonitor.isConnected
|
||||
|
||||
// If confidence is above threshold or network unavailable, return on-device results
|
||||
if topConfidence >= apiThreshold || !isOnlineAvailable {
|
||||
return HybridIdentificationResult(
|
||||
predictions: onDevicePredictions,
|
||||
source: .onDeviceML,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: isOnlineAvailable
|
||||
)
|
||||
}
|
||||
|
||||
// Try online identification for better results
|
||||
do {
|
||||
let onlinePredictions = try await onlineUseCase.execute(image: image)
|
||||
|
||||
return HybridIdentificationResult(
|
||||
predictions: onlinePredictions,
|
||||
source: .plantNetAPI,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: true
|
||||
)
|
||||
} catch {
|
||||
// If online fails, fall back to on-device results
|
||||
return HybridIdentificationResult(
|
||||
predictions: onDevicePredictions,
|
||||
source: .onDeviceML,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes both on-device and online identification in parallel
|
||||
private func executeParallel(image: UIImage) async throws -> HybridIdentificationResult {
|
||||
let isOnlineAvailable = networkMonitor.isConnected
|
||||
|
||||
// If offline, only run on-device
|
||||
guard isOnlineAvailable else {
|
||||
let predictions = try await onDeviceUseCase.execute(image: image)
|
||||
|
||||
return HybridIdentificationResult(
|
||||
predictions: predictions,
|
||||
source: .onDeviceML,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: false
|
||||
)
|
||||
}
|
||||
|
||||
// Run both in parallel using TaskGroup
|
||||
return try await withThrowingTaskGroup(
|
||||
of: IdentificationTaskResult.self,
|
||||
returning: HybridIdentificationResult.self
|
||||
) { group in
|
||||
// Add on-device task
|
||||
group.addTask {
|
||||
do {
|
||||
let predictions = try await onDeviceUseCase.execute(image: image)
|
||||
return .onDevice(predictions)
|
||||
} catch {
|
||||
return .onDeviceFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add online task
|
||||
group.addTask {
|
||||
do {
|
||||
let predictions = try await onlineUseCase.execute(image: image)
|
||||
return .online(predictions)
|
||||
} catch {
|
||||
return .onlineFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect results
|
||||
var onDevicePredictions: [ViewPlantPrediction]?
|
||||
var onlinePredictions: [ViewPlantPrediction]?
|
||||
var onDeviceError: Error?
|
||||
var onlineError: Error?
|
||||
|
||||
for try await result in group {
|
||||
switch result {
|
||||
case .onDevice(let predictions):
|
||||
onDevicePredictions = predictions
|
||||
case .online(let predictions):
|
||||
onlinePredictions = predictions
|
||||
case .onDeviceFailed(let error):
|
||||
onDeviceError = error
|
||||
case .onlineFailed(let error):
|
||||
onlineError = error
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer online results if available
|
||||
if let onlinePredictions {
|
||||
return HybridIdentificationResult(
|
||||
predictions: onlinePredictions,
|
||||
source: .plantNetAPI,
|
||||
onDeviceAvailable: onDevicePredictions != nil,
|
||||
onlineAvailable: true
|
||||
)
|
||||
}
|
||||
|
||||
// Fall back to on-device results
|
||||
if let onDevicePredictions {
|
||||
return HybridIdentificationResult(
|
||||
predictions: onDevicePredictions,
|
||||
source: .onDeviceML,
|
||||
onDeviceAvailable: true,
|
||||
onlineAvailable: false
|
||||
)
|
||||
}
|
||||
|
||||
// Both failed
|
||||
throw HybridIdentificationError.bothSourcesFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentificationTaskResult
|
||||
|
||||
/// Internal result type for parallel task execution
|
||||
private enum IdentificationTaskResult: Sendable {
|
||||
case onDevice([ViewPlantPrediction])
|
||||
case online([ViewPlantPrediction])
|
||||
case onDeviceFailed(Error)
|
||||
case onlineFailed(Error)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// IdentifyPlantOnDeviceUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 1/21/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - IdentifyPlantUseCaseProtocol
|
||||
|
||||
/// Protocol for plant identification use cases (both on-device and remote)
|
||||
protocol IdentifyPlantUseCaseProtocol: Sendable {
|
||||
/// Identifies a plant from an image
|
||||
/// - Parameter image: The UIImage containing the plant to identify
|
||||
/// - Returns: An array of view-layer predictions
|
||||
/// - Throws: If identification fails
|
||||
func execute(image: UIImage) async throws -> [ViewPlantPrediction]
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantOnDeviceUseCase
|
||||
|
||||
/// Use case for identifying plants using on-device machine learning
|
||||
struct IdentifyPlantOnDeviceUseCase: IdentifyPlantUseCaseProtocol {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let imagePreprocessor: ImagePreprocessor
|
||||
private let classificationService: PlantClassificationService
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
imagePreprocessor: ImagePreprocessor,
|
||||
classificationService: PlantClassificationService
|
||||
) {
|
||||
self.imagePreprocessor = imagePreprocessor
|
||||
self.classificationService = classificationService
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantUseCaseProtocol
|
||||
|
||||
/// Identifies a plant and returns view-layer predictions
|
||||
func execute(image: UIImage) async throws -> [ViewPlantPrediction] {
|
||||
// Preprocess the image for classification
|
||||
let preprocessedImage = try await imagePreprocessor.preprocess(image)
|
||||
|
||||
// Run classification on the preprocessed image
|
||||
let predictions = try await classificationService.classify(image: preprocessedImage)
|
||||
|
||||
guard !predictions.isEmpty else {
|
||||
throw IdentifyPlantOnDeviceUseCaseError.noMatchesFound
|
||||
}
|
||||
|
||||
// Map ML predictions to view-layer predictions
|
||||
return predictions.map { prediction in
|
||||
ViewPlantPrediction(
|
||||
id: prediction.id,
|
||||
speciesName: prediction.scientificName,
|
||||
commonName: prediction.commonNames.first,
|
||||
confidence: Double(prediction.confidence)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantOnDeviceUseCaseError
|
||||
|
||||
/// Errors that can occur during on-device plant identification
|
||||
enum IdentifyPlantOnDeviceUseCaseError: Error, LocalizedError {
|
||||
/// No matches were found for the provided image
|
||||
case noMatchesFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noMatchesFound:
|
||||
return "No plant matches were found in the image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// IdentifyPlantOnlineUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 1/21/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - IdentifyPlantOnlineUseCaseProtocol
|
||||
|
||||
/// Protocol for online plant identification using the PlantNet API
|
||||
protocol IdentifyPlantOnlineUseCaseProtocol: Sendable {
|
||||
/// Identifies a plant from an image using the PlantNet API
|
||||
/// - Parameter image: The UIImage containing the plant to identify
|
||||
/// - Returns: An array of view-layer predictions
|
||||
/// - Throws: If identification fails
|
||||
func execute(image: UIImage) async throws -> [ViewPlantPrediction]
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantOnlineUseCase
|
||||
|
||||
/// Use case for identifying plants using the PlantNet online API
|
||||
struct IdentifyPlantOnlineUseCase: IdentifyPlantOnlineUseCaseProtocol, IdentifyPlantUseCaseProtocol {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
/// JPEG compression quality (0.0 to 1.0)
|
||||
static let jpegCompressionQuality: CGFloat = 0.8
|
||||
/// Maximum image size in bytes (2MB)
|
||||
static let maxImageSizeBytes = 2 * 1024 * 1024
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let apiService: PlantNetAPIServiceProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(apiService: PlantNetAPIServiceProtocol) {
|
||||
self.apiService = apiService
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantOnlineUseCaseProtocol
|
||||
|
||||
/// Identifies a plant and returns view-layer predictions
|
||||
func execute(image: UIImage) async throws -> [ViewPlantPrediction] {
|
||||
// Convert UIImage to JPEG data
|
||||
guard var imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) else {
|
||||
throw IdentifyPlantOnlineUseCaseError.imageConversionFailed
|
||||
}
|
||||
|
||||
// Resize image if it exceeds max size
|
||||
if imageData.count > Constants.maxImageSizeBytes {
|
||||
imageData = try resizeImage(image, targetSizeBytes: Constants.maxImageSizeBytes)
|
||||
}
|
||||
|
||||
// Call PlantNet API with default project and organs
|
||||
let response = try await apiService.identify(
|
||||
imageData: imageData,
|
||||
organs: [.auto],
|
||||
project: .all
|
||||
)
|
||||
|
||||
// Map response to view-layer predictions
|
||||
let predictions = PlantNetMapper.mapToPredictions(from: response)
|
||||
|
||||
guard !predictions.isEmpty else {
|
||||
throw IdentifyPlantOnlineUseCaseError.noMatchesFound
|
||||
}
|
||||
|
||||
return predictions
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Resizes the image to fit within the target size in bytes
|
||||
/// - Parameters:
|
||||
/// - image: The original image to resize
|
||||
/// - targetSizeBytes: The maximum target size in bytes
|
||||
/// - Returns: The resized image data
|
||||
/// - Throws: `IdentifyPlantOnlineUseCaseError.imageConversionFailed` if resizing fails
|
||||
private func resizeImage(_ image: UIImage, targetSizeBytes: Int) throws -> Data {
|
||||
var compressionQuality: CGFloat = Constants.jpegCompressionQuality
|
||||
var scaleFactor: CGFloat = 1.0
|
||||
var imageData: Data?
|
||||
|
||||
// First try reducing compression quality
|
||||
while compressionQuality > 0.1 {
|
||||
if let data = image.jpegData(compressionQuality: compressionQuality),
|
||||
data.count <= targetSizeBytes {
|
||||
return data
|
||||
}
|
||||
compressionQuality -= 0.1
|
||||
}
|
||||
|
||||
// If compression alone doesn't work, scale down the image
|
||||
while scaleFactor > 0.1 {
|
||||
let newSize = CGSize(
|
||||
width: image.size.width * scaleFactor,
|
||||
height: image.size.height * scaleFactor
|
||||
)
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||
let resizedImage = renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
}
|
||||
|
||||
if let data = resizedImage.jpegData(compressionQuality: Constants.jpegCompressionQuality),
|
||||
data.count <= targetSizeBytes {
|
||||
imageData = data
|
||||
break
|
||||
}
|
||||
|
||||
scaleFactor -= 0.1
|
||||
}
|
||||
|
||||
guard let finalData = imageData else {
|
||||
throw IdentifyPlantOnlineUseCaseError.imageConversionFailed
|
||||
}
|
||||
|
||||
return finalData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentifyPlantOnlineUseCaseError
|
||||
|
||||
/// Errors that can occur during online plant identification
|
||||
enum IdentifyPlantOnlineUseCaseError: Error, LocalizedError {
|
||||
/// Failed to convert the UIImage to JPEG data
|
||||
case imageConversionFailed
|
||||
/// No matches were found for the provided image
|
||||
case noMatchesFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .imageConversionFailed:
|
||||
return "Failed to process the image for identification"
|
||||
case .noMatchesFound:
|
||||
return "No plant matches were found in the image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CreateCareScheduleUseCaseProtocol
|
||||
|
||||
/// Protocol defining the contract for creating plant care schedules.
|
||||
///
|
||||
/// This use case generates a complete care schedule for a plant based on its
|
||||
/// care requirements and user preferences.
|
||||
protocol CreateCareScheduleUseCaseProtocol: Sendable {
|
||||
/// Creates a care schedule for a plant.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plant: The plant to create a schedule for.
|
||||
/// - careInfo: The care information containing watering, fertilizer, and other requirements.
|
||||
/// - preferences: Optional user preferences for scheduling (e.g., preferred watering time).
|
||||
/// - Returns: A `PlantCareSchedule` containing the generated care tasks.
|
||||
/// - Throws: An error if the schedule cannot be created.
|
||||
func execute(
|
||||
for plant: Plant,
|
||||
careInfo: PlantCareInfo,
|
||||
preferences: CarePreferences?
|
||||
) async throws -> PlantCareSchedule
|
||||
}
|
||||
|
||||
// MARK: - CreateCareScheduleUseCase
|
||||
|
||||
/// Use case for creating plant care schedules.
|
||||
///
|
||||
/// This implementation generates care tasks for watering and fertilizing based on
|
||||
/// the plant's care requirements and user preferences. Tasks are generated for a
|
||||
/// configurable number of days ahead (default 30 days).
|
||||
///
|
||||
/// ## Example Usage
|
||||
/// ```swift
|
||||
/// let useCase = CreateCareScheduleUseCase()
|
||||
/// let schedule = try await useCase.execute(
|
||||
/// for: myPlant,
|
||||
/// careInfo: plantCareInfo,
|
||||
/// preferences: userPreferences
|
||||
/// )
|
||||
/// ```
|
||||
final class CreateCareScheduleUseCase: CreateCareScheduleUseCaseProtocol {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Default hour for care task scheduling (8 AM)
|
||||
private static let defaultHour = 8
|
||||
|
||||
/// Default minute for care task scheduling
|
||||
private static let defaultMinute = 0
|
||||
|
||||
/// Default number of days to generate tasks for
|
||||
private static let defaultDaysAhead = 30
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new instance of the use case.
|
||||
init() {}
|
||||
|
||||
// MARK: - CreateCareScheduleUseCaseProtocol
|
||||
|
||||
func execute(
|
||||
for plant: Plant,
|
||||
careInfo: PlantCareInfo,
|
||||
preferences: CarePreferences?
|
||||
) async throws -> PlantCareSchedule {
|
||||
// Generate care tasks for the next 30 days
|
||||
let tasks = generateTasks(plantID: plant.id, careInfo: careInfo, preferences: preferences)
|
||||
|
||||
// Create the schedule
|
||||
return PlantCareSchedule(
|
||||
plantID: plant.id,
|
||||
lightRequirement: careInfo.lightRequirement,
|
||||
wateringSchedule: careInfo.wateringSchedule.frequency.rawValue,
|
||||
temperatureRange: Int(careInfo.temperatureRange.minimumCelsius)...Int(careInfo.temperatureRange.maximumCelsius),
|
||||
fertilizerSchedule: careInfo.fertilizerSchedule?.frequency.rawValue ?? "Not required",
|
||||
tasks: tasks
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Generates care tasks based on plant care information and user preferences.
|
||||
///
|
||||
/// This method creates both watering and fertilizing tasks for the specified
|
||||
/// number of days ahead, starting from tomorrow.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plantID: The ID of the plant the tasks belong to.
|
||||
/// - careInfo: The care information containing watering and fertilizer schedules.
|
||||
/// - preferences: Optional user preferences for task scheduling.
|
||||
/// - daysAhead: The number of days to generate tasks for. Defaults to 30.
|
||||
/// - Returns: An array of `CareTask` objects sorted by scheduled date.
|
||||
private func generateTasks(
|
||||
plantID: UUID,
|
||||
careInfo: PlantCareInfo,
|
||||
preferences: CarePreferences?,
|
||||
daysAhead: Int = defaultDaysAhead
|
||||
) -> [CareTask] {
|
||||
var tasks: [CareTask] = []
|
||||
|
||||
// Get preferred time from preferences or use defaults
|
||||
let hour = preferences?.preferredWateringHour ?? Self.defaultHour
|
||||
let minute = preferences?.preferredWateringMinute ?? Self.defaultMinute
|
||||
|
||||
// Calculate start date (tomorrow)
|
||||
let calendar = Calendar.current
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date()))!
|
||||
|
||||
// Generate watering tasks
|
||||
let wateringInterval = careInfo.wateringSchedule.frequency.intervalDays
|
||||
let wateringTaskCount = max(1, daysAhead / wateringInterval)
|
||||
let wateringDates = generateDates(
|
||||
startingFrom: tomorrow,
|
||||
intervalDays: wateringInterval,
|
||||
count: wateringTaskCount,
|
||||
hour: hour,
|
||||
minute: minute
|
||||
)
|
||||
|
||||
let wateringTasks = wateringDates.map { date in
|
||||
CareTask(
|
||||
plantID: plantID,
|
||||
type: .watering,
|
||||
scheduledDate: date,
|
||||
notes: "Water with \(careInfo.wateringSchedule.amount.rawValue) amount"
|
||||
)
|
||||
}
|
||||
tasks.append(contentsOf: wateringTasks)
|
||||
|
||||
// Generate fertilizer tasks if schedule is present
|
||||
if let fertilizerSchedule = careInfo.fertilizerSchedule {
|
||||
let fertilizerInterval = intervalDays(for: fertilizerSchedule.frequency)
|
||||
let fertilizerTaskCount = max(1, daysAhead / fertilizerInterval)
|
||||
let fertilizerDates = generateDates(
|
||||
startingFrom: tomorrow,
|
||||
intervalDays: fertilizerInterval,
|
||||
count: fertilizerTaskCount,
|
||||
hour: hour,
|
||||
minute: minute
|
||||
)
|
||||
|
||||
let fertilizerTasks = fertilizerDates.map { date in
|
||||
CareTask(
|
||||
plantID: plantID,
|
||||
type: .fertilizing,
|
||||
scheduledDate: date,
|
||||
notes: "Apply \(fertilizerSchedule.type.rawValue) fertilizer"
|
||||
)
|
||||
}
|
||||
tasks.append(contentsOf: fertilizerTasks)
|
||||
}
|
||||
|
||||
// Sort tasks by scheduled date
|
||||
return tasks.sorted { $0.scheduledDate < $1.scheduledDate }
|
||||
}
|
||||
|
||||
/// Generates a series of dates at regular intervals.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - startingFrom: The starting date for generation.
|
||||
/// - intervalDays: The number of days between each date.
|
||||
/// - count: The number of dates to generate.
|
||||
/// - hour: The hour component for each generated date (0-23).
|
||||
/// - minute: The minute component for each generated date (0-59).
|
||||
/// - Returns: An array of dates at the specified intervals.
|
||||
private func generateDates(
|
||||
startingFrom: Date,
|
||||
intervalDays: Int,
|
||||
count: Int,
|
||||
hour: Int,
|
||||
minute: Int
|
||||
) -> [Date] {
|
||||
let calendar = Calendar.current
|
||||
var dates: [Date] = []
|
||||
|
||||
for index in 0..<count {
|
||||
guard let baseDate = calendar.date(
|
||||
byAdding: .day,
|
||||
value: index * intervalDays,
|
||||
to: startingFrom
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the specific hour and minute
|
||||
var components = calendar.dateComponents([.year, .month, .day], from: baseDate)
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
components.second = 0
|
||||
|
||||
if let scheduledDate = calendar.date(from: components) {
|
||||
dates.append(scheduledDate)
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
/// Calculates the interval in days for a fertilizer frequency.
|
||||
///
|
||||
/// - Parameter frequency: The fertilizer frequency.
|
||||
/// - Returns: The number of days between fertilizing sessions.
|
||||
private func intervalDays(for frequency: FertilizerFrequency) -> Int {
|
||||
switch frequency {
|
||||
case .weekly:
|
||||
return 7
|
||||
case .biweekly:
|
||||
return 14
|
||||
case .monthly:
|
||||
return 30
|
||||
case .quarterly:
|
||||
return 90
|
||||
case .biannually:
|
||||
return 182
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//
|
||||
// FetchPlantCareUseCase.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created on 2026-01-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - FetchPlantCareUseCaseProtocol
|
||||
|
||||
/// Protocol defining the interface for fetching plant care information.
|
||||
///
|
||||
/// This protocol enables dependency injection and easy mocking for unit tests.
|
||||
/// Implementations retrieve plant care data from the Trefle botanical API
|
||||
/// and transform it into domain entities.
|
||||
protocol FetchPlantCareUseCaseProtocol: Sendable {
|
||||
/// Fetches care information for a plant by its scientific name.
|
||||
///
|
||||
/// This method searches the Trefle database for the plant matching
|
||||
/// the scientific name, retrieves detailed species information,
|
||||
/// and maps it to a `PlantCareInfo` domain entity.
|
||||
///
|
||||
/// - Parameter scientificName: The scientific (botanical) name of the plant
|
||||
/// (e.g., "Rosa gallica").
|
||||
/// - Returns: A `PlantCareInfo` entity containing care requirements.
|
||||
/// - Throws: `FetchPlantCareError` if the plant cannot be found or data retrieval fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
|
||||
/// print("Light: \(careInfo.lightRequirement)")
|
||||
/// ```
|
||||
func execute(scientificName: String) async throws -> PlantCareInfo
|
||||
|
||||
/// Fetches care information for a plant by its Trefle species ID.
|
||||
///
|
||||
/// This method retrieves detailed species information directly using
|
||||
/// the numeric Trefle ID and maps it to a `PlantCareInfo` domain entity.
|
||||
/// This is more efficient than searching by name when the ID is already known.
|
||||
///
|
||||
/// - Parameter trefleId: The numeric identifier for the species in the Trefle database.
|
||||
/// - Returns: A `PlantCareInfo` entity containing care requirements.
|
||||
/// - Throws: `FetchPlantCareError` if the species cannot be found or data retrieval fails.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// let careInfo = try await useCase.execute(trefleId: 123456)
|
||||
/// print("Watering: \(careInfo.wateringSchedule.frequency)")
|
||||
/// ```
|
||||
func execute(trefleId: Int) async throws -> PlantCareInfo
|
||||
}
|
||||
|
||||
// MARK: - FetchPlantCareError
|
||||
|
||||
/// Errors that can occur when fetching plant care information.
|
||||
///
|
||||
/// These errors provide specific context for care data retrieval failures,
|
||||
/// enabling appropriate error handling and user messaging throughout
|
||||
/// the plant care workflow.
|
||||
enum FetchPlantCareError: Error, LocalizedError {
|
||||
/// The species could not be found in the Trefle database.
|
||||
///
|
||||
/// This error occurs when a search by scientific name returns no results,
|
||||
/// or when a species ID does not exist in the database.
|
||||
case speciesNotFound(name: String)
|
||||
|
||||
/// A network error occurred while fetching data.
|
||||
///
|
||||
/// This error wraps underlying network errors from the API service,
|
||||
/// such as connection failures, timeouts, or server errors.
|
||||
case networkError(Error)
|
||||
|
||||
/// The Trefle API returned data but it was insufficient for care information.
|
||||
///
|
||||
/// This error occurs when the API returns a valid species but lacks
|
||||
/// the necessary growth and care data to populate a `PlantCareInfo` entity.
|
||||
case noDataAvailable
|
||||
|
||||
// MARK: - LocalizedError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .speciesNotFound(let name):
|
||||
return "Could not find care information for '\(name)'."
|
||||
case .networkError(let error):
|
||||
return "Network error: \(error.localizedDescription)"
|
||||
case .noDataAvailable:
|
||||
return "No care data is available for this plant."
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .speciesNotFound(let name):
|
||||
return "No species matching '\(name)' was found in the botanical database."
|
||||
case .networkError:
|
||||
return "The request to the plant database failed."
|
||||
case .noDataAvailable:
|
||||
return "The botanical database does not have care requirements for this species."
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .speciesNotFound:
|
||||
return "Try using a different spelling or the full scientific name."
|
||||
case .networkError:
|
||||
return "Check your internet connection and try again."
|
||||
case .noDataAvailable:
|
||||
return "Try searching for a related species or consult a plant care guide."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FetchPlantCareUseCase
|
||||
|
||||
/// Use case for fetching plant care information from the Trefle botanical API.
|
||||
///
|
||||
/// This use case coordinates the retrieval of plant care data by:
|
||||
/// 1. Checking local cache first for previously fetched care info
|
||||
/// 2. Validating cache freshness (7-day expiration by default)
|
||||
/// 3. Searching for plants by scientific name or fetching directly by ID
|
||||
/// 4. Retrieving detailed species information including growth requirements
|
||||
/// 5. Mapping API responses to domain entities using `TrefleMapper`
|
||||
/// 6. Caching API responses for future use
|
||||
/// 7. Providing fallback default care information when API data is incomplete
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// let useCase = FetchPlantCareUseCase(
|
||||
/// trefleAPIService: trefleService,
|
||||
/// cacheRepository: cacheStorage
|
||||
/// )
|
||||
///
|
||||
/// // Fetch by scientific name (checks cache first)
|
||||
/// let careInfo = try await useCase.execute(scientificName: "Rosa gallica")
|
||||
///
|
||||
/// // Or fetch by Trefle ID
|
||||
/// let careInfo = try await useCase.execute(trefleId: 123456)
|
||||
/// ```
|
||||
final class FetchPlantCareUseCase: FetchPlantCareUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let trefleAPIService: TrefleAPIServiceProtocol
|
||||
private let cacheRepository: PlantCareInfoRepositoryProtocol?
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Cache expiration duration (7 days in seconds)
|
||||
private let cacheExpiration: TimeInterval = 7 * 24 * 60 * 60
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new FetchPlantCareUseCase instance.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - trefleAPIService: The Trefle API service for retrieving plant data.
|
||||
/// - cacheRepository: Optional repository for caching care info locally.
|
||||
init(
|
||||
trefleAPIService: TrefleAPIServiceProtocol,
|
||||
cacheRepository: PlantCareInfoRepositoryProtocol? = nil
|
||||
) {
|
||||
self.trefleAPIService = trefleAPIService
|
||||
self.cacheRepository = cacheRepository
|
||||
}
|
||||
|
||||
// MARK: - FetchPlantCareUseCaseProtocol
|
||||
|
||||
func execute(scientificName: String) async throws -> PlantCareInfo {
|
||||
// 1. Check cache first
|
||||
if let cached = try? await fetchFromCache(scientificName: scientificName) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 2. Fetch from API
|
||||
let careInfo = try await fetchFromAPI(scientificName: scientificName)
|
||||
|
||||
// 3. Cache the result (fire and forget, don't block on cache errors)
|
||||
Task {
|
||||
try? await cacheRepository?.save(careInfo, for: nil)
|
||||
}
|
||||
|
||||
return careInfo
|
||||
}
|
||||
|
||||
/// Fetches care info from cache if it exists and is not stale.
|
||||
///
|
||||
/// - Parameter scientificName: The scientific name of the plant.
|
||||
/// - Returns: Cached PlantCareInfo if valid, nil otherwise.
|
||||
private func fetchFromCache(scientificName: String) async throws -> PlantCareInfo? {
|
||||
guard let repository = cacheRepository else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if cache is stale
|
||||
let isStale = try await repository.isCacheStale(
|
||||
scientificName: scientificName,
|
||||
cacheExpiration: cacheExpiration
|
||||
)
|
||||
|
||||
if isStale {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try await repository.fetch(scientificName: scientificName)
|
||||
}
|
||||
|
||||
/// Fetches care info from the Trefle API.
|
||||
///
|
||||
/// - Parameter scientificName: The scientific name of the plant.
|
||||
/// - Returns: PlantCareInfo from the API.
|
||||
private func fetchFromAPI(scientificName: String) async throws -> PlantCareInfo {
|
||||
do {
|
||||
// Search for the plant by scientific name
|
||||
let searchResponse = try await trefleAPIService.searchPlants(query: scientificName, page: 1)
|
||||
|
||||
// Take the first result from the search
|
||||
guard let firstResult = searchResponse.data.first else {
|
||||
throw FetchPlantCareError.speciesNotFound(name: scientificName)
|
||||
}
|
||||
|
||||
// Fetch full species details using the slug
|
||||
let speciesResponse = try await trefleAPIService.getSpecies(slug: firstResult.slug)
|
||||
let species = speciesResponse.data
|
||||
|
||||
// Map to PlantCareInfo using TrefleMapper
|
||||
let careInfo = TrefleMapper.mapToPlantCareInfo(from: species)
|
||||
|
||||
return careInfo
|
||||
|
||||
} catch let error as FetchPlantCareError {
|
||||
throw error
|
||||
} catch let error as TrefleAPIError {
|
||||
// Handle specific Trefle API errors
|
||||
switch error {
|
||||
case .speciesNotFound:
|
||||
throw FetchPlantCareError.speciesNotFound(name: scientificName)
|
||||
default:
|
||||
throw FetchPlantCareError.networkError(error)
|
||||
}
|
||||
} catch {
|
||||
throw FetchPlantCareError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func execute(trefleId: Int) async throws -> PlantCareInfo {
|
||||
// 1. Check cache first
|
||||
if let cached = try? await cacheRepository?.fetch(trefleID: trefleId) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 2. Fetch from API
|
||||
do {
|
||||
// Fetch species directly by ID
|
||||
let speciesResponse = try await trefleAPIService.getSpeciesById(id: trefleId)
|
||||
let species = speciesResponse.data
|
||||
|
||||
// Map to PlantCareInfo using TrefleMapper
|
||||
let careInfo = TrefleMapper.mapToPlantCareInfo(from: species)
|
||||
|
||||
// 3. Cache the result (fire and forget)
|
||||
Task {
|
||||
try? await cacheRepository?.save(careInfo, for: nil)
|
||||
}
|
||||
|
||||
return careInfo
|
||||
|
||||
} catch let error as TrefleAPIError {
|
||||
// Handle specific Trefle API errors
|
||||
switch error {
|
||||
case .speciesNotFound:
|
||||
throw FetchPlantCareError.speciesNotFound(name: "ID: \(trefleId)")
|
||||
default:
|
||||
throw FetchPlantCareError.networkError(error)
|
||||
}
|
||||
} catch {
|
||||
throw FetchPlantCareError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Creates default care information when Trefle doesn't have sufficient data.
|
||||
///
|
||||
/// This method provides generic, safe care recommendations for plants
|
||||
/// when the API returns incomplete data. The defaults are conservative
|
||||
/// and suitable for most common houseplants.
|
||||
///
|
||||
/// - Parameter scientificName: The scientific name of the plant.
|
||||
/// - Returns: A `PlantCareInfo` entity with sensible default values.
|
||||
///
|
||||
/// Default values:
|
||||
/// - Light: Partial shade (adaptable to most conditions)
|
||||
/// - Watering: Weekly with moderate amount
|
||||
/// - Temperature: 15-30 degrees Celsius (typical indoor range)
|
||||
/// - No fertilizer schedule (to avoid over-fertilizing)
|
||||
/// - No humidity, growth rate, or blooming season (unknown)
|
||||
private func createDefaultCareInfo(scientificName: String) -> PlantCareInfo {
|
||||
PlantCareInfo(
|
||||
id: UUID(),
|
||||
scientificName: scientificName,
|
||||
commonName: nil,
|
||||
lightRequirement: .partialShade,
|
||||
wateringSchedule: WateringSchedule(
|
||||
frequency: .weekly,
|
||||
amount: .moderate
|
||||
),
|
||||
temperatureRange: TemperatureRange(
|
||||
minimumCelsius: 15.0,
|
||||
maximumCelsius: 30.0,
|
||||
optimalCelsius: 22.0,
|
||||
frostTolerant: false
|
||||
),
|
||||
fertilizerSchedule: nil,
|
||||
humidity: nil,
|
||||
growthRate: nil,
|
||||
bloomingSeason: nil,
|
||||
additionalNotes: "Care information is based on general recommendations. Monitor your plant and adjust care as needed.",
|
||||
sourceURL: nil,
|
||||
trefleID: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol for looking up plants in the local database.
|
||||
protocol LookupPlantUseCaseProtocol: Sendable {
|
||||
/// Looks up a plant by exact scientific name
|
||||
func execute(scientificName: String) async -> LocalPlantEntry?
|
||||
|
||||
/// Searches for plants matching a query string
|
||||
func search(query: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Suggests matches for an identified plant name based on confidence
|
||||
/// - Parameters:
|
||||
/// - identifiedName: The name returned by identification
|
||||
/// - confidence: Confidence score from 0.0 to 1.0
|
||||
/// - Returns: Suggested plant matches from local database
|
||||
func suggestMatches(for identifiedName: String, confidence: Double) async -> [LocalPlantEntry]
|
||||
|
||||
/// Returns related species from the same genus
|
||||
func getRelatedSpecies(for scientificName: String) async -> [LocalPlantEntry]
|
||||
|
||||
/// Performs fuzzy search with typo tolerance
|
||||
func fuzzySearch(_ query: String) async -> [LocalPlantEntry]
|
||||
}
|
||||
|
||||
/// Use case for looking up and searching plants in the local database.
|
||||
final class LookupPlantUseCase: LookupPlantUseCaseProtocol, @unchecked Sendable {
|
||||
|
||||
private let databaseService: PlantDatabaseServiceProtocol
|
||||
|
||||
init(databaseService: PlantDatabaseServiceProtocol) {
|
||||
self.databaseService = databaseService
|
||||
}
|
||||
|
||||
func execute(scientificName: String) async -> LocalPlantEntry? {
|
||||
// Ensure database is loaded
|
||||
try? await databaseService.loadDatabase()
|
||||
return await databaseService.getPlant(scientificName: scientificName)
|
||||
}
|
||||
|
||||
func search(query: String) async -> [LocalPlantEntry] {
|
||||
try? await databaseService.loadDatabase()
|
||||
return await databaseService.searchAll(query)
|
||||
}
|
||||
|
||||
func suggestMatches(for identifiedName: String, confidence: Double) async -> [LocalPlantEntry] {
|
||||
try? await databaseService.loadDatabase()
|
||||
|
||||
// Try exact match first
|
||||
if let exactMatch = await databaseService.getPlant(scientificName: identifiedName) {
|
||||
var results = [exactMatch]
|
||||
|
||||
// For high confidence, also return related cultivars/species
|
||||
if confidence >= 0.7 {
|
||||
let related = await getRelatedSpecies(for: identifiedName)
|
||||
results.append(contentsOf: related.prefix(4))
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// For low confidence or no exact match, use fuzzy search
|
||||
if confidence < 0.7 {
|
||||
// Try to match base species name (strip cultivar if present)
|
||||
let baseName = extractBaseSpeciesName(from: identifiedName)
|
||||
if let baseMatch = await databaseService.getPlant(scientificName: baseName) {
|
||||
var results = [baseMatch]
|
||||
let related = await getRelatedSpecies(for: baseName)
|
||||
results.append(contentsOf: related.prefix(4))
|
||||
return results
|
||||
}
|
||||
|
||||
// Fall back to fuzzy search
|
||||
return await fuzzySearch(identifiedName)
|
||||
}
|
||||
|
||||
// Try partial matching for moderate confidence
|
||||
let searchResults = await databaseService.searchAll(identifiedName)
|
||||
return Array(searchResults.prefix(5))
|
||||
}
|
||||
|
||||
func getRelatedSpecies(for scientificName: String) async -> [LocalPlantEntry] {
|
||||
guard let service = databaseService as? PlantDatabaseService else {
|
||||
return []
|
||||
}
|
||||
return await service.getRelatedSpecies(for: scientificName)
|
||||
}
|
||||
|
||||
func fuzzySearch(_ query: String) async -> [LocalPlantEntry] {
|
||||
guard let service = databaseService as? PlantDatabaseService else {
|
||||
// Fall back to regular search if not the concrete type
|
||||
return await databaseService.searchAll(query)
|
||||
}
|
||||
return await service.fuzzySearch(query)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Extracts the base species name without cultivar designation
|
||||
private func extractBaseSpeciesName(from name: String) -> String {
|
||||
// Remove cultivar names in quotes (e.g., "'Brasil'", "'Pink Princess'")
|
||||
if name.contains("'") {
|
||||
let parts = name.components(separatedBy: "'")
|
||||
return parts.first?.trimmingCharacters(in: .whitespaces) ?? name
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>PlantGuide needs camera access to identify plants by taking photos.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>PlantGuide needs photo library access to select existing plant photos for identification.</string>
|
||||
<key>PLANTNET_API_KEY</key>
|
||||
<string>$(PLANTNET_API_KEY)</string>
|
||||
<key>TREFLE_API_TOKEN</key>
|
||||
<string>$(TREFLE_API_TOKEN)</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,485 @@
|
||||
//
|
||||
// ImagePreprocessor.swift
|
||||
// PlantGuide
|
||||
//
|
||||
// Created by Trey Tartt on 1/21/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
|
||||
// MARK: - Image Preprocessor Protocol
|
||||
|
||||
/// Protocol for preprocessing images before classification
|
||||
protocol ImagePreprocessorProtocol: Sendable {
|
||||
/// Preprocesses a UIImage for model input
|
||||
/// - Parameter image: The source UIImage to preprocess
|
||||
/// - Returns: A CGImage ready for classification
|
||||
/// - Throws: If preprocessing fails
|
||||
func preprocess(_ image: UIImage) async throws -> CGImage
|
||||
}
|
||||
|
||||
// MARK: - Image Preprocessor Error
|
||||
|
||||
enum ImagePreprocessorError: LocalizedError {
|
||||
case invalidImage
|
||||
case corruptData
|
||||
case unsupportedFormat
|
||||
case dimensionsTooSmall(width: Int, height: Int)
|
||||
case colorSpaceConversionFailed
|
||||
case cgImageCreationFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
return "The provided image is invalid or nil."
|
||||
case .corruptData:
|
||||
return "The image data is corrupt or unreadable."
|
||||
case .unsupportedFormat:
|
||||
return "The image format is not supported."
|
||||
case .dimensionsTooSmall(let width, let height):
|
||||
return "Image dimensions (\(width)x\(height)) are below the minimum required (224x224)."
|
||||
case .colorSpaceConversionFailed:
|
||||
return "Failed to convert image to sRGB color space."
|
||||
case .cgImageCreationFailed:
|
||||
return "Failed to create CGImage from the provided image."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Preprocessor Configuration
|
||||
|
||||
/// Configuration for image preprocessing operations.
|
||||
struct ImagePreprocessorConfiguration: Sendable {
|
||||
/// Minimum width required for ML model input.
|
||||
let minimumWidth: Int
|
||||
|
||||
/// Minimum height required for ML model input.
|
||||
let minimumHeight: Int
|
||||
|
||||
/// Whether to enforce sRGB color space conversion.
|
||||
let requiresSRGB: Bool
|
||||
|
||||
/// Default configuration for plant identification model (224x224 RGB input).
|
||||
static let `default` = ImagePreprocessorConfiguration(
|
||||
minimumWidth: 224,
|
||||
minimumHeight: 224,
|
||||
requiresSRGB: true
|
||||
)
|
||||
|
||||
init(minimumWidth: Int = 224, minimumHeight: Int = 224, requiresSRGB: Bool = true) {
|
||||
self.minimumWidth = minimumWidth
|
||||
self.minimumHeight = minimumHeight
|
||||
self.requiresSRGB = requiresSRGB
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Preprocessor
|
||||
|
||||
/// Prepares images for Core ML model inference.
|
||||
///
|
||||
/// This struct handles:
|
||||
/// - EXIF orientation correction
|
||||
/// - Color space conversion to sRGB
|
||||
/// - Dimension validation (minimum 224x224)
|
||||
/// - Various input formats (UIImage, Data)
|
||||
///
|
||||
/// The Vision framework handles actual resizing to model input dimensions.
|
||||
struct ImagePreprocessor: ImagePreprocessorProtocol, Sendable {
|
||||
// MARK: - Properties
|
||||
|
||||
private let configuration: ImagePreprocessorConfiguration
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(configuration: ImagePreprocessorConfiguration = .default) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: - ImagePreprocessorProtocol Conformance
|
||||
|
||||
/// Preprocesses a UIImage for model input (async throwing variant for protocol conformance).
|
||||
/// - Parameter image: The source UIImage to preprocess.
|
||||
/// - Returns: A CGImage ready for classification.
|
||||
/// - Throws: `ImagePreprocessorError` if preprocessing fails.
|
||||
func preprocess(_ image: UIImage) async throws -> CGImage {
|
||||
try prepareWithValidation(image: image)
|
||||
}
|
||||
|
||||
// MARK: - Convenience Methods
|
||||
|
||||
/// Prepares a UIImage for ML model input.
|
||||
/// - Parameter image: The source UIImage to prepare.
|
||||
/// - Returns: A CGImage ready for Vision framework processing, or nil if preparation fails.
|
||||
func prepare(image: UIImage) -> CGImage? {
|
||||
// Handle nil cgImage case
|
||||
guard let cgImage = extractCGImage(from: image) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return processImage(cgImage)
|
||||
}
|
||||
|
||||
/// Prepares image data for ML model input.
|
||||
/// - Parameter data: Raw image data (JPEG, PNG, HEIC, etc.).
|
||||
/// - Returns: A CGImage ready for Vision framework processing, or nil if preparation fails.
|
||||
func prepare(data: Data) -> CGImage? {
|
||||
// Validate data is not empty
|
||||
guard !data.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create image source from data
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the source has at least one image
|
||||
guard CGImageSourceGetCount(imageSource) > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get image properties to determine orientation
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
kCGImageSourceShouldAllowFloat: true
|
||||
]
|
||||
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract EXIF orientation if available
|
||||
let orientation = extractOrientation(from: imageSource)
|
||||
|
||||
// Apply orientation correction
|
||||
let correctedImage = applyOrientationCorrection(to: cgImage, orientation: orientation)
|
||||
|
||||
return processImage(correctedImage)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Extracts CGImage from UIImage, handling orientation.
|
||||
private func extractCGImage(from image: UIImage) -> CGImage? {
|
||||
// If the image already has correct orientation (.up), return cgImage directly
|
||||
if image.imageOrientation == .up {
|
||||
return image.cgImage
|
||||
}
|
||||
|
||||
// Otherwise, render the image with correct orientation
|
||||
return renderWithCorrectOrientation(image)
|
||||
}
|
||||
|
||||
/// Renders a UIImage to a new CGImage with correct orientation applied.
|
||||
private func renderWithCorrectOrientation(_ image: UIImage) -> CGImage? {
|
||||
let size = image.size
|
||||
|
||||
guard size.width > 0 && size.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a bitmap context with sRGB color space
|
||||
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
|
||||
// Fallback to device RGB if sRGB is unavailable
|
||||
guard let deviceColorSpace = CGColorSpace(name: CGColorSpace.genericRGBLinear) else {
|
||||
return nil
|
||||
}
|
||||
return renderImage(image, size: size, colorSpace: deviceColorSpace)
|
||||
}
|
||||
|
||||
return renderImage(image, size: size, colorSpace: colorSpace)
|
||||
}
|
||||
|
||||
/// Renders image to a new bitmap context.
|
||||
private func renderImage(_ image: UIImage, size: CGSize, colorSpace: CGColorSpace) -> CGImage? {
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
|
||||
guard width > 0 && height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UIKit draws with origin at top-left, CGContext at bottom-left
|
||||
context.translateBy(x: 0, y: CGFloat(height))
|
||||
context.scaleBy(x: 1.0, y: -1.0)
|
||||
|
||||
UIGraphicsPushContext(context)
|
||||
image.draw(in: CGRect(origin: .zero, size: size))
|
||||
UIGraphicsPopContext()
|
||||
|
||||
return context.makeImage()
|
||||
}
|
||||
|
||||
/// Extracts EXIF orientation from image source.
|
||||
private func extractOrientation(from source: CGImageSource) -> CGImagePropertyOrientation {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let orientationValue = properties[kCGImagePropertyOrientation] as? UInt32,
|
||||
let orientation = CGImagePropertyOrientation(rawValue: orientationValue) else {
|
||||
return .up
|
||||
}
|
||||
return orientation
|
||||
}
|
||||
|
||||
/// Applies orientation correction to a CGImage.
|
||||
private func applyOrientationCorrection(
|
||||
to image: CGImage,
|
||||
orientation: CGImagePropertyOrientation
|
||||
) -> CGImage {
|
||||
// If orientation is already correct, return as-is
|
||||
guard orientation != .up else {
|
||||
return image
|
||||
}
|
||||
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
|
||||
// Calculate the size of the output image
|
||||
let outputSize: (width: Int, height: Int)
|
||||
switch orientation {
|
||||
case .left, .leftMirrored, .right, .rightMirrored:
|
||||
outputSize = (height, width)
|
||||
default:
|
||||
outputSize = (width, height)
|
||||
}
|
||||
|
||||
// Create context with sRGB color space
|
||||
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? image.colorSpace else {
|
||||
return image
|
||||
}
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: outputSize.width,
|
||||
height: outputSize.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
) else {
|
||||
return image
|
||||
}
|
||||
|
||||
// Apply transform based on orientation
|
||||
applyTransform(to: context, for: orientation, width: width, height: height)
|
||||
|
||||
// Draw the image
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
|
||||
/// Applies the appropriate transform to the context based on EXIF orientation.
|
||||
private func applyTransform(
|
||||
to context: CGContext,
|
||||
for orientation: CGImagePropertyOrientation,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
let w = CGFloat(width)
|
||||
let h = CGFloat(height)
|
||||
|
||||
switch orientation {
|
||||
case .up:
|
||||
break
|
||||
case .upMirrored:
|
||||
context.translateBy(x: w, y: 0)
|
||||
context.scaleBy(x: -1, y: 1)
|
||||
case .down:
|
||||
context.translateBy(x: w, y: h)
|
||||
context.rotate(by: .pi)
|
||||
case .downMirrored:
|
||||
context.translateBy(x: 0, y: h)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
case .left:
|
||||
context.translateBy(x: h, y: 0)
|
||||
context.rotate(by: .pi / 2)
|
||||
case .leftMirrored:
|
||||
context.translateBy(x: h, y: w)
|
||||
context.rotate(by: .pi / 2)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
case .right:
|
||||
context.translateBy(x: 0, y: w)
|
||||
context.rotate(by: -.pi / 2)
|
||||
case .rightMirrored:
|
||||
context.translateBy(x: 0, y: 0)
|
||||
context.rotate(by: -.pi / 2)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes a CGImage for ML model input.
|
||||
private func processImage(_ image: CGImage) -> CGImage? {
|
||||
// Validate dimensions
|
||||
guard validateDimensions(width: image.width, height: image.height) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to sRGB if required
|
||||
if configuration.requiresSRGB {
|
||||
return convertToSRGB(image)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
/// Validates that image dimensions meet minimum requirements.
|
||||
private func validateDimensions(width: Int, height: Int) -> Bool {
|
||||
return width >= configuration.minimumWidth && height >= configuration.minimumHeight
|
||||
}
|
||||
|
||||
/// Converts a CGImage to sRGB color space if needed.
|
||||
private func convertToSRGB(_ image: CGImage) -> CGImage? {
|
||||
// Check if already in sRGB
|
||||
if let colorSpace = image.colorSpace,
|
||||
colorSpace.name == CGColorSpace.sRGB {
|
||||
return image
|
||||
}
|
||||
|
||||
// Create sRGB color space
|
||||
guard let srgbColorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
|
||||
return image // Return original if sRGB is unavailable
|
||||
}
|
||||
|
||||
let width = image.width
|
||||
let height = image.height
|
||||
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: srgbColorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
) else {
|
||||
return image // Return original if context creation fails
|
||||
}
|
||||
|
||||
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Throwing Variant
|
||||
|
||||
extension ImagePreprocessor {
|
||||
/// Prepares a UIImage for ML model input, throwing detailed errors on failure.
|
||||
/// - Parameter image: The source UIImage to prepare.
|
||||
/// - Returns: A CGImage ready for Vision framework processing.
|
||||
/// - Throws: `ImagePreprocessorError` if preparation fails.
|
||||
func prepareWithValidation(image: UIImage) throws -> CGImage {
|
||||
guard let cgImage = extractCGImage(from: image) else {
|
||||
throw ImagePreprocessorError.cgImageCreationFailed
|
||||
}
|
||||
|
||||
// Validate dimensions
|
||||
guard validateDimensions(width: cgImage.width, height: cgImage.height) else {
|
||||
throw ImagePreprocessorError.dimensionsTooSmall(
|
||||
width: cgImage.width,
|
||||
height: cgImage.height
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to sRGB if required
|
||||
if configuration.requiresSRGB {
|
||||
guard let srgbImage = convertToSRGB(cgImage) else {
|
||||
throw ImagePreprocessorError.colorSpaceConversionFailed
|
||||
}
|
||||
return srgbImage
|
||||
}
|
||||
|
||||
return cgImage
|
||||
}
|
||||
|
||||
/// Prepares image data for ML model input, throwing detailed errors on failure.
|
||||
/// - Parameter data: Raw image data (JPEG, PNG, HEIC, etc.).
|
||||
/// - Returns: A CGImage ready for Vision framework processing.
|
||||
/// - Throws: `ImagePreprocessorError` if preparation fails.
|
||||
func prepareWithValidation(data: Data) throws -> CGImage {
|
||||
guard !data.isEmpty else {
|
||||
throw ImagePreprocessorError.corruptData
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
throw ImagePreprocessorError.corruptData
|
||||
}
|
||||
|
||||
guard CGImageSourceGetCount(imageSource) > 0 else {
|
||||
throw ImagePreprocessorError.unsupportedFormat
|
||||
}
|
||||
|
||||
// Check if the format is supported
|
||||
guard let type = CGImageSourceGetType(imageSource),
|
||||
isFormatSupported(type) else {
|
||||
throw ImagePreprocessorError.unsupportedFormat
|
||||
}
|
||||
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
kCGImageSourceShouldAllowFloat: true
|
||||
]
|
||||
|
||||
guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) else {
|
||||
throw ImagePreprocessorError.cgImageCreationFailed
|
||||
}
|
||||
|
||||
// Extract and apply orientation
|
||||
let orientation = extractOrientation(from: imageSource)
|
||||
let correctedImage = applyOrientationCorrection(to: cgImage, orientation: orientation)
|
||||
|
||||
// Validate dimensions
|
||||
guard validateDimensions(width: correctedImage.width, height: correctedImage.height) else {
|
||||
throw ImagePreprocessorError.dimensionsTooSmall(
|
||||
width: correctedImage.width,
|
||||
height: correctedImage.height
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to sRGB if required
|
||||
if configuration.requiresSRGB {
|
||||
guard let srgbImage = convertToSRGB(correctedImage) else {
|
||||
throw ImagePreprocessorError.colorSpaceConversionFailed
|
||||
}
|
||||
return srgbImage
|
||||
}
|
||||
|
||||
return correctedImage
|
||||
}
|
||||
|
||||
/// Checks if the image format is supported.
|
||||
private func isFormatSupported(_ typeIdentifier: CFString) -> Bool {
|
||||
let supportedTypes: Set<String> = [
|
||||
"public.jpeg",
|
||||
"public.png",
|
||||
"public.heic",
|
||||
"public.heif",
|
||||
"com.compuserve.gif",
|
||||
"public.tiff",
|
||||
"com.microsoft.bmp",
|
||||
"com.apple.icns"
|
||||
]
|
||||
|
||||
return supportedTypes.contains(typeIdentifier as String)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user