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

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