diff --git a/BusinessCard/Views/Components/ImageEditorFlow.swift b/BusinessCard/Views/Components/ImageEditorFlow.swift index dfd99f2..52c4367 100644 --- a/BusinessCard/Views/Components/ImageEditorFlow.swift +++ b/BusinessCard/Views/Components/ImageEditorFlow.swift @@ -25,12 +25,18 @@ struct ImageEditorFlow: View { imageType.cropAspectRatio } + /// Only allow aspect ratio selection for logos + private var allowAspectRatioSelection: 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, onComplete: { imageData in showingFullScreenPicker = false if let imageData { @@ -43,6 +49,7 @@ struct ImageEditorFlow: View { .fullScreenCover(isPresented: $showingFullScreenCamera) { CameraFlow( aspectRatio: aspectRatio, + allowAspectRatioSelection: allowAspectRatioSelection, onComplete: { imageData in showingFullScreenCamera = false if let imageData { @@ -121,6 +128,7 @@ struct ImageEditorFlow: View { private struct PhotoPickerFlow: View { let aspectRatio: CropAspectRatio + let allowAspectRatioSelection: Bool let onComplete: (Data?) -> Void @State private var selectedPhotoItem: PhotosPickerItem? @@ -163,6 +171,7 @@ private struct PhotoPickerFlow: View { PhotoCropperSheet( imageData: imageData, aspectRatio: aspectRatio, + allowAspectRatioSelection: allowAspectRatioSelection, shouldDismissOnComplete: false ) { croppedData in if let croppedData { @@ -186,6 +195,7 @@ private struct PhotoPickerFlow: View { private struct CameraFlow: View { let aspectRatio: CropAspectRatio + let allowAspectRatioSelection: Bool let onComplete: (Data?) -> Void @State private var capturedImageData: Data? @@ -215,6 +225,7 @@ private struct CameraFlow: View { PhotoCropperSheet( imageData: imageData, aspectRatio: aspectRatio, + allowAspectRatioSelection: allowAspectRatioSelection, shouldDismissOnComplete: false ) { croppedData in if let croppedData { diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift index 18ccc24..9535596 100644 --- a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift +++ b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift @@ -2,44 +2,94 @@ import SwiftUI import Bedrock /// Aspect ratio options for the photo cropper -enum CropAspectRatio { +enum CropAspectRatio: Identifiable, CaseIterable, Equatable { + case original // Use image's original aspect ratio case square // 1:1 for profile photos + case threeToTwo // 3:2 + case fiveToThree // 5:3 + case fourToThree // 4:3 + case fiveToFour // 5:4 + case sevenToFive // 7:5 + case sixteenToNine // 16:9 case banner // Wide ratio for cover/banner photos (roughly 2.3:1) - case custom(CGFloat) // Custom width:height ratio - var ratio: CGFloat { + var id: String { displayName } + + static var allCases: [CropAspectRatio] { + [.original, .square, .threeToTwo, .fiveToThree, .fourToThree, .fiveToFour, .sevenToFive, .sixteenToNine] + } + + var displayName: String { switch self { + case .original: return String.localized("Original") + case .square: return String.localized("Square") + case .threeToTwo: return "3:2" + case .fiveToThree: return "5:3" + case .fourToThree: return "4:3" + case .fiveToFour: return "5:4" + case .sevenToFive: return "7:5" + case .sixteenToNine: return "16:9" + case .banner: return String.localized("Banner") + } + } + + /// Returns the ratio (width / height). For .original, pass the image size. + func ratio(for imageSize: CGSize? = nil) -> CGFloat { + switch self { + case .original: + guard let size = imageSize, size.height > 0 else { return 1.0 } + return size.width / size.height case .square: return 1.0 + case .threeToTwo: + return 3.0 / 2.0 + case .fiveToThree: + return 5.0 / 3.0 + case .fourToThree: + return 4.0 / 3.0 + case .fiveToFour: + return 5.0 / 4.0 + case .sevenToFive: + return 7.0 / 5.0 + case .sixteenToNine: + return 16.0 / 9.0 case .banner: return 2.3 // Width is 2.3x height - case .custom(let ratio): - return ratio } } + + /// Simple ratio for backwards compatibility (doesn't handle .original properly) + var ratio: CGFloat { + ratio(for: nil) + } } /// A sheet that allows the user to crop an image. /// Supports pinch-to-zoom and drag gestures for positioning. /// Use `aspectRatio` to specify square, banner, or custom crop shapes. +/// Set `allowAspectRatioSelection` to true to show aspect ratio picker (for logos). struct PhotoCropperSheet: View { @Environment(\.dismiss) private var dismiss let imageData: Data - let aspectRatio: CropAspectRatio + let initialAspectRatio: CropAspectRatio + let allowAspectRatioSelection: Bool let onComplete: (Data?) -> Void // nil = cancelled, Data = saved let shouldDismissOnComplete: Bool init( imageData: Data, aspectRatio: CropAspectRatio = .square, + allowAspectRatioSelection: Bool = false, shouldDismissOnComplete: Bool = true, onComplete: @escaping (Data?) -> Void ) { self.imageData = imageData - self.aspectRatio = aspectRatio + self.initialAspectRatio = aspectRatio + self.allowAspectRatioSelection = allowAspectRatioSelection self.shouldDismissOnComplete = shouldDismissOnComplete self.onComplete = onComplete + self._selectedAspectRatio = State(initialValue: aspectRatio) } @State private var scale: CGFloat = 1.0 @@ -50,25 +100,47 @@ struct PhotoCropperSheet: View { @State private var lastRotation: Angle = .zero @State private var fixedRotation: Angle = .zero // For 90° toolbar rotations @State private var containerSize: CGSize = .zero + @State private var selectedAspectRatio: CropAspectRatio + @State private var showingAspectRatioPicker = false // Horizontal padding for the crop area private let horizontalPadding: CGFloat = 20 + /// Current effective aspect ratio (accounting for image size for .original) + private var effectiveRatio: CGFloat { + selectedAspectRatio.ratio(for: uiImage?.size) + } + private var cropWidth: CGFloat { // Use almost full width for better cropping experience max(100, containerSize.width - (horizontalPadding * 2)) } private var cropHeight: CGFloat { - cropWidth / aspectRatio.ratio + // Calculate height based on aspect ratio + let height = cropWidth / effectiveRatio + // Ensure it fits within the container + if height > containerSize.height - 100 { + return containerSize.height - 100 + } + return height + } + + private var adjustedCropWidth: CGFloat { + // If height was constrained, recalculate width + let height = cropWidth / effectiveRatio + if height > containerSize.height - 100 { + return cropHeight * effectiveRatio + } + return cropWidth } private var cropSize: CGSize { // Guard against zero/invalid sizes guard containerSize.width > 0, containerSize.height > 0 else { - return CGSize(width: 280, height: 280 / aspectRatio.ratio) + return CGSize(width: 280, height: 280 / effectiveRatio) } - return CGSize(width: cropWidth, height: cropHeight) + return CGSize(width: adjustedCropWidth, height: cropHeight) } private var uiImage: UIImage? { @@ -140,6 +212,16 @@ struct PhotoCropperSheet: View { .foregroundStyle(.white) } + // Aspect ratio picker (only for logo workflow) + if allowAspectRatioSelection { + Button { + showingAspectRatioPicker = true + } label: { + Image(systemName: "aspectratio") + .foregroundStyle(.white) + } + } + // Rotate 90° right Button { rotate90Right() @@ -159,6 +241,20 @@ struct PhotoCropperSheet: View { } } .preferredColorScheme(.dark) + .confirmationDialog( + String.localized("Aspect Ratio"), + isPresented: $showingAspectRatioPicker, + titleVisibility: .visible + ) { + ForEach(CropAspectRatio.allCases) { ratio in + Button(ratio.displayName) { + withAnimation(.spring(duration: Design.Animation.springDuration)) { + selectedAspectRatio = ratio + } + } + } + Button(String.localized("Cancel"), role: .cancel) { } + } } // MARK: - Gestures @@ -335,13 +431,17 @@ struct PhotoCropperSheet: View { // Resize to a consistent output size based on aspect ratio let outputSize: CGSize - switch aspectRatio { + let baseWidth: CGFloat = 1024 + let currentRatio = effectiveRatio + + switch selectedAspectRatio { case .square: outputSize = CGSize(width: 512, height: 512) case .banner: - outputSize = CGSize(width: 1024, height: 445) // ~2.3:1 ratio - case .custom(let ratio): - outputSize = CGSize(width: 1024, height: 1024 / ratio) + outputSize = CGSize(width: baseWidth, height: baseWidth / 2.3) + default: + // For all other ratios, use base width and calculate height + outputSize = CGSize(width: baseWidth, height: baseWidth / currentRatio) } let format = UIGraphicsImageRendererFormat()