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:
393
PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift
Normal file
393
PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift
Normal 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
|
||||
))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user