Add PlantGuide iOS app with plant identification and care management

- Implement camera capture and plant identification workflow
- Add Core Data persistence for plants, care schedules, and cached API data
- Create collection view with grid/list layouts and filtering
- Build plant detail views with care information display
- Integrate Trefle botanical API for plant care data
- Add local image storage for captured plant photos
- Implement dependency injection container for testability
- Include accessibility support throughout the app

Bug fixes in this commit:
- Fix Trefle API decoding by removing duplicate CodingKeys
- Fix LocalCachedImage to load from correct PlantImages directory
- Set dateAdded when saving plants for proper collection sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

953
Docs/Phase4_plan.md Normal file
View File

@@ -0,0 +1,953 @@
# Phase 4: Trefle API & Plant Care
**Goal:** Complete care information and scheduling with local notifications
**Prerequisites:** Phase 3 complete (hybrid identification working, API infrastructure established)
---
## Tasks
### 4.1 Register for Trefle API Access
- [ ] Navigate to [trefle.io](https://trefle.io)
- [ ] Create developer account
- [ ] Generate API token
- [ ] Review API documentation and rate limits
- [ ] Add `TREFLE_API_TOKEN` to `APIKeys.swift`:
```swift
enum APIKeys {
// ... existing keys
static let trefleAPIToken: String = {
guard let token = Bundle.main.object(forInfoDictionaryKey: "TREFLE_API_TOKEN") as? String else {
fatalError("Trefle API token not configured")
}
return token
}()
}
```
- [ ] Add `TREFLE_API_TOKEN` to Info.plist via xcconfig
- [ ] Update `.xcconfig` file with Trefle token (already in .gitignore)
- [ ] Verify API access with test request
**Acceptance Criteria:** API token configured and accessible, test request returns valid data
---
### 4.2 Create Trefle Endpoints
- [ ] Create `Data/DataSources/Remote/TrefleAPI/TrefleEndpoints.swift`
- [ ] Define endpoint configuration:
```swift
enum TrefleEndpoint: Endpoint {
case searchPlants(query: String, page: Int)
case getSpecies(slug: String)
case getSpeciesById(id: Int)
case getPlant(id: Int)
var baseURL: URL { URL(string: "https://trefle.io/api/v1")! }
var path: String {
switch self {
case .searchPlants: return "/plants/search"
case .getSpecies(let slug): return "/species/\(slug)"
case .getSpeciesById(let id): return "/species/\(id)"
case .getPlant(let id): return "/plants/\(id)"
}
}
var method: HTTPMethod { .get }
var queryItems: [URLQueryItem] {
var items = [URLQueryItem(name: "token", value: APIKeys.trefleAPIToken)]
switch self {
case .searchPlants(let query, let page):
items.append(URLQueryItem(name: "q", value: query))
items.append(URLQueryItem(name: "page", value: String(page)))
default:
break
}
return items
}
}
```
- [ ] Support pagination for search results
- [ ] Add filter parameters (edible, vegetable, etc.)
**Acceptance Criteria:** Endpoints build correct URLs with token and query parameters
---
### 4.3 Implement Trefle API Service
- [ ] Create `Data/DataSources/Remote/TrefleAPI/TrefleAPIService.swift`
- [ ] Define protocol:
```swift
protocol TrefleAPIServiceProtocol: Sendable {
func searchPlants(query: String, page: Int) async throws -> TrefleSearchResponseDTO
func getSpecies(slug: String) async throws -> TrefleSpeciesResponseDTO
func getSpeciesById(id: Int) async throws -> TrefleSpeciesResponseDTO
}
```
- [ ] Implement service using NetworkService:
- Handle token-based authentication
- Parse paginated responses
- Handle 404 for unknown species
- [ ] Implement retry logic (1 retry with exponential backoff)
- [ ] Add request timeout (15 seconds)
- [ ] Handle rate limiting (120 requests/minute)
- [ ] Log request/response for debugging
**Acceptance Criteria:** Service retrieves species data and handles errors gracefully
---
### 4.4 Create Trefle DTOs
- [ ] Create `Data/DataSources/Remote/TrefleAPI/DTOs/TrefleDTOs.swift`
- [ ] Define response DTOs:
```swift
struct TrefleSearchResponseDTO: Decodable {
let data: [TreflePlantSummaryDTO]
let links: TrefleLinksDTO
let meta: TrefleMetaDTO
}
struct TrefleSpeciesResponseDTO: Decodable {
let data: TrefleSpeciesDTO
let meta: TrefleMetaDTO
}
```
- [ ] Create `TrefleSpeciesDTO`:
```swift
struct TrefleSpeciesDTO: Decodable {
let id: Int
let commonName: String?
let slug: String
let scientificName: String
let year: Int?
let bibliography: String?
let author: String?
let familyCommonName: String?
let family: String?
let genus: String?
let genusId: Int?
let imageUrl: String?
let images: TrefleImagesDTO?
let distribution: TrefleDistributionDTO?
let specifications: TrefleSpecificationsDTO?
let growth: TrefleGrowthDTO?
let synonyms: [TrefleSynonymDTO]?
let sources: [TrefleSourceDTO]?
}
```
- [ ] Create `TrefleGrowthDTO`:
```swift
struct TrefleGrowthDTO: Decodable {
let description: String?
let sowing: String?
let daysToHarvest: Int?
let rowSpacing: TrefleMeasurementDTO?
let spread: TrefleMeasurementDTO?
let phMaximum: Double?
let phMinimum: Double?
let light: Int? // 0-10 scale
let atmosphericHumidity: Int? // 0-10 scale
let growthMonths: [String]?
let bloomMonths: [String]?
let fruitMonths: [String]?
let minimumPrecipitation: TrefleMeasurementDTO?
let maximumPrecipitation: TrefleMeasurementDTO?
let minimumRootDepth: TrefleMeasurementDTO?
let minimumTemperature: TrefleMeasurementDTO?
let maximumTemperature: TrefleMeasurementDTO?
let soilNutriments: Int? // 0-10 scale
let soilSalinity: Int? // 0-10 scale
let soilTexture: Int? // 0-10 scale
let soilHumidity: Int? // 0-10 scale
}
```
- [ ] Create supporting DTOs: `TrefleSpecificationsDTO`, `TrefleImagesDTO`, `TrefleMeasurementDTO`
- [ ] Add CodingKeys for snake_case API responses
- [ ] Write unit tests for DTO decoding
**Acceptance Criteria:** DTOs decode actual Trefle API responses without errors
---
### 4.5 Build Trefle Mapper
- [ ] Create `Data/Mappers/TrefleMapper.swift`
- [ ] Implement mapping functions:
```swift
struct TrefleMapper {
static func mapToPlantCareSchedule(
from species: TrefleSpeciesDTO,
plantID: UUID
) -> PlantCareSchedule
static func mapToLightRequirement(
from light: Int?
) -> LightRequirement
static func mapToWateringSchedule(
from growth: TrefleGrowthDTO?
) -> WateringSchedule
static func mapToTemperatureRange(
from growth: TrefleGrowthDTO?
) -> TemperatureRange
static func mapToFertilizerSchedule(
from growth: TrefleGrowthDTO?
) -> FertilizerSchedule?
static func generateCareTasks(
from schedule: PlantCareSchedule,
startDate: Date
) -> [CareTask]
}
```
- [ ] Map Trefle light scale (0-10) to `LightRequirement`:
```swift
enum LightRequirement: String, Codable, Sendable {
case fullShade // 0-2
case partialShade // 3-4
case partialSun // 5-6
case fullSun // 7-10
var description: String { ... }
var hoursOfLight: ClosedRange<Int> { ... }
}
```
- [ ] Map humidity/precipitation to `WateringSchedule`:
```swift
struct WateringSchedule: Codable, Sendable {
let frequency: WateringFrequency
let amount: WateringAmount
let seasonalAdjustments: [Season: WateringFrequency]?
enum WateringFrequency: String, Codable, Sendable {
case daily, everyOtherDay, twiceWeekly, weekly, biweekly, monthly
var intervalDays: Int { ... }
}
enum WateringAmount: String, Codable, Sendable {
case light, moderate, thorough, soak
}
}
```
- [ ] Map temperature data to `TemperatureRange`:
```swift
struct TemperatureRange: Codable, Sendable {
let minimum: Measurement<UnitTemperature>
let maximum: Measurement<UnitTemperature>
let optimal: Measurement<UnitTemperature>?
let frostTolerant: Bool
}
```
- [ ] Map soil nutrients to `FertilizerSchedule`:
```swift
struct FertilizerSchedule: Codable, Sendable {
let frequency: FertilizerFrequency
let type: FertilizerType
let seasonalApplication: Bool
let activeMonths: [Int]? // 1-12
enum FertilizerFrequency: String, Codable, Sendable {
case weekly, biweekly, monthly, quarterly, biannually
}
enum FertilizerType: String, Codable, Sendable {
case balanced, highNitrogen, highPhosphorus, highPotassium, organic
}
}
```
- [ ] Handle missing data with sensible defaults
- [ ] Unit test all mapping functions
**Acceptance Criteria:** Mapper produces valid care schedules from all Trefle response variations
---
### 4.6 Implement Fetch Plant Care Use Case
- [ ] Create `Domain/UseCases/PlantCare/FetchPlantCareUseCase.swift`
- [ ] Define protocol:
```swift
protocol FetchPlantCareUseCaseProtocol: Sendable {
func execute(scientificName: String) async throws -> PlantCareInfo
func execute(trefleId: Int) async throws -> PlantCareInfo
}
```
- [ ] Define `PlantCareInfo` domain entity:
```swift
struct PlantCareInfo: Identifiable, Sendable {
let id: UUID
let scientificName: String
let commonName: String?
let lightRequirement: LightRequirement
let wateringSchedule: WateringSchedule
let temperatureRange: TemperatureRange
let fertilizerSchedule: FertilizerSchedule?
let soilType: SoilType?
let humidity: HumidityLevel?
let growthRate: GrowthRate?
let bloomingSeason: [Season]?
let additionalNotes: String?
let sourceURL: URL?
}
```
- [ ] Implement use case:
- Search Trefle by scientific name
- Fetch detailed species data
- Map to domain entity
- Cache results for offline access
- [ ] Handle species not found in Trefle
- [ ] Add fallback to generic care data for unknown species
- [ ] Register in DIContainer
**Acceptance Criteria:** Use case retrieves care data, handles missing species gracefully
---
### 4.7 Create Care Schedule Use Case
- [ ] Create `Domain/UseCases/PlantCare/CreateCareScheduleUseCase.swift`
- [ ] Define protocol:
```swift
protocol CreateCareScheduleUseCaseProtocol: Sendable {
func execute(
for plant: Plant,
careInfo: PlantCareInfo,
userPreferences: CarePreferences?
) async throws -> PlantCareSchedule
}
```
- [ ] Define `CarePreferences`:
```swift
struct CarePreferences: Codable, Sendable {
let preferredWateringTime: DateComponents // e.g., 8:00 AM
let reminderDaysBefore: Int // remind N days before task
let groupWateringDays: Bool // water all plants same day
let adjustForSeason: Bool
let location: PlantLocation?
enum PlantLocation: String, Codable, Sendable {
case indoor, outdoor, greenhouse, balcony
}
}
```
- [ ] Implement schedule generation:
- Calculate next N watering dates (30 days ahead)
- Calculate fertilizer dates based on schedule
- Adjust for seasons if enabled
- Create `CareTask` entities for each scheduled item
- [ ] Define `CareTask` entity:
```swift
struct CareTask: Identifiable, Codable, Sendable {
let id: UUID
let plantID: UUID
let type: CareTaskType
let scheduledDate: Date
let isCompleted: Bool
let completedDate: Date?
let notes: String?
enum CareTaskType: String, Codable, Sendable {
case watering, fertilizing, pruning, repotting, pestControl, rotation
var icon: String { ... }
var defaultReminderOffset: TimeInterval { ... }
}
}
```
- [ ] Persist schedule to Core Data
- [ ] Register in DIContainer
**Acceptance Criteria:** Use case creates complete care schedule with future tasks
---
### 4.8 Build Plant Detail View
- [ ] Create `Presentation/Scenes/PlantDetail/PlantDetailView.swift`
- [ ] Create `PlantDetailViewModel`:
```swift
@Observable
final class PlantDetailViewModel {
private(set) var plant: Plant
private(set) var careInfo: PlantCareInfo?
private(set) var careSchedule: PlantCareSchedule?
private(set) var isLoading: Bool = false
private(set) var error: Error?
func loadCareInfo() async
func createSchedule(preferences: CarePreferences?) async
func markTaskComplete(_ task: CareTask) async
}
```
- [ ] Implement view sections:
```swift
struct PlantDetailView: View {
@State private var viewModel: PlantDetailViewModel
var body: some View {
ScrollView {
PlantHeaderSection(plant: viewModel.plant)
IdentificationSection(plant: viewModel.plant)
CareInformationSection(careInfo: viewModel.careInfo)
UpcomingTasksSection(tasks: viewModel.upcomingTasks)
CareScheduleSection(schedule: viewModel.careSchedule)
}
}
}
```
- [ ] Create `CareInformationSection` component:
```swift
struct CareInformationSection: View {
let careInfo: PlantCareInfo?
var body: some View {
Section("Care Requirements") {
LightRequirementRow(requirement: careInfo?.lightRequirement)
WateringRow(schedule: careInfo?.wateringSchedule)
TemperatureRow(range: careInfo?.temperatureRange)
FertilizerRow(schedule: careInfo?.fertilizerSchedule)
HumidityRow(level: careInfo?.humidity)
}
}
}
```
- [ ] Create care info row components:
- `LightRequirementRow` - sun icon, description, hours
- `WateringRow` - drop icon, frequency, amount
- `TemperatureRow` - thermometer, min/max/optimal
- `FertilizerRow` - leaf icon, frequency, type
- `HumidityRow` - humidity icon, level indicator
- [ ] Add loading skeleton for care info
- [ ] Handle "care data unavailable" state
- [ ] Implement pull-to-refresh
**Acceptance Criteria:** Detail view displays all plant info with care requirements
---
### 4.9 Implement Care Schedule View
- [ ] Create `Presentation/Scenes/CareSchedule/CareScheduleView.swift`
- [ ] Create `CareScheduleViewModel`:
```swift
@Observable
final class CareScheduleViewModel {
private(set) var upcomingTasks: [CareTask] = []
private(set) var tasksByDate: [Date: [CareTask]] = [:]
private(set) var plants: [Plant] = []
var selectedFilter: TaskFilter = .all
enum TaskFilter: CaseIterable {
case all, watering, fertilizing, overdue, today
}
func loadTasks() async
func markComplete(_ task: CareTask) async
func snoozeTask(_ task: CareTask, until: Date) async
func skipTask(_ task: CareTask) async
}
```
- [ ] Implement main schedule view:
```swift
struct CareScheduleView: View {
@State private var viewModel: CareScheduleViewModel
var body: some View {
NavigationStack {
List {
OverdueTasksSection(tasks: viewModel.overdueTasks)
TodayTasksSection(tasks: viewModel.todayTasks)
UpcomingTasksSection(tasksByDate: viewModel.upcomingByDate)
}
.navigationTitle("Care Schedule")
.toolbar {
FilterMenu(selection: $viewModel.selectedFilter)
}
}
}
}
```
- [ ] Create `CareTaskRow` component:
```swift
struct CareTaskRow: View {
let task: CareTask
let plant: Plant
let onComplete: () -> Void
let onSnooze: (Date) -> Void
var body: some View {
HStack {
PlantThumbnail(plant: plant)
VStack(alignment: .leading) {
Text(plant.commonNames.first ?? plant.scientificName)
Text(task.type.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
TaskActionButtons(...)
}
.swipeActions { ... }
}
}
```
- [ ] Implement calendar view option:
```swift
struct CareCalendarView: View {
let tasksByDate: [Date: [CareTask]]
@Binding var selectedDate: Date
var body: some View {
VStack {
CalendarGrid(tasksByDate: tasksByDate, selection: $selectedDate)
TaskListForDate(tasks: tasksByDate[selectedDate] ?? [])
}
}
}
```
- [ ] Add empty state for "no tasks scheduled"
- [ ] Implement batch actions (complete all today's watering)
- [ ] Add quick-add task functionality
**Acceptance Criteria:** Schedule view shows all upcoming tasks, supports filtering and completion
---
### 4.10 Add Local Notifications for Care Reminders
- [ ] Create `Core/Services/NotificationService.swift`
- [ ] Define protocol:
```swift
protocol NotificationServiceProtocol: Sendable {
func requestAuthorization() async throws -> Bool
func scheduleReminder(for task: CareTask, plant: Plant) async throws
func cancelReminder(for task: CareTask) async
func cancelAllReminders(for plantID: UUID) async
func updateBadgeCount() async
func getPendingNotifications() async -> [UNNotificationRequest]
}
```
- [ ] Implement notification service:
```swift
final class NotificationService: NotificationServiceProtocol {
private let center = UNUserNotificationCenter.current()
func scheduleReminder(for task: CareTask, plant: Plant) async throws {
let content = UNMutableNotificationContent()
content.title = "Plant Care Reminder"
content.body = "\(plant.commonNames.first ?? plant.scientificName) needs \(task.type.rawValue)"
content.sound = .default
content.badge = await calculateBadgeCount() as NSNumber
content.userInfo = [
"taskID": task.id.uuidString,
"plantID": plant.id.uuidString,
"taskType": task.type.rawValue
]
content.categoryIdentifier = "CARE_REMINDER"
let trigger = UNCalendarNotificationTrigger(
dateMatching: Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: task.scheduledDate
),
repeats: false
)
let request = UNNotificationRequest(
identifier: "care-\(task.id.uuidString)",
content: content,
trigger: trigger
)
try await center.add(request)
}
}
```
- [ ] Set up notification categories and actions:
```swift
func setupNotificationCategories() {
let completeAction = UNNotificationAction(
identifier: "COMPLETE",
title: "Mark Complete",
options: .foreground
)
let snoozeAction = UNNotificationAction(
identifier: "SNOOZE",
title: "Snooze 1 Hour",
options: []
)
let category = UNNotificationCategory(
identifier: "CARE_REMINDER",
actions: [completeAction, snoozeAction],
intentIdentifiers: [],
options: .customDismissAction
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
```
- [ ] Handle notification responses in app delegate/scene delegate
- [ ] Create `ScheduleNotificationsUseCase`:
```swift
protocol ScheduleNotificationsUseCaseProtocol: Sendable {
func scheduleAll(for schedule: PlantCareSchedule, plant: Plant) async throws
func rescheduleAll() async throws // Call after task completion
func syncWithSystem() async // Verify scheduled vs expected
}
```
- [ ] Add notification settings UI:
- Enable/disable reminders
- Set default reminder time
- Set advance notice period
- Sound selection
- [ ] Handle notification permission denied gracefully
- [ ] Register in DIContainer
**Acceptance Criteria:** Notifications fire at scheduled times with actionable buttons
---
## End-of-Phase Validation
### Functional Verification
| Test | Steps | Expected Result | Status |
|------|-------|-----------------|--------|
| API Token Configured | Build app | No crash on Trefle token access | [ ] |
| Plant Search | Search "Monstera" | Returns matching species | [ ] |
| Species Detail | Fetch species by slug | Returns complete growth data | [ ] |
| Care Info Display | View identified plant | Care requirements shown | [ ] |
| Schedule Creation | Add plant to collection | Care schedule generated | [ ] |
| Task List | Open care schedule tab | Upcoming tasks displayed | [ ] |
| Task Completion | Tap complete on task | Task marked done, removed from list | [ ] |
| Task Snooze | Snooze task 1 hour | Task rescheduled, notification updated | [ ] |
| Notification Permission | First launch | Permission dialog shown | [ ] |
| Notification Delivery | Wait for scheduled time | Notification appears | [ ] |
| Notification Action | Tap "Mark Complete" | App opens, task completed | [ ] |
| Offline Care Data | Disable network | Cached care info displayed | [ ] |
| Unknown Species | Search non-existent plant | Graceful "not found" message | [ ] |
| Calendar View | Switch to calendar | Tasks shown on correct dates | [ ] |
| Filter Tasks | Filter by "watering" | Only watering tasks shown | [ ] |
### Code Quality Verification
| Check | Criteria | Status |
|-------|----------|--------|
| Build | Project builds with zero warnings | [ ] |
| Architecture | Trefle code isolated in Data/DataSources/Remote/TrefleAPI/ | [ ] |
| Protocols | All services use protocols for testability | [ ] |
| Sendable | All new types conform to Sendable | [ ] |
| DTOs | DTOs decode sample Trefle responses correctly | [ ] |
| Mapper | Mapper handles all optional fields with defaults | [ ] |
| Use Cases | Business logic in use cases, not ViewModels | [ ] |
| DI Container | New services registered in container | [ ] |
| Error Types | Trefle-specific errors defined | [ ] |
| Unit Tests | DTOs, mappers, and use cases have tests | [ ] |
| Secrets | API token not in source control | [ ] |
| Notifications | Permission handling follows Apple guidelines | [ ] |
### Performance Verification
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| Trefle Search Response | < 2 seconds | | [ ] |
| Species Detail Fetch | < 3 seconds | | [ ] |
| Care Schedule Generation | < 100ms | | [ ] |
| Plant Detail View Load | < 500ms | | [ ] |
| Care Schedule View Load | < 300ms | | [ ] |
| Notification Scheduling (batch) | < 1 second for 10 tasks | | [ ] |
| Care Info Cache Lookup | < 50ms | | [ ] |
| Calendar View Render | < 200ms | | [ ] |
### API Integration Verification
| Test | Steps | Expected Result | Status |
|------|-------|-----------------|--------|
| Valid Species | Search "Quercus robur" | Returns oak species data | [ ] |
| Growth Data Present | Fetch species with growth | Light, water, temp data present | [ ] |
| Growth Data Missing | Fetch species without growth | Defaults used, no crash | [ ] |
| Pagination | Search common term | Multiple pages available | [ ] |
| Rate Limiting | Make rapid requests | 429 handled gracefully | [ ] |
| Invalid Token | Use wrong token | Unauthorized error shown | [ ] |
| Species Not Found | Search gibberish | Empty results, no error | [ ] |
| Image URLs | Fetch species | Valid image URLs returned | [ ] |
### Care Schedule Verification
| Scenario | Input | Expected Output | Status |
|----------|-------|-----------------|--------|
| Daily Watering | High humidity plant | Tasks every day | [ ] |
| Weekly Watering | Low humidity plant | Tasks every 7 days | [ ] |
| Monthly Fertilizer | High nutrient need | Tasks every 30 days | [ ] |
| No Fertilizer | Low nutrient need | No fertilizer tasks | [ ] |
| Seasonal Adjustment | Outdoor plant in winter | Reduced watering frequency | [ ] |
| User Preferred Time | Set 9:00 AM | All tasks at 9:00 AM | [ ] |
| 30-Day Lookahead | Create schedule | Tasks for next 30 days | [ ] |
| Task Completion | Complete watering | Next occurrence scheduled | [ ] |
| Plant Deletion | Delete plant | All tasks removed | [ ] |
### Notification Verification
| Test | Steps | Expected Result | Status |
|------|-------|-----------------|--------|
| Permission Granted | Accept notification prompt | Reminders scheduled | [ ] |
| Permission Denied | Deny notification prompt | Graceful fallback, in-app alerts | [ ] |
| Notification Content | Receive notification | Correct plant name and task type | [ ] |
| Complete Action | Tap "Mark Complete" | Task completed in app | [ ] |
| Snooze Action | Tap "Snooze" | Notification rescheduled | [ ] |
| Badge Count | Have 3 overdue tasks | Badge shows 3 | [ ] |
| Badge Clear | Complete all tasks | Badge cleared | [ ] |
| Background Delivery | App closed | Notification still fires | [ ] |
| Notification Tap | Tap notification | Opens plant detail | [ ] |
| Bulk Reschedule | Complete task | Future notifications updated | [ ] |
---
## Phase 4 Completion Checklist
- [ ] All 10 tasks completed (core implementation)
- [ ] All functional tests pass
- [ ] All code quality checks pass
- [ ] All performance targets met
- [ ] Trefle API integration verified
- [ ] Care schedule generation working
- [ ] Task management (complete/snooze/skip) working
- [ ] Notifications scheduling and firing correctly
- [ ] Notification actions handled properly
- [ ] Offline mode works (cached care data)
- [ ] API token secured (not in git)
- [ ] Unit tests for DTOs, mappers, and use cases
- [ ] UI tests for critical flows (view plant, complete task)
- [ ] Code committed with descriptive message
- [ ] Ready for Phase 5 (Plant Collection & Persistence)
---
## Error Handling
### Trefle API Errors
```swift
enum TrefleAPIError: Error, LocalizedError {
case invalidToken
case rateLimitExceeded
case speciesNotFound(query: String)
case serverError(statusCode: Int)
case networkUnavailable
case timeout
case invalidResponse
case paginationExhausted
var errorDescription: String? {
switch self {
case .invalidToken:
return "Invalid API token. Please check configuration."
case .rateLimitExceeded:
return "Too many requests. Please wait a moment."
case .speciesNotFound(let query):
return "No species found matching '\(query)'."
case .serverError(let code):
return "Server error (\(code)). Please try again later."
case .networkUnavailable:
return "No network connection."
case .timeout:
return "Request timed out. Please try again."
case .invalidResponse:
return "Invalid response from server."
case .paginationExhausted:
return "No more results available."
}
}
}
```
### Care Schedule Errors
```swift
enum CareScheduleError: Error, LocalizedError {
case noCareDataAvailable
case schedulePersistenceFailed
case invalidDateRange
case plantNotFound
var errorDescription: String? {
switch self {
case .noCareDataAvailable:
return "Care information not available for this plant."
case .schedulePersistenceFailed:
return "Failed to save care schedule."
case .invalidDateRange:
return "Invalid date range for schedule."
case .plantNotFound:
return "Plant not found in collection."
}
}
}
```
### Notification Errors
```swift
enum NotificationError: Error, LocalizedError {
case permissionDenied
case schedulingFailed
case invalidTriggerDate
case categoryNotRegistered
var errorDescription: String? {
switch self {
case .permissionDenied:
return "Notification permission denied. Enable in Settings."
case .schedulingFailed:
return "Failed to schedule reminder."
case .invalidTriggerDate:
return "Cannot schedule reminder for past date."
case .categoryNotRegistered:
return "Notification category not configured."
}
}
}
```
---
## Notes
- Trefle API has growth data for ~10% of species; implement graceful fallbacks
- Cache Trefle responses aggressively (data rarely changes)
- Notification limit: iOS allows ~64 pending local notifications
- Schedule notifications in batches to stay under limit
- Use background app refresh to reschedule notifications periodically
- Consider user's timezone for notification scheduling
- Trefle measurement units vary; normalize to metric internally, display in user's preference
- Some plants need seasonal care adjustments (reduce watering in winter)
- Badge count should only reflect overdue tasks, not all pending
- Test notification actions with app in foreground, background, and terminated states
---
## Dependencies
| Dependency | Type | Notes |
|------------|------|-------|
| Trefle API | External API | 120 req/min rate limit |
| UserNotifications | System | Local notifications |
| URLSession | System | API requests |
| Core Data | System | Schedule persistence |
---
## Risk Mitigation
| Risk | Mitigation |
|------|------------|
| Trefle API token exposed | Use xcconfig, add to .gitignore |
| Species not in Trefle | Provide generic care defaults |
| Missing growth data | Use conservative defaults for watering/light |
| Notification permission denied | In-app task list always available |
| Too many notifications | Limit to 64, prioritize soonest tasks |
| User ignores reminders | Badge count, overdue section in UI |
| Trefle API downtime | Cache responses, retry with backoff |
| Incorrect care recommendations | Add disclaimer, allow user overrides |
| Timezone issues | Store all dates in UTC, convert for display |
| App deleted with pending notifications | Notifications orphaned (OS handles cleanup) |
---
## Sample Trefle API Response
### Search Response
```json
{
"data": [
{
"id": 834,
"common_name": "Swiss cheese plant",
"slug": "monstera-deliciosa",
"scientific_name": "Monstera deliciosa",
"year": 1849,
"bibliography": "Vidensk. Meddel. Naturhist. Foren. Kjøbenhavn 1849: 19 (1849)",
"author": "Liebm.",
"family_common_name": "Arum family",
"genus_id": 1254,
"image_url": "https://bs.plantnet.org/image/o/abc123",
"genus": "Monstera",
"family": "Araceae"
}
],
"links": {
"self": "/api/v1/plants/search?q=monstera",
"first": "/api/v1/plants/search?page=1&q=monstera",
"last": "/api/v1/plants/search?page=1&q=monstera"
},
"meta": {
"total": 12
}
}
```
### Species Detail Response
```json
{
"data": {
"id": 834,
"common_name": "Swiss cheese plant",
"slug": "monstera-deliciosa",
"scientific_name": "Monstera deliciosa",
"growth": {
"light": 6,
"atmospheric_humidity": 8,
"minimum_temperature": {
"deg_c": 15
},
"maximum_temperature": {
"deg_c": 30
},
"soil_humidity": 7,
"soil_nutriments": 5
},
"specifications": {
"growth_rate": "moderate",
"toxicity": "mild"
}
},
"meta": {
"last_modified": "2023-01-15T12:00:00Z"
}
}
```
---
## UI Mockups (Conceptual)
### Plant Detail - Care Section
```
┌─────────────────────────────────────┐
│ ☀️ Light: Partial Sun (5-6 hrs) │
│ 💧 Water: Twice Weekly (Moderate) │
│ 🌡️ Temp: 15-30°C (Optimal: 22°C) │
│ 🌱 Fertilizer: Monthly (Balanced) │
│ 💨 Humidity: High │
└─────────────────────────────────────┘
```
### Care Schedule - Task List
```
┌─────────────────────────────────────┐
│ OVERDUE (2) │
│ ┌─────────────────────────────────┐ │
│ │ 🪴 Monstera 💧 Water [✓] │ │
│ │ 🪴 Pothos 💧 Water [✓] │ │
│ └─────────────────────────────────┘ │
│ │
│ TODAY │
│ ┌─────────────────────────────────┐ │
│ │ 🪴 Ficus 🌱 Fertilize [✓]│ │
│ └─────────────────────────────────┘ │
│ │
│ TOMORROW │
│ ┌─────────────────────────────────┐ │
│ │ 🪴 Snake Plant 💧 Water [○] │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
```