From 4a9ac58b7a9ea3a76c9df54c1829a170d778d89e Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 09:28:36 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Models/ContactFieldType.swift | 52 +++---- BusinessCard/Views/CardEditorView.swift | 30 ++-- BusinessCard/Views/CardsHomeView.swift | 18 ++- .../Views/Components/CameraCaptureView.swift | 11 +- .../Views/Components/CameraWithCropper.swift | 56 +++++++ BusinessCard/Views/ContactDetailView.swift | 27 +--- BusinessCard/Views/ContactsView.swift | 20 +-- .../Views/Sheets/AddContactSheet.swift | 27 +--- .../Views/Sheets/PhotoCropperSheet.swift | 142 +++++++++++++++--- 9 files changed, 258 insertions(+), 125 deletions(-) create mode 100644 BusinessCard/Views/Components/CameraWithCropper.swift diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index bc05788..0bbc852 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -193,7 +193,7 @@ extension ContactFieldType { displayName: "LinkedIn", systemImage: "linkedin", isCustomSymbol: true, - iconColor: Color(red: 0.0, green: 0.47, blue: 0.71), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "linkedin.com/in/username", @@ -221,7 +221,7 @@ extension ContactFieldType { displayName: "Instagram", systemImage: "instagram", isCustomSymbol: true, - iconColor: Color(red: 0.88, green: 0.19, blue: 0.42), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "instagram.com/username", @@ -235,7 +235,7 @@ extension ContactFieldType { displayName: "Facebook", systemImage: "facebook", isCustomSymbol: true, - iconColor: Color(red: 0.26, green: 0.40, blue: 0.70), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "facebook.com/username", @@ -277,7 +277,7 @@ extension ContactFieldType { displayName: "YouTube", systemImage: "youtube", isCustomSymbol: true, - iconColor: Color(red: 1.0, green: 0.0, blue: 0.0), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "youtube.com/@channel", @@ -290,7 +290,7 @@ extension ContactFieldType { id: "snapchat", displayName: "Snapchat", systemImage: "camera.fill", - iconColor: Color(red: 1.0, green: 0.98, blue: 0.0), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "snapchat.com/add/username", @@ -303,7 +303,7 @@ extension ContactFieldType { id: "pinterest", displayName: "Pinterest", systemImage: "pin.fill", - iconColor: Color(red: 0.9, green: 0.11, blue: 0.14), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "pinterest.com/username", @@ -317,7 +317,7 @@ extension ContactFieldType { displayName: "Twitch", systemImage: "twitch", isCustomSymbol: true, - iconColor: Color(red: 0.57, green: 0.27, blue: 1.0), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "twitch.tv/username", @@ -331,7 +331,7 @@ extension ContactFieldType { displayName: "Bluesky", systemImage: "bluesky", isCustomSymbol: true, - iconColor: Color(red: 0.0, green: 0.52, blue: 1.0), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "bsky.app/profile/username", @@ -345,7 +345,7 @@ extension ContactFieldType { displayName: "Mastodon", systemImage: "mastodon", isCustomSymbol: true, - iconColor: Color(red: 0.38, green: 0.28, blue: 0.85), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "mastodon.social/@username", @@ -368,7 +368,7 @@ extension ContactFieldType { displayName: "Reddit", systemImage: "reddit", isCustomSymbol: true, - iconColor: Color(red: 1.0, green: 0.27, blue: 0.0), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .social, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "reddit.com/user/username", @@ -384,7 +384,7 @@ extension ContactFieldType { displayName: "GitHub", systemImage: "github.fill", isCustomSymbol: true, - iconColor: Color(red: 0.13, green: 0.13, blue: 0.13), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .developer, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "github.com/username", @@ -397,7 +397,7 @@ extension ContactFieldType { id: "gitlab", displayName: "GitLab", systemImage: "chevron.left.forwardslash.chevron.right", - iconColor: Color(red: 0.99, green: 0.41, blue: 0.13), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .developer, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "gitlab.com/username", @@ -410,7 +410,7 @@ extension ContactFieldType { id: "stackoverflow", displayName: "Stack Overflow", systemImage: "text.bubble.fill", - iconColor: Color(red: 0.95, green: 0.51, blue: 0.13), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .developer, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "stackoverflow.com/users/id", @@ -426,7 +426,7 @@ extension ContactFieldType { displayName: "Telegram", systemImage: "telegram", isCustomSymbol: true, - iconColor: Color(red: 0.16, green: 0.63, blue: 0.89), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "t.me/username", @@ -444,7 +444,7 @@ extension ContactFieldType { id: "whatsapp", displayName: "WhatsApp", systemImage: "message.fill", - iconColor: Color(red: 0.15, green: 0.68, blue: 0.38), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "+1 555 123 4567", @@ -460,7 +460,7 @@ extension ContactFieldType { id: "signal", displayName: "Signal", systemImage: "bubble.left.fill", - iconColor: Color(red: 0.23, green: 0.47, blue: 0.98), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "+1 555 123 4567", @@ -477,7 +477,7 @@ extension ContactFieldType { displayName: "Discord", systemImage: "discord", isCustomSymbol: true, - iconColor: Color(red: 0.34, green: 0.40, blue: 0.95), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "discord.gg/invite", @@ -491,7 +491,7 @@ extension ContactFieldType { displayName: "Slack", systemImage: "slack", isCustomSymbol: true, - iconColor: Color(red: 0.38, green: 0.11, blue: 0.44), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "yourworkspace.slack.com", @@ -505,7 +505,7 @@ extension ContactFieldType { displayName: "Matrix", systemImage: "matrix", isCustomSymbol: true, - iconColor: Color(red: 0.0, green: 0.73, blue: 0.58), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .messaging, valueLabel: String(localized: "Username/Link"), valuePlaceholder: "@username:matrix.org", @@ -526,7 +526,7 @@ extension ContactFieldType { id: "venmo", displayName: "Venmo", systemImage: "dollarsign.circle.fill", - iconColor: Color(red: 0.22, green: 0.53, blue: 0.79), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .payment, valueLabel: String(localized: "Username"), valuePlaceholder: "@username", @@ -542,7 +542,7 @@ extension ContactFieldType { id: "cashApp", displayName: "Cash App", systemImage: "dollarsign.square.fill", - iconColor: Color(red: 0.0, green: 0.82, blue: 0.35), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .payment, valueLabel: String(localized: "Username"), valuePlaceholder: "$cashtag", @@ -558,7 +558,7 @@ extension ContactFieldType { id: "paypal", displayName: "PayPal", systemImage: "creditcard.fill", - iconColor: Color(red: 0.0, green: 0.19, blue: 0.56), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .payment, valueLabel: String(localized: "Email or Username"), valuePlaceholder: "paypal.me/username", @@ -571,7 +571,7 @@ extension ContactFieldType { id: "zelle", displayName: "Zelle", systemImage: "dollarsign.arrow.circlepath", - iconColor: Color(red: 0.42, green: 0.11, blue: 0.69), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .payment, valueLabel: String(localized: "Phone or Email"), valuePlaceholder: "email@example.com", @@ -587,7 +587,7 @@ extension ContactFieldType { displayName: "Patreon", systemImage: "patreon.fill", isCustomSymbol: true, - iconColor: Color(red: 1.0, green: 0.27, blue: 0.33), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .creator, valueLabel: String(localized: "Profile Link"), valuePlaceholder: "patreon.com/username", @@ -601,7 +601,7 @@ extension ContactFieldType { displayName: "Ko-fi", systemImage: "ko-fi", isCustomSymbol: true, - iconColor: Color(red: 1.0, green: 0.35, blue: 0.45), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .creator, valueLabel: String(localized: "Profile Link"), valuePlaceholder: "ko-fi.com/username", @@ -616,7 +616,7 @@ extension ContactFieldType { id: "calendly", displayName: "Calendly", systemImage: "calendar", - iconColor: Color(red: 0.0, green: 0.42, blue: 0.95), + iconColor: Color(red: 0.0, green: 0.0, blue: 0.0), category: .scheduling, valueLabel: String(localized: "Calendly Link"), valuePlaceholder: "calendly.com/username", diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index f139878..90d308a 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -45,13 +45,11 @@ struct CardEditorView: View { @State private var logoData: Data? // Photo picker state - @State private var pendingImageData: Data? // For camera flow only @State private var pendingImageType: ImageType? // For showing PhotoSourcePicker @State private var activeImageType: ImageType? // Tracks which type we're editing through the full flow @State private var pendingAction: PendingPhotoAction? // Action to take after source picker dismisses @State private var showingPhotoPicker = false @State private var showingCamera = false - @State private var showingPhotoCropper = false // For camera flow only private enum PendingPhotoAction { case library @@ -265,25 +263,17 @@ struct CardEditorView: View { } } .fullScreenCover(isPresented: $showingCamera) { - CameraCaptureView { imageData in - if let imageData { - pendingImageData = imageData - showingPhotoCropper = true + CameraWithCropper( + onSave: { croppedData in + savePhoto(croppedData, for: activeImageType) + showingCamera = false + activeImageType = nil + }, + onCancel: { + showingCamera = false + activeImageType = nil } - showingCamera = false - } - } - .fullScreenCover(isPresented: $showingPhotoCropper) { - if let pendingImageData { - PhotoCropperSheet(imageData: pendingImageData) { croppedData in - if let croppedData { - savePhoto(croppedData, for: activeImageType) - } - self.pendingImageData = nil - self.activeImageType = nil - showingPhotoCropper = false - } - } + ) } .onAppear { loadCardData() } .sheet(isPresented: $showingPreview) { diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift index a9b7090..2d8c57f 100644 --- a/BusinessCard/Views/CardsHomeView.swift +++ b/BusinessCard/Views/CardsHomeView.swift @@ -49,15 +49,17 @@ struct CardsHomeView: View { .accessibilityHint(String.localized("Create a new business card")) } - ToolbarItemGroup(placement: .topBarTrailing) { - if cardStore.selectedCard != nil { - if cardStore.cards.count > 1 { - Button(String.localized("Delete"), systemImage: "trash") { - showingDeleteConfirmation = true - } - .accessibilityHint(String.localized("Delete this card")) + if cardStore.cards.count > 1 && cardStore.selectedCard != nil { + ToolbarItem(placement: .topBarTrailing) { + Button(String.localized("Delete"), systemImage: "trash") { + showingDeleteConfirmation = true } - + .accessibilityHint(String.localized("Delete this card")) + } + } + + if cardStore.selectedCard != nil { + ToolbarItem(placement: .topBarTrailing) { Button(String.localized("Edit"), systemImage: "pencil") { showingEditCard = true } diff --git a/BusinessCard/Views/Components/CameraCaptureView.swift b/BusinessCard/Views/Components/CameraCaptureView.swift index e9e483a..35dd152 100644 --- a/BusinessCard/Views/Components/CameraCaptureView.swift +++ b/BusinessCard/Views/Components/CameraCaptureView.swift @@ -2,11 +2,18 @@ import SwiftUI import AVFoundation /// A camera capture view that takes a photo and returns the image data. +/// Set `shouldDismissOnCapture` to false if you want to handle dismissal yourself (e.g., for overlay cropper). struct CameraCaptureView: UIViewControllerRepresentable { @Environment(\.dismiss) private var dismiss + let shouldDismissOnCapture: Bool let onCapture: (Data?) -> Void + init(shouldDismissOnCapture: Bool = true, onCapture: @escaping (Data?) -> Void) { + self.shouldDismissOnCapture = shouldDismissOnCapture + self.onCapture = onCapture + } + func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = .camera @@ -35,7 +42,9 @@ struct CameraCaptureView: UIViewControllerRepresentable { } else { parent.onCapture(nil) } - parent.dismiss() + if parent.shouldDismissOnCapture { + parent.dismiss() + } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { diff --git a/BusinessCard/Views/Components/CameraWithCropper.swift b/BusinessCard/Views/Components/CameraWithCropper.swift new file mode 100644 index 0000000..2f49af2 --- /dev/null +++ b/BusinessCard/Views/Components/CameraWithCropper.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Bedrock + +/// A combined camera and cropper flow. +/// Shows the camera, and when a photo is taken, +/// immediately overlays the cropper. Both dismiss together when done. +struct CameraWithCropper: View { + @Environment(\.dismiss) private var dismiss + + let onSave: (Data) -> Void + let onCancel: () -> Void + + @State private var capturedImageData: Data? + @State private var showingCropper = false + + var body: some View { + ZStack { + // Camera view - don't auto-dismiss so we can show cropper overlay + CameraCaptureView(shouldDismissOnCapture: false) { imageData in + if let imageData { + capturedImageData = imageData + showingCropper = true + } else { + // User cancelled camera + onCancel() + } + } + .ignoresSafeArea() + + // Cropper overlay + if showingCropper, let capturedImageData { + PhotoCropperSheet( + imageData: capturedImageData, + shouldDismissOnComplete: false + ) { croppedData in + if let croppedData { + onSave(croppedData) + } else { + // User cancelled cropper, go back to camera + showingCropper = false + self.capturedImageData = nil + } + } + .transition(.move(edge: .trailing)) + } + } + .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper) + } +} + +#Preview { + CameraWithCropper( + onSave: { _ in print("Saved") }, + onCancel: { print("Cancelled") } + ) +} diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift index 2f62b6a..ab3de1a 100644 --- a/BusinessCard/Views/ContactDetailView.swift +++ b/BusinessCard/Views/ContactDetailView.swift @@ -16,11 +16,9 @@ struct ContactDetailView: View { @State private var newTag = "" // Photo picker state - @State private var pendingPhotoData: Data? // For camera flow only @State private var showingPhotoSourcePicker = false @State private var showingPhotoPicker = false @State private var showingCamera = false - @State private var showingPhotoCropper = false // For camera flow only @State private var pendingAction: PendingPhotoAction? private enum PendingPhotoAction { @@ -228,24 +226,15 @@ struct ContactDetailView: View { } } .fullScreenCover(isPresented: $showingCamera) { - CameraCaptureView { imageData in - if let imageData { - pendingPhotoData = imageData - showingPhotoCropper = true + CameraWithCropper( + onSave: { croppedData in + contact.photoData = croppedData + showingCamera = false + }, + onCancel: { + showingCamera = false } - showingCamera = false - } - } - .fullScreenCover(isPresented: $showingPhotoCropper) { - if let pendingPhotoData { - PhotoCropperSheet(imageData: pendingPhotoData) { croppedData in - if let croppedData { - contact.photoData = croppedData - } - self.pendingPhotoData = nil - showingPhotoCropper = false - } - } + ) } } diff --git a/BusinessCard/Views/ContactsView.swift b/BusinessCard/Views/ContactsView.swift index 628fdf0..f670e60 100644 --- a/BusinessCard/Views/ContactsView.swift +++ b/BusinessCard/Views/ContactsView.swift @@ -21,17 +21,17 @@ struct ContactsView: View { .navigationTitle(String.localized("Contacts")) .toolbar { ToolbarItem(placement: .primaryAction) { - HStack(spacing: Design.Spacing.small) { - Button(String.localized("Add Contact"), systemImage: "plus") { - showingAddContact = true - } - .accessibilityHint(String.localized("Manually add a new contact")) - - Button(String.localized("Scan Card"), systemImage: "qrcode.viewfinder") { - showingScanner = true - } - .accessibilityHint(String.localized("Scan someone else's QR code to save their card")) + Button(String.localized("Scan Card"), systemImage: "qrcode.viewfinder") { + showingScanner = true } + .accessibilityHint(String.localized("Scan someone else's QR code to save their card")) + } + + ToolbarItem(placement: .primaryAction) { + Button(String.localized("Add Contact"), systemImage: "plus") { + showingAddContact = true + } + .accessibilityHint(String.localized("Manually add a new contact")) } } .sheet(isPresented: $showingScanner) { diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Sheets/AddContactSheet.swift index df84873..33dbe40 100644 --- a/BusinessCard/Views/Sheets/AddContactSheet.swift +++ b/BusinessCard/Views/Sheets/AddContactSheet.swift @@ -9,11 +9,9 @@ struct AddContactSheet: View { // Photo @State private var photoData: Data? - @State private var pendingPhotoData: Data? // For camera flow only @State private var showingPhotoSourcePicker = false @State private var showingPhotoPicker = false @State private var showingCamera = false - @State private var showingPhotoCropper = false // For camera flow only @State private var pendingAction: PendingPhotoAction? private enum PendingPhotoAction { @@ -201,24 +199,15 @@ struct AddContactSheet: View { } } .fullScreenCover(isPresented: $showingCamera) { - CameraCaptureView { imageData in - if let imageData { - pendingPhotoData = imageData - showingPhotoCropper = true + CameraWithCropper( + onSave: { croppedData in + photoData = croppedData + showingCamera = false + }, + onCancel: { + showingCamera = false } - showingCamera = false - } - } - .fullScreenCover(isPresented: $showingPhotoCropper) { - if let pendingPhotoData { - PhotoCropperSheet(imageData: pendingPhotoData) { croppedData in - if let croppedData { - photoData = croppedData - } - self.pendingPhotoData = nil - showingPhotoCropper = false - } - } + ) } .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift index 23ce34b..d57f041 100644 --- a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift +++ b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift @@ -56,6 +56,12 @@ struct PhotoCropperSheet: View { CropGridLines(cropSize: cropSize) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) } + .onAppear { + containerSize = geometry.size + } + .onChange(of: geometry.size) { _, newSize in + containerSize = newSize + } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) @@ -136,21 +142,111 @@ struct PhotoCropperSheet: View { return } - // Get the screen's main bounds for rendering - let screenScale = UIScreen.main.scale + // First, normalize the image orientation so we're working with correctly oriented pixels + let normalizedImage = normalizeImageOrientation(uiImage) + let imageSize = normalizedImage.size - // Calculate the crop rectangle - // The crop area is centered in the view - let renderer = ImageRenderer(content: croppedImageView(image: uiImage)) - renderer.scale = screenScale - - if let cgImage = renderer.cgImage { - let croppedUIImage = UIImage(cgImage: cgImage) - if let jpegData = croppedUIImage.jpegData(compressionQuality: 0.85) { - onComplete(jpegData) - } else { - onComplete(nil) + // Guard against zero container size + guard containerSize.width > 0, containerSize.height > 0 else { + onComplete(nil) + if shouldDismissOnComplete { + dismiss() } + return + } + + // The image is displayed with scaledToFill in the container + // Calculate the display size of the image (before user scale) + let containerAspect = containerSize.width / containerSize.height + let imageAspect = imageSize.width / imageSize.height + + let baseDisplaySize: CGSize + if imageAspect > containerAspect { + // Image is wider - height fills container + baseDisplaySize = CGSize( + width: containerSize.height * imageAspect, + height: containerSize.height + ) + } else { + // Image is taller - width fills container + baseDisplaySize = CGSize( + width: containerSize.width, + height: containerSize.width / imageAspect + ) + } + + // Apply the user's scale to get the actual display size + let scaledDisplaySize = CGSize( + width: baseDisplaySize.width * scale, + height: baseDisplaySize.height * scale + ) + + // Calculate the ratio between image pixels and display points + let displayToImageRatioX = imageSize.width / scaledDisplaySize.width + let displayToImageRatioY = imageSize.height / scaledDisplaySize.height + + // The crop square is centered in the container + // The image is also centered, but shifted by the user's offset + + // In display coordinates: + // - Container center: (containerSize.width/2, containerSize.height/2) + // - Image center after offset: (containerSize.width/2 + offset.width, containerSize.height/2 + offset.height) + // - Crop square center: (containerSize.width/2, containerSize.height/2) + + // The crop square's center relative to the image's center (in display coords): + // cropCenterInImage = cropCenter - imageCenter = -offset + let cropCenterRelativeToImageCenterX = -offset.width + let cropCenterRelativeToImageCenterY = -offset.height + + // Convert to image pixel coordinates + // Image center in pixels is (imageSize.width/2, imageSize.height/2) + let cropCenterInImageX = (imageSize.width / 2) + (cropCenterRelativeToImageCenterX * displayToImageRatioX) + let cropCenterInImageY = (imageSize.height / 2) + (cropCenterRelativeToImageCenterY * displayToImageRatioY) + + // Crop size in image pixels + let cropSizeInImagePixels = cropSize * displayToImageRatioX + + // Calculate crop rect origin (top-left corner) + let cropOriginX = cropCenterInImageX - (cropSizeInImagePixels / 2) + let cropOriginY = cropCenterInImageY - (cropSizeInImagePixels / 2) + + // Clamp to image bounds + let clampedX = max(0, min(cropOriginX, imageSize.width - cropSizeInImagePixels)) + let clampedY = max(0, min(cropOriginY, imageSize.height - cropSizeInImagePixels)) + let clampedWidth = min(cropSizeInImagePixels, imageSize.width - clampedX) + let clampedHeight = min(cropSizeInImagePixels, imageSize.height - clampedY) + + let cropRect = CGRect( + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight + ) + + // Crop the normalized image + guard let cgImage = normalizedImage.cgImage, + let croppedCGImage = cgImage.cropping(to: cropRect) else { + onComplete(nil) + if shouldDismissOnComplete { + dismiss() + } + return + } + + let croppedUIImage = UIImage(cgImage: croppedCGImage) + + // Resize to a consistent output size (512x512 for profile photos) + let outputSize = CGSize(width: 512, height: 512) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + + let renderer = UIGraphicsImageRenderer(size: outputSize, format: format) + let resizedImage = renderer.image { _ in + croppedUIImage.draw(in: CGRect(origin: .zero, size: outputSize)) + } + + if let jpegData = resizedImage.jpegData(compressionQuality: 0.85) { + onComplete(jpegData) } else { onComplete(nil) } @@ -160,15 +256,17 @@ struct PhotoCropperSheet: View { } } - @MainActor - private func croppedImageView(image: UIImage) -> some View { - Image(uiImage: image) - .resizable() - .scaledToFill() - .scaleEffect(scale) - .offset(offset) - .frame(width: cropSize, height: cropSize) - .clipped() + /// Normalizes image orientation by redrawing with correct orientation applied + private func normalizeImageOrientation(_ image: UIImage) -> UIImage { + guard image.imageOrientation != .up else { return image } + + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { _ in + image.draw(at: .zero) + } } }