Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
9c4d9304e6
commit
1258f5dcd3
@ -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: {
|
||||
.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)
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(item: $photoPickerImageType) { imageType in
|
||||
NavigationStack {
|
||||
PhotoPickerWithCropper(
|
||||
aspectRatio: imageType.cropAspectRatio,
|
||||
onSave: { croppedData in
|
||||
savePhoto(croppedData, for: imageType)
|
||||
photoPickerImageType = nil
|
||||
},
|
||||
onCancel: {
|
||||
photoPickerImageType = nil
|
||||
}
|
||||
)
|
||||
} 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) {
|
||||
|
||||
276
BusinessCard/Views/Components/ImageEditorFlow.swift
Normal file
276
BusinessCard/Views/Components/ImageEditorFlow.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user