Files
Reflect/Shared/Views/PhotoPickerView.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

379 lines
13 KiB
Swift

//
// PhotoPickerView.swift
// Reflect
//
// Photo picker and gallery for mood entry attachments.
//
import SwiftUI
import PhotosUI
// MARK: - Photo Picker View
struct PhotoPickerView: View {
@Environment(\.dismiss) private var dismiss
let entry: MoodEntryModel
let onPhotoSelected: (UIImage) -> Void
@State private var selectedItem: PhotosPickerItem?
@State private var showCamera = false
@State private var isProcessing = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "photo.on.rectangle.angled")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("Add a Photo")
.font(.title2)
.fontWeight(.semibold)
Text("Capture how you're feeling today")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 40)
Spacer()
// Options
VStack(spacing: 16) {
// Photo Library
PhotosPicker(selection: $selectedItem, matching: .images) {
HStack(spacing: 16) {
Image(systemName: "photo.stack")
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.blue.opacity(0.15))
.foregroundColor(.blue)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text("Photo Library")
.font(.headline)
.foregroundColor(.primary)
Text("Choose from your photos")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.disabled(isProcessing)
// Camera
Button {
showCamera = true
} label: {
HStack(spacing: 16) {
Image(systemName: "camera")
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.green.opacity(0.15))
.foregroundColor(.green)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text("Take Photo")
.font(.headline)
.foregroundColor(.primary)
Text("Use your camera")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
.disabled(isProcessing)
}
.padding(.horizontal)
Spacer()
// Loading indicator
if isProcessing {
ProgressView("Processing...")
.padding()
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Add Photo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onChange(of: selectedItem) { _, newItem in
Task {
await loadImage(from: newItem)
}
}
.fullScreenCover(isPresented: $showCamera) {
CameraView { image in
handleSelectedImage(image)
}
}
}
}
private func loadImage(from item: PhotosPickerItem?) async {
guard let item = item else { return }
isProcessing = true
defer { isProcessing = false }
do {
if let data = try await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
handleSelectedImage(image)
}
} catch {
print("PhotoPickerView: Failed to load image: \(error)")
}
}
private func handleSelectedImage(_ image: UIImage) {
onPhotoSelected(image)
dismiss()
}
}
// MARK: - Camera View
struct CameraView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
let onImageCaptured: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraView
init(_ parent: CameraView) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
parent.onImageCaptured(image)
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}
// MARK: - Photo Gallery View (Full screen photo viewer)
struct PhotoGalleryView: View {
@Environment(\.dismiss) private var dismiss
let photoID: UUID
let onDelete: () -> Void
@State private var showDeleteConfirmation = false
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
Color.black.ignoresSafeArea()
if let image = PhotoManager.shared.image(for: photoID) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(offset)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = lastScale * value
}
.onEnded { _ in
lastScale = scale
if scale < 1 {
withAnimation {
scale = 1
lastScale = 1
offset = .zero
lastOffset = .zero
}
}
}
)
.simultaneousGesture(
DragGesture()
.onChanged { value in
if scale > 1 {
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
}
.onEnded { _ in
lastOffset = offset
}
)
.onTapGesture(count: 2) {
withAnimation {
if scale > 1 {
scale = 1
lastScale = 1
offset = .zero
lastOffset = .zero
} else {
scale = 2
lastScale = 2
}
}
}
} else {
VStack(spacing: 16) {
Image(systemName: "photo.badge.exclamationmark")
.font(.largeTitle)
.foregroundColor(.gray)
Text("Photo not found")
.foregroundColor(.gray)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
sharePhoto()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
}
}
.confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
Button("Delete", role: .destructive) {
onDelete()
dismiss()
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Are you sure you want to delete this photo?")
}
}
}
private func sharePhoto() {
guard let uiImage = PhotoManager.shared.loadPhoto(id: photoID) else { return }
let activityVC = UIActivityViewController(
activityItems: [uiImage],
applicationActivities: nil
)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(activityVC, animated: true)
}
}
}
// MARK: - Photo Thumbnail View (for list display)
struct PhotoThumbnailView: View {
let photoID: UUID?
let size: CGFloat
var body: some View {
if let photoID = photoID,
let image = PhotoManager.shared.thumbnail(for: photoID) {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: size, height: size)
.overlay(
Image(systemName: "photo")
.foregroundStyle(.tertiary)
)
}
}
}