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>
486 lines
15 KiB
Swift
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
|
|
))
|
|
}
|
|
}
|