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

View File

@@ -0,0 +1,393 @@
//
// PlantDetailView.swift
// PlantGuide
//
// Created by Trey Tartt on 1/21/26.
//
import SwiftUI
// MARK: - PlantDetailView
/// Displays detailed information about a plant including care requirements and tasks
@MainActor
struct PlantDetailView: View {
// MARK: - Properties
@State private var viewModel: PlantDetailViewModel
@Environment(\.dismiss) private var dismiss
// MARK: - Initialization
/// Creates a new PlantDetailView for the specified plant
/// - Parameter plant: The plant to display details for
init(plant: Plant) {
_viewModel = State(initialValue: PlantDetailViewModel(plant: plant))
}
/// Creates a new PlantDetailView with an existing view model (for previews/testing)
/// - Parameter viewModel: The view model to use
init(viewModel: PlantDetailViewModel) {
_viewModel = State(initialValue: viewModel)
}
// MARK: - Body
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Plant header with image and names
PlantHeaderSection(plant: viewModel.plant)
// Loading state
if viewModel.isLoading {
loadingView
} else if let error = viewModel.error {
errorView(error: error)
} else {
// Care information section
if let careInfo = viewModel.careInfo {
CareInformationSection(careInfo: careInfo)
}
// Auto-Add Care Items section
if viewModel.careInfo != nil {
autoAddCareSection
}
// Upcoming tasks section
if !viewModel.upcomingTasks.isEmpty {
UpcomingTasksSection(
tasks: viewModel.upcomingTasks,
onTaskComplete: { task in
Task {
await viewModel.markTaskComplete(task)
}
}
)
}
// Identification info
identificationInfoSection
}
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle(viewModel.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
// TODO: Edit plant
} label: {
Label("Edit Plant", systemImage: "pencil")
}
Button {
Task {
await viewModel.refresh()
}
} label: {
Label("Refresh Care Info", systemImage: "arrow.clockwise")
}
Divider()
Button(role: .destructive) {
// TODO: Delete plant
} label: {
Label("Remove Plant", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 17))
}
.accessibilityLabel("Plant options")
}
}
.task {
await viewModel.loadCareInfo()
}
.refreshable {
await viewModel.refresh()
}
}
// MARK: - Loading View
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(1.2)
Text("Loading care information...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Error View
private func errorView(error: Error) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundStyle(.orange)
Text("Unable to Load Care Info")
.font(.headline)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
Task {
await viewModel.loadCareInfo()
}
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Try Again")
}
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.accentColor)
)
}
.padding(.top, 8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.padding(.horizontal)
.background(Color(.systemGray6))
.cornerRadius(12)
}
// MARK: - Auto-Add Care Section
private var autoAddCareSection: some View {
VStack(alignment: .leading, spacing: 12) {
if viewModel.hasExistingSchedule {
// Show notification toggles when schedule exists
careRemindersSection
} else {
// Show button to create schedule
autoAddButton
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
private var autoAddButton: some View {
VStack(spacing: 12) {
Button {
Task {
// Request notification permission first
await viewModel.requestNotificationPermission()
// Then create the schedule
await viewModel.createAndPersistSchedule()
}
} label: {
HStack(spacing: 10) {
if viewModel.isCreatingSchedule {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.tint(.white)
} else {
Image(systemName: "leaf.fill")
.font(.system(size: 18))
}
Text("Auto-Add Care Items")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.foregroundStyle(.white)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.green)
)
}
.disabled(viewModel.isCreatingSchedule)
Text("Create watering and fertilizing reminders based on this plant's needs")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// Success message
if let successMessage = viewModel.successMessage {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(successMessage)
.font(.subheadline)
.foregroundStyle(.green)
}
.padding(.top, 4)
}
}
}
private var careRemindersSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Care Reminders")
.font(.headline)
Spacer()
Text("\(viewModel.scheduleTaskCount) tasks")
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
// Watering toggle
Toggle(isOn: Binding(
get: { viewModel.notificationPreferences.wateringEnabled },
set: { newValue in
Task {
await viewModel.updateNotificationPreference(for: .watering, enabled: newValue)
}
}
)) {
HStack(spacing: 10) {
Image(systemName: "drop.fill")
.foregroundStyle(.blue)
.frame(width: 24)
Text("Watering reminders")
}
}
.tint(.green)
// Fertilizer toggle
Toggle(isOn: Binding(
get: { viewModel.notificationPreferences.fertilizingEnabled },
set: { newValue in
Task {
await viewModel.updateNotificationPreference(for: .fertilizing, enabled: newValue)
}
}
)) {
HStack(spacing: 10) {
Image(systemName: "leaf.fill")
.foregroundStyle(.green)
.frame(width: 24)
Text("Fertilizer reminders")
}
}
.tint(.green)
}
}
// MARK: - Identification Info Section
private var identificationInfoSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Identification")
.font(.headline)
HStack(spacing: 12) {
Image(systemName: "sparkles")
.font(.system(size: 18))
.foregroundStyle(.purple)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text("Identified via")
.font(.caption)
.foregroundStyle(.secondary)
Text(identificationSourceDescription)
.font(.subheadline)
.fontWeight(.medium)
}
Spacer()
Text(formattedIdentificationDate)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
// MARK: - Private Helpers
private var identificationSourceDescription: String {
switch viewModel.plant.identificationSource {
case .onDeviceML:
return "On-Device ML"
case .plantNetAPI:
return "PlantNet API"
case .userManual:
return "Manual Entry"
}
}
private var formattedIdentificationDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: viewModel.plant.dateIdentified)
}
}
// MARK: - Previews
#Preview("Plant Detail View") {
NavigationStack {
PlantDetailView(plant: Plant(
scientificName: "Monstera deliciosa",
commonNames: ["Swiss Cheese Plant", "Split-leaf Philodendron"],
family: "Araceae",
genus: "Monstera",
imageURLs: [],
identificationSource: .onDeviceML
))
}
}
#Preview("Plant Detail - With Care Info") {
let viewModel = PlantDetailViewModel(plant: Plant(
scientificName: "Ficus lyrata",
commonNames: ["Fiddle Leaf Fig"],
family: "Moraceae",
genus: "Ficus",
imageURLs: [],
identificationSource: .plantNetAPI
))
return NavigationStack {
PlantDetailView(viewModel: viewModel)
}
}
#Preview("Plant Detail - Scientific Name Only") {
NavigationStack {
PlantDetailView(plant: Plant(
scientificName: "Epipremnum aureum",
commonNames: [],
family: "Araceae",
genus: "Epipremnum",
imageURLs: [],
identificationSource: .userManual
))
}
}