351 lines
13 KiB
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")
|
|
}
|
|
}
|
|
}
|