Files
PlantGuide/PlantGuide/Presentation/Scenes/PlantDetail/PlantDetailView.swift
Trey t 4fcec31c02 Add Progress Photos feature for plant growth tracking (Phase 8)
Implement progress photo capture with HEIC compression and thumbnail
generation, gallery view with grid display and full-size viewing,
time-lapse playback with adjustable speed, and photo reminder
notifications at weekly/biweekly/monthly intervals.

New files:
- ProgressPhoto domain entity with imageData and thumbnailData
- ProgressPhotoRepositoryProtocol and CoreDataProgressPhotoRepository
- CaptureProgressPhotoUseCase with image compression/resizing
- SchedulePhotoReminderUseCase with notification scheduling
- ProgressPhotosViewModel, ProgressPhotoGalleryView
- ProgressPhotoCaptureView, TimeLapsePlayerView

Modified:
- PlantMO with progressPhotos relationship
- Core Data model with ProgressPhotoMO entity
- NotificationService with photo reminder support
- PlantDetailView with Progress Photos section
- DIContainer with photo service registrations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:40:50 -06:00

486 lines
15 KiB
Swift

//
// 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 {
// Room assignment section
roomSection
// 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
// Progress photos section
progressPhotosSection
}
}
.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: - Room Section
private var roomSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Location")
.font(.headline)
RoomPickerView(
selectedRoomID: Binding(
get: { viewModel.plant.roomID },
set: { newRoomID in
Task {
await viewModel.updateRoom(to: newRoomID)
}
}
),
onRoomChanged: { newRoomID in
Task {
await viewModel.updateRoom(to: newRoomID)
}
}
)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
// 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: - Progress Photos Section
private var progressPhotosSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Progress Photos")
.font(.headline)
Spacer()
Text("\(viewModel.progressPhotoCount) photos")
.font(.caption)
.foregroundStyle(.secondary)
}
NavigationLink {
ProgressPhotoGalleryView(
plantID: viewModel.plant.id,
plantName: viewModel.displayName
)
} label: {
HStack {
// Recent thumbnail or placeholder
if let recentThumbnail = viewModel.recentProgressPhotoThumbnail {
Image(uiImage: UIImage(data: recentThumbnail) ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 60, height: 60)
.overlay {
Image(systemName: "camera")
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading) {
Text("View Gallery")
.font(.subheadline)
Text("Track your plant's growth")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
.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
))
}
}