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