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 coverPhotoData: Data?
|
||||||
@State private var logoData: Data?
|
@State private var logoData: Data?
|
||||||
|
|
||||||
// Photo picker state
|
// Photo editor state - just one variable!
|
||||||
@State private var pendingImageType: ImageType? // For showing PhotoSourcePicker
|
@State private var editingImageType: ImageType?
|
||||||
@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
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var showingPreview = false
|
@State private var showingPreview = false
|
||||||
|
|
||||||
@ -129,7 +120,7 @@ struct CardEditorView: View {
|
|||||||
avatarSystemName: avatarSystemName,
|
avatarSystemName: avatarSystemName,
|
||||||
selectedTheme: selectedTheme,
|
selectedTheme: selectedTheme,
|
||||||
onSelectImage: { imageType in
|
onSelectImage: { imageType in
|
||||||
pendingImageType = imageType
|
editingImageType = imageType
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
@ -223,64 +214,23 @@ struct CardEditorView: View {
|
|||||||
.disabled(!isFormValid)
|
.disabled(!isFormValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $pendingImageType, onDismiss: {
|
.sheet(item: $editingImageType) { imageType in
|
||||||
// After source picker dismisses, show the appropriate picker
|
ImageEditorFlow(
|
||||||
guard let action = pendingAction, let imageType = nextImageType else { return }
|
imageType: imageType,
|
||||||
pendingAction = nil
|
hasExistingImage: hasExistingPhoto(for: imageType)
|
||||||
nextImageType = nil
|
) { imageData in
|
||||||
|
if let imageData {
|
||||||
// Small delay to ensure sheet is fully dismissed
|
if imageData.isEmpty {
|
||||||
Task { @MainActor in
|
// Empty data = remove photo
|
||||||
try? await Task.sleep(for: .milliseconds(100))
|
removePhoto(for: imageType)
|
||||||
switch action {
|
} else {
|
||||||
case .library:
|
// Save the cropped image
|
||||||
photoPickerImageType = imageType // Triggers fullScreenCover(item:)
|
savePhoto(imageData, for: imageType)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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() }
|
.onAppear { loadCardData() }
|
||||||
.sheet(isPresented: $showingPreview) {
|
.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