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",
|
displayName: "LinkedIn",
|
||||||
systemImage: "linkedin",
|
systemImage: "linkedin",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "linkedin.com/in/username",
|
valuePlaceholder: "linkedin.com/in/username",
|
||||||
@ -221,7 +221,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Instagram",
|
displayName: "Instagram",
|
||||||
systemImage: "instagram",
|
systemImage: "instagram",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "instagram.com/username",
|
valuePlaceholder: "instagram.com/username",
|
||||||
@ -235,7 +235,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Facebook",
|
displayName: "Facebook",
|
||||||
systemImage: "facebook",
|
systemImage: "facebook",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "facebook.com/username",
|
valuePlaceholder: "facebook.com/username",
|
||||||
@ -277,7 +277,7 @@ extension ContactFieldType {
|
|||||||
displayName: "YouTube",
|
displayName: "YouTube",
|
||||||
systemImage: "youtube",
|
systemImage: "youtube",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "youtube.com/@channel",
|
valuePlaceholder: "youtube.com/@channel",
|
||||||
@ -290,7 +290,7 @@ extension ContactFieldType {
|
|||||||
id: "snapchat",
|
id: "snapchat",
|
||||||
displayName: "Snapchat",
|
displayName: "Snapchat",
|
||||||
systemImage: "camera.fill",
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "snapchat.com/add/username",
|
valuePlaceholder: "snapchat.com/add/username",
|
||||||
@ -303,7 +303,7 @@ extension ContactFieldType {
|
|||||||
id: "pinterest",
|
id: "pinterest",
|
||||||
displayName: "Pinterest",
|
displayName: "Pinterest",
|
||||||
systemImage: "pin.fill",
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "pinterest.com/username",
|
valuePlaceholder: "pinterest.com/username",
|
||||||
@ -317,7 +317,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Twitch",
|
displayName: "Twitch",
|
||||||
systemImage: "twitch",
|
systemImage: "twitch",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "twitch.tv/username",
|
valuePlaceholder: "twitch.tv/username",
|
||||||
@ -331,7 +331,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Bluesky",
|
displayName: "Bluesky",
|
||||||
systemImage: "bluesky",
|
systemImage: "bluesky",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "bsky.app/profile/username",
|
valuePlaceholder: "bsky.app/profile/username",
|
||||||
@ -345,7 +345,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Mastodon",
|
displayName: "Mastodon",
|
||||||
systemImage: "mastodon",
|
systemImage: "mastodon",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "mastodon.social/@username",
|
valuePlaceholder: "mastodon.social/@username",
|
||||||
@ -368,7 +368,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Reddit",
|
displayName: "Reddit",
|
||||||
systemImage: "reddit",
|
systemImage: "reddit",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "reddit.com/user/username",
|
valuePlaceholder: "reddit.com/user/username",
|
||||||
@ -384,7 +384,7 @@ extension ContactFieldType {
|
|||||||
displayName: "GitHub",
|
displayName: "GitHub",
|
||||||
systemImage: "github.fill",
|
systemImage: "github.fill",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "github.com/username",
|
valuePlaceholder: "github.com/username",
|
||||||
@ -397,7 +397,7 @@ extension ContactFieldType {
|
|||||||
id: "gitlab",
|
id: "gitlab",
|
||||||
displayName: "GitLab",
|
displayName: "GitLab",
|
||||||
systemImage: "chevron.left.forwardslash.chevron.right",
|
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,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "gitlab.com/username",
|
valuePlaceholder: "gitlab.com/username",
|
||||||
@ -410,7 +410,7 @@ extension ContactFieldType {
|
|||||||
id: "stackoverflow",
|
id: "stackoverflow",
|
||||||
displayName: "Stack Overflow",
|
displayName: "Stack Overflow",
|
||||||
systemImage: "text.bubble.fill",
|
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,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "stackoverflow.com/users/id",
|
valuePlaceholder: "stackoverflow.com/users/id",
|
||||||
@ -426,7 +426,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Telegram",
|
displayName: "Telegram",
|
||||||
systemImage: "telegram",
|
systemImage: "telegram",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "t.me/username",
|
valuePlaceholder: "t.me/username",
|
||||||
@ -444,7 +444,7 @@ extension ContactFieldType {
|
|||||||
id: "whatsapp",
|
id: "whatsapp",
|
||||||
displayName: "WhatsApp",
|
displayName: "WhatsApp",
|
||||||
systemImage: "message.fill",
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "+1 555 123 4567",
|
valuePlaceholder: "+1 555 123 4567",
|
||||||
@ -460,7 +460,7 @@ extension ContactFieldType {
|
|||||||
id: "signal",
|
id: "signal",
|
||||||
displayName: "Signal",
|
displayName: "Signal",
|
||||||
systemImage: "bubble.left.fill",
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "+1 555 123 4567",
|
valuePlaceholder: "+1 555 123 4567",
|
||||||
@ -477,7 +477,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Discord",
|
displayName: "Discord",
|
||||||
systemImage: "discord",
|
systemImage: "discord",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "discord.gg/invite",
|
valuePlaceholder: "discord.gg/invite",
|
||||||
@ -491,7 +491,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Slack",
|
displayName: "Slack",
|
||||||
systemImage: "slack",
|
systemImage: "slack",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "yourworkspace.slack.com",
|
valuePlaceholder: "yourworkspace.slack.com",
|
||||||
@ -505,7 +505,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Matrix",
|
displayName: "Matrix",
|
||||||
systemImage: "matrix",
|
systemImage: "matrix",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
valuePlaceholder: "@username:matrix.org",
|
valuePlaceholder: "@username:matrix.org",
|
||||||
@ -526,7 +526,7 @@ extension ContactFieldType {
|
|||||||
id: "venmo",
|
id: "venmo",
|
||||||
displayName: "Venmo",
|
displayName: "Venmo",
|
||||||
systemImage: "dollarsign.circle.fill",
|
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,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Username"),
|
valueLabel: String(localized: "Username"),
|
||||||
valuePlaceholder: "@username",
|
valuePlaceholder: "@username",
|
||||||
@ -542,7 +542,7 @@ extension ContactFieldType {
|
|||||||
id: "cashApp",
|
id: "cashApp",
|
||||||
displayName: "Cash App",
|
displayName: "Cash App",
|
||||||
systemImage: "dollarsign.square.fill",
|
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,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Username"),
|
valueLabel: String(localized: "Username"),
|
||||||
valuePlaceholder: "$cashtag",
|
valuePlaceholder: "$cashtag",
|
||||||
@ -558,7 +558,7 @@ extension ContactFieldType {
|
|||||||
id: "paypal",
|
id: "paypal",
|
||||||
displayName: "PayPal",
|
displayName: "PayPal",
|
||||||
systemImage: "creditcard.fill",
|
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,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Email or Username"),
|
valueLabel: String(localized: "Email or Username"),
|
||||||
valuePlaceholder: "paypal.me/username",
|
valuePlaceholder: "paypal.me/username",
|
||||||
@ -571,7 +571,7 @@ extension ContactFieldType {
|
|||||||
id: "zelle",
|
id: "zelle",
|
||||||
displayName: "Zelle",
|
displayName: "Zelle",
|
||||||
systemImage: "dollarsign.arrow.circlepath",
|
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,
|
category: .payment,
|
||||||
valueLabel: String(localized: "Phone or Email"),
|
valueLabel: String(localized: "Phone or Email"),
|
||||||
valuePlaceholder: "email@example.com",
|
valuePlaceholder: "email@example.com",
|
||||||
@ -587,7 +587,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Patreon",
|
displayName: "Patreon",
|
||||||
systemImage: "patreon.fill",
|
systemImage: "patreon.fill",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
valuePlaceholder: "patreon.com/username",
|
valuePlaceholder: "patreon.com/username",
|
||||||
@ -601,7 +601,7 @@ extension ContactFieldType {
|
|||||||
displayName: "Ko-fi",
|
displayName: "Ko-fi",
|
||||||
systemImage: "ko-fi",
|
systemImage: "ko-fi",
|
||||||
isCustomSymbol: true,
|
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,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
valuePlaceholder: "ko-fi.com/username",
|
valuePlaceholder: "ko-fi.com/username",
|
||||||
@ -616,7 +616,7 @@ extension ContactFieldType {
|
|||||||
id: "calendly",
|
id: "calendly",
|
||||||
displayName: "Calendly",
|
displayName: "Calendly",
|
||||||
systemImage: "calendar",
|
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,
|
category: .scheduling,
|
||||||
valueLabel: String(localized: "Calendly Link"),
|
valueLabel: String(localized: "Calendly Link"),
|
||||||
valuePlaceholder: "calendly.com/username",
|
valuePlaceholder: "calendly.com/username",
|
||||||
|
|||||||
@ -45,13 +45,11 @@ struct CardEditorView: View {
|
|||||||
@State private var logoData: Data?
|
@State private var logoData: Data?
|
||||||
|
|
||||||
// Photo picker state
|
// Photo picker state
|
||||||
@State private var pendingImageData: Data? // For camera flow only
|
|
||||||
@State private var pendingImageType: ImageType? // For showing PhotoSourcePicker
|
@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 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 pendingAction: PendingPhotoAction? // Action to take after source picker dismisses
|
||||||
@State private var showingPhotoPicker = false
|
@State private var showingPhotoPicker = false
|
||||||
@State private var showingCamera = false
|
@State private var showingCamera = false
|
||||||
@State private var showingPhotoCropper = false // For camera flow only
|
|
||||||
|
|
||||||
private enum PendingPhotoAction {
|
private enum PendingPhotoAction {
|
||||||
case library
|
case library
|
||||||
@ -265,25 +263,17 @@ struct CardEditorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showingCamera) {
|
.fullScreenCover(isPresented: $showingCamera) {
|
||||||
CameraCaptureView { imageData in
|
CameraWithCropper(
|
||||||
if let imageData {
|
onSave: { croppedData in
|
||||||
pendingImageData = imageData
|
savePhoto(croppedData, for: activeImageType)
|
||||||
showingPhotoCropper = true
|
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() }
|
.onAppear { loadCardData() }
|
||||||
.sheet(isPresented: $showingPreview) {
|
.sheet(isPresented: $showingPreview) {
|
||||||
|
|||||||
@ -49,15 +49,17 @@ struct CardsHomeView: View {
|
|||||||
.accessibilityHint(String.localized("Create a new business card"))
|
.accessibilityHint(String.localized("Create a new business card"))
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
if cardStore.cards.count > 1 && cardStore.selectedCard != nil {
|
||||||
if cardStore.selectedCard != nil {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if cardStore.cards.count > 1 {
|
Button(String.localized("Delete"), systemImage: "trash") {
|
||||||
Button(String.localized("Delete"), systemImage: "trash") {
|
showingDeleteConfirmation = true
|
||||||
showingDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
.accessibilityHint(String.localized("Delete this card"))
|
|
||||||
}
|
}
|
||||||
|
.accessibilityHint(String.localized("Delete this card"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cardStore.selectedCard != nil {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button(String.localized("Edit"), systemImage: "pencil") {
|
Button(String.localized("Edit"), systemImage: "pencil") {
|
||||||
showingEditCard = true
|
showingEditCard = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,18 @@ import SwiftUI
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
/// A camera capture view that takes a photo and returns the image data.
|
/// 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 {
|
struct CameraCaptureView: UIViewControllerRepresentable {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let shouldDismissOnCapture: Bool
|
||||||
let onCapture: (Data?) -> Void
|
let onCapture: (Data?) -> Void
|
||||||
|
|
||||||
|
init(shouldDismissOnCapture: Bool = true, onCapture: @escaping (Data?) -> Void) {
|
||||||
|
self.shouldDismissOnCapture = shouldDismissOnCapture
|
||||||
|
self.onCapture = onCapture
|
||||||
|
}
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
let picker = UIImagePickerController()
|
let picker = UIImagePickerController()
|
||||||
picker.sourceType = .camera
|
picker.sourceType = .camera
|
||||||
@ -35,7 +42,9 @@ struct CameraCaptureView: UIViewControllerRepresentable {
|
|||||||
} else {
|
} else {
|
||||||
parent.onCapture(nil)
|
parent.onCapture(nil)
|
||||||
}
|
}
|
||||||
parent.dismiss()
|
if parent.shouldDismissOnCapture {
|
||||||
|
parent.dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
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 = ""
|
@State private var newTag = ""
|
||||||
|
|
||||||
// Photo picker state
|
// Photo picker state
|
||||||
@State private var pendingPhotoData: Data? // For camera flow only
|
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@State private var showingPhotoPicker = false
|
@State private var showingPhotoPicker = false
|
||||||
@State private var showingCamera = false
|
@State private var showingCamera = false
|
||||||
@State private var showingPhotoCropper = false // For camera flow only
|
|
||||||
@State private var pendingAction: PendingPhotoAction?
|
@State private var pendingAction: PendingPhotoAction?
|
||||||
|
|
||||||
private enum PendingPhotoAction {
|
private enum PendingPhotoAction {
|
||||||
@ -228,24 +226,15 @@ struct ContactDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showingCamera) {
|
.fullScreenCover(isPresented: $showingCamera) {
|
||||||
CameraCaptureView { imageData in
|
CameraWithCropper(
|
||||||
if let imageData {
|
onSave: { croppedData in
|
||||||
pendingPhotoData = imageData
|
contact.photoData = croppedData
|
||||||
showingPhotoCropper = true
|
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"))
|
.navigationTitle(String.localized("Contacts"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
HStack(spacing: Design.Spacing.small) {
|
Button(String.localized("Scan Card"), systemImage: "qrcode.viewfinder") {
|
||||||
Button(String.localized("Add Contact"), systemImage: "plus") {
|
showingScanner = true
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
.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) {
|
.sheet(isPresented: $showingScanner) {
|
||||||
|
|||||||
@ -9,11 +9,9 @@ struct AddContactSheet: View {
|
|||||||
|
|
||||||
// Photo
|
// Photo
|
||||||
@State private var photoData: Data?
|
@State private var photoData: Data?
|
||||||
@State private var pendingPhotoData: Data? // For camera flow only
|
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@State private var showingPhotoPicker = false
|
@State private var showingPhotoPicker = false
|
||||||
@State private var showingCamera = false
|
@State private var showingCamera = false
|
||||||
@State private var showingPhotoCropper = false // For camera flow only
|
|
||||||
@State private var pendingAction: PendingPhotoAction?
|
@State private var pendingAction: PendingPhotoAction?
|
||||||
|
|
||||||
private enum PendingPhotoAction {
|
private enum PendingPhotoAction {
|
||||||
@ -201,24 +199,15 @@ struct AddContactSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showingCamera) {
|
.fullScreenCover(isPresented: $showingCamera) {
|
||||||
CameraCaptureView { imageData in
|
CameraWithCropper(
|
||||||
if let imageData {
|
onSave: { croppedData in
|
||||||
pendingPhotoData = imageData
|
photoData = croppedData
|
||||||
showingPhotoCropper = true
|
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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
|||||||
@ -56,6 +56,12 @@ struct PhotoCropperSheet: View {
|
|||||||
CropGridLines(cropSize: cropSize)
|
CropGridLines(cropSize: cropSize)
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
.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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
@ -136,21 +142,111 @@ struct PhotoCropperSheet: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the screen's main bounds for rendering
|
// First, normalize the image orientation so we're working with correctly oriented pixels
|
||||||
let screenScale = UIScreen.main.scale
|
let normalizedImage = normalizeImageOrientation(uiImage)
|
||||||
|
let imageSize = normalizedImage.size
|
||||||
|
|
||||||
// Calculate the crop rectangle
|
// Guard against zero container size
|
||||||
// The crop area is centered in the view
|
guard containerSize.width > 0, containerSize.height > 0 else {
|
||||||
let renderer = ImageRenderer(content: croppedImageView(image: uiImage))
|
onComplete(nil)
|
||||||
renderer.scale = screenScale
|
if shouldDismissOnComplete {
|
||||||
|
dismiss()
|
||||||
if let cgImage = renderer.cgImage {
|
|
||||||
let croppedUIImage = UIImage(cgImage: cgImage)
|
|
||||||
if let jpegData = croppedUIImage.jpegData(compressionQuality: 0.85) {
|
|
||||||
onComplete(jpegData)
|
|
||||||
} else {
|
|
||||||
onComplete(nil)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
} else {
|
||||||
onComplete(nil)
|
onComplete(nil)
|
||||||
}
|
}
|
||||||
@ -160,15 +256,17 @@ struct PhotoCropperSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
/// Normalizes image orientation by redrawing with correct orientation applied
|
||||||
private func croppedImageView(image: UIImage) -> some View {
|
private func normalizeImageOrientation(_ image: UIImage) -> UIImage {
|
||||||
Image(uiImage: image)
|
guard image.imageOrientation != .up else { return image }
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
let format = UIGraphicsImageRendererFormat()
|
||||||
.scaleEffect(scale)
|
format.scale = image.scale
|
||||||
.offset(offset)
|
|
||||||
.frame(width: cropSize, height: cropSize)
|
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||||
.clipped()
|
return renderer.image { _ in
|
||||||
|
image.draw(at: .zero)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user