Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 10:10:47 -06:00
parent 9c4d9304e6
commit 1258f5dcd3
2 changed files with 294 additions and 68 deletions

View File

@ -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) {

View 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")
}
}
}