- 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>
954 lines
32 KiB
Markdown
954 lines
32 KiB
Markdown
# 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 [○] │ │
|
|
│ └─────────────────────────────────┘ │
|
|
└─────────────────────────────────────┘
|
|
```
|