Files
PlantGuide/PlantGuide/Presentation/Scenes/Camera/CameraView.swift
Trey t 681476a499 WIP: Various UI and feature improvements
- Add AllTasksView and PlantEditView components
- Update CoreDataStack CloudKit container ID
- Improve CameraView and IdentificationViewModel
- Update MainTabView, RoomsListView, UpcomingTasksSection
- Minor fixes to PlantGuideApp and SettingsViewModel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 22:50:04 -06:00

342 lines
11 KiB
Swift

//
// CameraView.swift
// PlantGuide
//
// Created by Trey Tartt on 1/21/26.
//
import SwiftUI
/// Main camera view for plant identification
@MainActor
struct CameraView: View {
// MARK: - Properties
@State private var viewModel = CameraViewModel()
@State private var showIdentification = false
@State private var isTransitioningToIdentification = false
// MARK: - Body
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
switch viewModel.permissionStatus {
case .notDetermined:
permissionRequestView
case .authorized:
if viewModel.showCapturedImagePreview {
capturedImagePreview
} else {
cameraPreviewContent
}
case .denied, .restricted:
permissionDeniedView
}
}
.task {
await viewModel.onAppear()
}
.onDisappear {
Task {
await viewModel.onDisappear()
}
}
.alert("Error", isPresented: .init(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
}
}
.fullScreenCover(isPresented: $showIdentification) {
if let image = viewModel.capturedImage {
IdentificationView(image: image)
}
}
.onChange(of: showIdentification) { _, isShowing in
if !isShowing {
// When returning from identification, allow retaking
isTransitioningToIdentification = false
Task {
await viewModel.retakePhoto()
}
}
}
}
// MARK: - Camera Preview Content
private var cameraPreviewContent: some View {
ZStack {
// Camera preview
CameraPreviewView(session: viewModel.captureSession)
.ignoresSafeArea()
// Overlay controls
VStack {
Spacer()
// Capture controls
captureControlsView
.padding(.bottom, 40)
}
// Loading overlay
if viewModel.isCapturing {
capturingOverlay
}
}
}
// MARK: - Capture Controls
private var captureControlsView: some View {
HStack(spacing: 60) {
// Placeholder for symmetry (could be gallery button)
Circle()
.fill(Color.clear)
.frame(width: 50, height: 50)
// Capture button
captureButton
// Placeholder for symmetry (could be flash toggle)
Circle()
.fill(Color.clear)
.frame(width: 50, height: 50)
}
}
private var captureButton: some View {
Button {
Task {
await viewModel.capturePhoto()
}
} label: {
ZStack {
// Outer ring
Circle()
.strokeBorder(Color.white, lineWidth: 4)
.frame(width: 80, height: 80)
// Inner circle
Circle()
.fill(Color.white)
.frame(width: 68, height: 68)
}
.contentShape(Circle())
}
.buttonStyle(.plain)
.frame(width: 80, height: 80)
.contentShape(Circle())
.disabled(!viewModel.isCameraControlsEnabled)
.opacity(viewModel.isCameraControlsEnabled ? 1.0 : 0.5)
.accessibilityLabel("Capture photo")
.accessibilityHint("Takes a photo of the plant for identification")
}
// MARK: - Captured Image Preview
private var capturedImagePreview: some View {
ZStack {
Color.black.ignoresSafeArea()
if let image = viewModel.capturedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.ignoresSafeArea()
}
VStack {
// Top bar with retake button
HStack {
Button {
Task {
await viewModel.retakePhoto()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.counterclockwise")
Text("Retake")
}
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.black.opacity(0.7))
.shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2)
)
}
.buttonStyle(.plain)
.contentShape(Capsule())
.accessibilityLabel("Retake photo")
Spacer()
}
.padding(.horizontal, 20)
.safeAreaInset(edge: .top) { Color.clear.frame(height: 0) } // Ensure safe area is respected
.padding(.top, 16)
Spacer()
// Bottom bar with use photo button
VStack(spacing: 16) {
Text("Ready to identify this plant?")
.font(.headline)
.foregroundColor(.white)
Button {
isTransitioningToIdentification = true
showIdentification = true
} label: {
HStack(spacing: 8) {
if isTransitioningToIdentification {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .black))
.scaleEffect(0.8)
}
Text(isTransitioningToIdentification ? "Preparing..." : "Use Photo")
}
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.white)
)
}
.disabled(isTransitioningToIdentification)
.padding(.horizontal, 20)
.accessibilityLabel("Use this photo")
.accessibilityHint("Proceeds to plant identification with this photo")
}
.padding(.bottom, 40)
}
}
}
// MARK: - Capturing Overlay
private var capturingOverlay: some View {
ZStack {
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
Text("Capturing...")
.font(.subheadline)
.foregroundColor(.white)
}
.padding(30)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.black.opacity(0.7))
)
}
}
// MARK: - Permission Request View
private var permissionRequestView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "camera.fill")
.font(.system(size: 60))
.foregroundColor(.white.opacity(0.7))
Text("Camera Access Required")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
Text("PlantGuide needs camera access to identify plants by taking photos.")
.font(.body)
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
Spacer()
}
}
// MARK: - Permission Denied View
private var permissionDeniedView: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "camera.fill")
.font(.system(size: 60))
.foregroundColor(.white.opacity(0.5))
.overlay(
Image(systemName: "slash.circle.fill")
.font(.system(size: 30))
.foregroundColor(.red)
.offset(x: 25, y: 25)
)
Text("Camera Access Denied")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
Text("PlantGuide needs camera access to identify plants. Please enable camera access in Settings to use this feature.")
.font(.body)
.foregroundColor(.white.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
viewModel.openSettings()
} label: {
HStack(spacing: 8) {
Image(systemName: "gear")
Text("Open Settings")
}
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.black)
.padding(.horizontal, 32)
.padding(.vertical, 14)
.background(
Capsule()
.fill(Color.white)
)
}
.padding(.top, 12)
.accessibilityLabel("Open Settings")
.accessibilityHint("Opens system settings to enable camera access")
Spacer()
}
}
}
// MARK: - Previews
#Preview("Camera View") {
CameraView()
}
#Preview("Permission Denied") {
// Note: To test permission denied state, you would need to mock the view model
CameraView()
}