- 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>
342 lines
11 KiB
Swift
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()
|
|
}
|