diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index 11717e8..4965b9e 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -44,17 +44,8 @@ struct CardEditorView: View { @State private var coverPhotoData: Data? @State private var logoData: Data? - // Photo picker state - @State private var pendingImageType: ImageType? // For showing PhotoSourcePicker - @State private var nextImageType: ImageType? // Stored when user selects action, used after dismiss - @State private var photoPickerImageType: ImageType? // For showing PhotoPickerWithCropper (item-based) - @State private var cameraImageType: ImageType? // For showing CameraWithCropper (item-based) - @State private var pendingAction: PendingPhotoAction? // Action to take after source picker dismisses - - private enum PendingPhotoAction { - case library - case camera - } + // Photo editor state - just one variable! + @State private var editingImageType: ImageType? @State private var showingPreview = false @@ -129,7 +120,7 @@ struct CardEditorView: View { avatarSystemName: avatarSystemName, selectedTheme: selectedTheme, onSelectImage: { imageType in - pendingImageType = imageType + editingImageType = imageType } ) } header: { @@ -223,64 +214,23 @@ struct CardEditorView: View { .disabled(!isFormValid) } } - .sheet(item: $pendingImageType, onDismiss: { - // After source picker dismisses, show the appropriate picker - guard let action = pendingAction, let imageType = nextImageType else { return } - pendingAction = nil - nextImageType = nil - - // Small delay to ensure sheet is fully dismissed - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - switch action { - case .library: - photoPickerImageType = imageType // Triggers fullScreenCover(item:) - case .camera: - cameraImageType = imageType // Triggers fullScreenCover(item:) - } - } - }) { imageType in - PhotoSourcePicker( - title: imageType.title, - hasExistingPhoto: hasExistingPhoto(for: imageType), - onSelectFromLibrary: { - nextImageType = imageType // Store for use after dismiss - pendingAction = .library - }, - onTakePhoto: { - nextImageType = imageType // Store for use after dismiss - pendingAction = .camera - }, - onRemovePhoto: { - removePhoto(for: imageType) - } - ) - } - .fullScreenCover(item: $photoPickerImageType) { imageType in - NavigationStack { - PhotoPickerWithCropper( - aspectRatio: imageType.cropAspectRatio, - onSave: { croppedData in - savePhoto(croppedData, for: imageType) - photoPickerImageType = nil - }, - onCancel: { - photoPickerImageType = nil + .sheet(item: $editingImageType) { imageType in + ImageEditorFlow( + imageType: imageType, + hasExistingImage: hasExistingPhoto(for: imageType) + ) { imageData in + if let imageData { + if imageData.isEmpty { + // Empty data = remove photo + removePhoto(for: imageType) + } else { + // Save the cropped image + savePhoto(imageData, for: imageType) } - ) - } - } - .fullScreenCover(item: $cameraImageType) { imageType in - CameraWithCropper( - aspectRatio: imageType.cropAspectRatio, - onSave: { croppedData in - savePhoto(croppedData, for: imageType) - cameraImageType = nil - }, - onCancel: { - cameraImageType = nil } - ) + // nil = cancelled, do nothing + editingImageType = nil + } } .onAppear { loadCardData() } .sheet(isPresented: $showingPreview) { diff --git a/BusinessCard/Views/Components/ImageEditorFlow.swift b/BusinessCard/Views/Components/ImageEditorFlow.swift new file mode 100644 index 0000000..dfd99f2 --- /dev/null +++ b/BusinessCard/Views/Components/ImageEditorFlow.swift @@ -0,0 +1,276 @@ +import SwiftUI +import PhotosUI +import Bedrock + +/// A self-contained image editor flow. +/// Shows source picker first, then presents photo picker or camera as a full-screen cover. +/// The aspect ratio is determined by the imageType. +struct ImageEditorFlow: View { + @Environment(\.dismiss) private var dismiss + + let imageType: CardEditorView.ImageType + let hasExistingImage: Bool + let onComplete: (Data?) -> Void // nil = cancelled/no change, Data = final cropped image + + private enum NextAction { + case library + case camera + } + + @State private var nextAction: NextAction? + @State private var showingFullScreenPicker = false + @State private var showingFullScreenCamera = false + + private var aspectRatio: CropAspectRatio { + imageType.cropAspectRatio + } + + var body: some View { + // Source picker is the base content of this sheet + sourcePickerView + .fullScreenCover(isPresented: $showingFullScreenPicker) { + PhotoPickerFlow( + aspectRatio: aspectRatio, + onComplete: { imageData in + showingFullScreenPicker = false + if let imageData { + onComplete(imageData) + } + // If nil, we stay on source picker + } + ) + } + .fullScreenCover(isPresented: $showingFullScreenCamera) { + CameraFlow( + aspectRatio: aspectRatio, + onComplete: { imageData in + showingFullScreenCamera = false + if let imageData { + onComplete(imageData) + } + // If nil, we stay on source picker + } + ) + } + } + + // MARK: - Source Picker + + private var sourcePickerView: some View { + NavigationStack { + VStack(spacing: 0) { + VStack(spacing: 0) { + OptionRow( + icon: "photo.on.rectangle", + title: String.localized("Select from photo library") + ) { + showingFullScreenPicker = true + } + + Divider() + .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize) + + OptionRow( + icon: "camera", + title: String.localized("Take photo") + ) { + showingFullScreenCamera = true + } + + if hasExistingImage { + Divider() + .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize) + + OptionRow( + icon: "trash", + title: String.localized("Remove photo"), + isDestructive: true + ) { + onComplete(Data()) + } + } + } + .background(Color.AppBackground.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.medium) + + Spacer() + } + .background(Color.AppBackground.secondary) + .navigationTitle(imageType.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + onComplete(nil) + } label: { + Image(systemName: "xmark") + .font(.body.bold()) + .foregroundStyle(Color.Text.primary) + } + } + } + } + .presentationDetents([.height(CGFloat((hasExistingImage ? 3 : 2) * 56 + 100))]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - Photo Picker Flow (full screen) + +private struct PhotoPickerFlow: View { + let aspectRatio: CropAspectRatio + let onComplete: (Data?) -> Void + + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var imageData: Data? + @State private var showingCropper = false + @State private var pickerID = UUID() + + var body: some View { + NavigationStack { + PhotosPicker( + selection: $selectedPhotoItem, + matching: .images, + photoLibrary: .shared() + ) { + EmptyView() + } + .photosPickerStyle(.inline) + .photosPickerDisabledCapabilities([.selectionActions]) + .ignoresSafeArea() + .id(pickerID) + .onChange(of: selectedPhotoItem) { _, newValue in + guard let newValue else { return } + Task { @MainActor in + if let data = try? await newValue.loadTransferable(type: Data.self) { + imageData = data + showingCropper = true + } + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String.localized("Cancel")) { + onComplete(nil) + } + } + } + } + .overlay { + if showingCropper, let imageData { + PhotoCropperSheet( + imageData: imageData, + aspectRatio: aspectRatio, + shouldDismissOnComplete: false + ) { croppedData in + if let croppedData { + onComplete(croppedData) + } else { + // Go back to picker + showingCropper = false + self.imageData = nil + self.selectedPhotoItem = nil + pickerID = UUID() + } + } + .transition(.move(edge: .trailing)) + } + } + .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper) + } +} + +// MARK: - Camera Flow (full screen) + +private struct CameraFlow: View { + let aspectRatio: CropAspectRatio + let onComplete: (Data?) -> Void + + @State private var capturedImageData: Data? + @State private var showingCropper = false + @State private var cameraID = UUID() // For resetting camera after cancel + + var body: some View { + ZStack { + // Camera - only show when not cropping + if !showingCropper { + CameraCaptureView(shouldDismissOnCapture: false) { imageData in + if let imageData { + capturedImageData = imageData + showingCropper = true + } else { + // User cancelled camera itself + onComplete(nil) + } + } + .id(cameraID) + .ignoresSafeArea() + .transition(.opacity) + } + + // Cropper overlay + if showingCropper, let imageData = capturedImageData { + PhotoCropperSheet( + imageData: imageData, + aspectRatio: aspectRatio, + shouldDismissOnComplete: false + ) { croppedData in + if let croppedData { + onComplete(croppedData) + } else { + // User cancelled cropper - go back to camera for retake + showingCropper = false + capturedImageData = nil + cameraID = UUID() // Reset camera + } + } + .transition(.move(edge: .trailing)) + } + } + .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper) + } +} + +// MARK: - Option Row + +private struct OptionRow: View { + let icon: String + let title: String + var isDestructive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: icon) + .font(.body) + .foregroundStyle(isDestructive ? Color.red : Color.Text.secondary) + .frame(width: Design.CardSize.socialIconSize) + + Text(title) + .font(.body) + .foregroundStyle(isDestructive ? Color.red : Color.Text.primary) + + Spacer() + } + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} + +#Preview { + Text("Tap to edit") + .sheet(isPresented: .constant(true)) { + ImageEditorFlow( + imageType: .profile, + hasExistingImage: false + ) { data in + print(data != nil ? "Got image" : "Cancelled") + } + } +}