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

This commit is contained in:
Matt Bruce 2026-01-09 09:28:36 -06:00
parent 7231f50a07
commit 4a9ac58b7a
9 changed files with 258 additions and 125 deletions

View File

@ -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",

View File

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

View File

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

View File

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

View 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") }
)
}

View File

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

View File

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

View File

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

View File

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