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

This commit is contained in:
Matt Bruce 2026-01-09 10:28:01 -06:00
parent 05aedadfc3
commit 8c40e638e5
2 changed files with 125 additions and 14 deletions

View File

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

View File

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