Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
7231f50a07
commit
4a9ac58b7a
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
56
BusinessCard/Views/Components/CameraWithCropper.swift
Normal file
56
BusinessCard/Views/Components/CameraWithCropper.swift
Normal file
@ -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") }
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user