BusinessCard/BusinessCard/Views/Components/ImageEditorFlow.swift

351 lines
13 KiB
Swift

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.
/// For logos, an additional LogoEditorSheet is shown after cropping.
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
}
/// Only allow aspect ratio selection for logos
private var allowAspectRatioSelection: Bool {
imageType == .logo
}
/// Whether this is a logo image (needs extra editing step)
private var isLogoImage: Bool {
imageType == .logo
}
var body: some View {
// Source picker is the base content of this sheet
sourcePickerView
.fullScreenCover(isPresented: $showingFullScreenPicker) {
PhotoPickerFlow(
aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
isLogoImage: isLogoImage,
onComplete: { imageData in
showingFullScreenPicker = false
if let imageData {
onComplete(imageData)
}
// If nil, we stay on source picker
}
)
}
.fullScreenCover(isPresented: $showingFullScreenCamera) {
CameraFlow(
aspectRatio: aspectRatio,
allowAspectRatioSelection: allowAspectRatioSelection,
isLogoImage: isLogoImage,
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 allowAspectRatioSelection: Bool
let isLogoImage: Bool
let onComplete: (Data?) -> Void
@State private var selectedPhotoItem: PhotosPickerItem?
@State private var imageData: Data?
@State private var showingCropper = false
@State private var showingLogoEditor = false
@State private var croppedLogoImage: UIImage?
@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,
allowAspectRatioSelection: allowAspectRatioSelection,
shouldDismissOnComplete: false
) { croppedData in
if let croppedData {
// For logos, show the logo editor next
if isLogoImage, let uiImage = UIImage(data: croppedData) {
croppedLogoImage = uiImage
showingCropper = false
showingLogoEditor = true
} else {
onComplete(croppedData)
}
} else {
// Go back to picker
showingCropper = false
self.imageData = nil
self.selectedPhotoItem = nil
pickerID = UUID()
}
}
.transition(.move(edge: .trailing))
}
// Logo editor overlay
if showingLogoEditor, let logoImage = croppedLogoImage {
LogoEditorSheet(logoImage: logoImage) { finalData in
if let finalData {
onComplete(finalData)
} else {
// User cancelled logo editor, go back to picker
showingLogoEditor = false
croppedLogoImage = nil
self.imageData = nil
self.selectedPhotoItem = nil
pickerID = UUID()
}
}
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
.animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
}
}
// MARK: - Camera Flow (full screen)
private struct CameraFlow: View {
let aspectRatio: CropAspectRatio
let allowAspectRatioSelection: Bool
let isLogoImage: Bool
let onComplete: (Data?) -> Void
@State private var capturedImageData: Data?
@State private var showingCropper = false
@State private var showingLogoEditor = false
@State private var croppedLogoImage: UIImage?
@State private var cameraID = UUID() // For resetting camera after cancel
var body: some View {
ZStack {
// Camera - only show when not cropping or editing
if !showingCropper && !showingLogoEditor {
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,
allowAspectRatioSelection: allowAspectRatioSelection,
shouldDismissOnComplete: false
) { croppedData in
if let croppedData {
// For logos, show the logo editor next
if isLogoImage, let uiImage = UIImage(data: croppedData) {
croppedLogoImage = uiImage
showingCropper = false
showingLogoEditor = true
} else {
onComplete(croppedData)
}
} else {
// User cancelled cropper - go back to camera for retake
showingCropper = false
capturedImageData = nil
cameraID = UUID() // Reset camera
}
}
.transition(.move(edge: .trailing))
}
// Logo editor overlay
if showingLogoEditor, let logoImage = croppedLogoImage {
LogoEditorSheet(logoImage: logoImage) { finalData in
if let finalData {
onComplete(finalData)
} else {
// User cancelled logo editor, go back to camera
showingLogoEditor = false
croppedLogoImage = nil
capturedImageData = nil
cameraID = UUID()
}
}
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
.animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
}
}
// 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")
}
}
}