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

This commit is contained in:
Matt Bruce 2026-01-09 09:09:57 -06:00
parent 6d9c956c06
commit 7231f50a07
17 changed files with 1075 additions and 115 deletions

View File

@ -275,7 +275,8 @@ extension ContactFieldType {
static let youtube = ContactFieldType(
id: "youtube",
displayName: "YouTube",
systemImage: "youtube.fill",
systemImage: "youtube",
isCustomSymbol: true,
iconColor: Color(red: 1.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -342,7 +343,7 @@ extension ContactFieldType {
static let mastodon = ContactFieldType(
id: "mastodon",
displayName: "Mastodon",
systemImage: "mastodon.fill",
systemImage: "mastodon",
isCustomSymbol: true,
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
category: .social,
@ -365,7 +366,7 @@ extension ContactFieldType {
static let reddit = ContactFieldType(
id: "reddit",
displayName: "Reddit",
systemImage: "reddit.fill",
systemImage: "reddit",
isCustomSymbol: true,
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
category: .social,
@ -474,7 +475,7 @@ extension ContactFieldType {
static let discord = ContactFieldType(
id: "discord",
displayName: "Discord",
systemImage: "discord.fill",
systemImage: "discord",
isCustomSymbol: true,
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
category: .messaging,

View File

@ -167,9 +167,6 @@
},
"Contact Fields" : {
},
"Cover photo" : {
},
"Create multiple business cards" : {
"extractionState" : "stale",
@ -314,6 +311,9 @@
},
"Link" : {
},
"Links" : {
},
"Maiden Name" : {
@ -372,6 +372,9 @@
},
"Personal details" : {
},
"Phone" : {
},
"Phone Number" : {
@ -400,6 +403,9 @@
}
}
}
},
"Photo" : {
},
"Please allow camera access in Settings to scan QR codes." : {
@ -443,6 +449,9 @@
},
"Profile Link" : {
},
"Profile Photo" : {
},
"Pronouns (e.g. she/her)" : {

View File

@ -85,7 +85,8 @@ final class ContactsStore: ContactTracking {
notes: String = "",
tags: String = "",
followUpDate: Date? = nil,
contactFields: [ContactField] = []
contactFields: [ContactField] = [],
photoData: Data? = nil
) {
let contact = Contact(
name: name,
@ -96,7 +97,8 @@ final class ContactsStore: ContactTracking {
tags: tags,
followUpDate: followUpDate,
email: email,
phone: phone
phone: phone,
photoData: photoData
)
modelContext.insert(contact)

View File

@ -40,15 +40,42 @@ struct CardEditorView: View {
@State private var selectedLayout: CardLayoutStyle = .stacked
// Photos
@State private var selectedPhoto: PhotosPickerItem?
@State private var photoData: Data?
@State private var selectedCoverPhoto: PhotosPickerItem?
@State private var coverPhotoData: Data?
@State private var selectedLogo: PhotosPickerItem?
@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
case camera
}
@State private var showingPreview = false
enum ImageType: String, Identifiable {
case profile
case cover
case logo
var id: String { rawValue }
var title: String {
switch self {
case .profile: return String.localized("Add profile picture")
case .cover: return String.localized("Add cover photo")
case .logo: return String.localized("Add company logo")
}
}
}
private var isEditing: Bool { card != nil }
private var isFormValid: Bool {
!effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@ -90,14 +117,14 @@ struct CardEditorView: View {
// Images & Layout section
Section {
ImageLayoutRow(
selectedPhoto: $selectedPhoto,
photoData: $photoData,
selectedCoverPhoto: $selectedCoverPhoto,
coverPhotoData: $coverPhotoData,
selectedLogo: $selectedLogo,
logoData: $logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme
selectedTheme: selectedTheme,
onSelectImage: { imageType in
pendingImageType = imageType
}
)
} header: {
Text("Images & layout")
@ -190,24 +217,71 @@ struct CardEditorView: View {
.disabled(!isFormValid)
}
}
.onChange(of: selectedPhoto) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
photoData = data
.sheet(item: $pendingImageType, onDismiss: {
// After source picker dismisses, show the appropriate picker
guard let action = pendingAction else { return }
pendingAction = nil
// Small delay to ensure sheet is fully dismissed
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
switch action {
case .library:
showingPhotoPicker = true
case .camera:
showingCamera = true
}
}
}) { imageType in
PhotoSourcePicker(
title: imageType.title,
hasExistingPhoto: hasExistingPhoto(for: imageType),
onSelectFromLibrary: {
activeImageType = imageType
pendingAction = .library
},
onTakePhoto: {
activeImageType = imageType
pendingAction = .camera
},
onRemovePhoto: {
removePhoto(for: imageType)
}
.onChange(of: selectedCoverPhoto) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
coverPhotoData = data
)
}
.fullScreenCover(isPresented: $showingPhotoPicker) {
NavigationStack {
PhotoPickerWithCropper(
onSave: { croppedData in
savePhoto(croppedData, for: activeImageType)
showingPhotoPicker = false
activeImageType = nil
},
onCancel: {
showingPhotoPicker = false
activeImageType = nil
}
)
}
}
.fullScreenCover(isPresented: $showingCamera) {
CameraCaptureView { imageData in
if let imageData {
pendingImageData = imageData
showingPhotoCropper = true
}
.onChange(of: selectedLogo) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
logoData = data
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
}
}
}
@ -252,14 +326,12 @@ private struct CardStylePicker: View {
// MARK: - Image Layout Row
private struct ImageLayoutRow: View {
@Binding var selectedPhoto: PhotosPickerItem?
@Binding var photoData: Data?
@Binding var selectedCoverPhoto: PhotosPickerItem?
@Binding var coverPhotoData: Data?
@Binding var selectedLogo: PhotosPickerItem?
@Binding var logoData: Data?
let avatarSystemName: String
let selectedTheme: CardTheme
let onSelectImage: (CardEditorView.ImageType) -> Void
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
@ -270,15 +342,17 @@ private struct ImageLayoutRow: View {
coverPhotoData: coverPhotoData,
logoData: logoData,
selectedTheme: selectedTheme,
selectedCoverPhoto: $selectedCoverPhoto,
selectedLogo: $selectedLogo
onEditCover: { onSelectImage(.cover) },
onEditLogo: { onSelectImage(.logo) }
)
// Profile photo with edit button
ZStack(alignment: .bottomTrailing) {
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
PhotosPicker(selection: $selectedPhoto, matching: .images) {
Button {
onSelectImage(.profile)
} label: {
Image(systemName: "pencil")
.font(.caption2)
.padding(Design.Spacing.xSmall)
@ -296,9 +370,7 @@ private struct ImageLayoutRow: View {
photoData: $photoData,
coverPhotoData: $coverPhotoData,
logoData: $logoData,
selectedPhoto: $selectedPhoto,
selectedCoverPhoto: $selectedCoverPhoto,
selectedLogo: $selectedLogo
onSelectImage: onSelectImage
)
}
}
@ -310,8 +382,8 @@ private struct BannerPreviewView: View {
let coverPhotoData: Data?
let logoData: Data?
let selectedTheme: CardTheme
@Binding var selectedCoverPhoto: PhotosPickerItem?
@Binding var selectedLogo: PhotosPickerItem?
let onEditCover: () -> Void
let onEditLogo: () -> Void
var body: some View {
ZStack {
@ -342,7 +414,7 @@ private struct BannerPreviewView: View {
VStack {
HStack {
// Edit cover photo button (top-left)
PhotosPicker(selection: $selectedCoverPhoto, matching: .images) {
Button(action: onEditCover) {
Image(systemName: "photo")
.font(.caption)
.padding(Design.Spacing.small)
@ -355,7 +427,7 @@ private struct BannerPreviewView: View {
Spacer()
// Edit logo button (top-right)
PhotosPicker(selection: $selectedLogo, matching: .images) {
Button(action: onEditLogo) {
Image(systemName: "building.2")
.font(.caption)
.padding(Design.Spacing.small)
@ -380,9 +452,7 @@ private struct ImageActionButtonsRow: View {
@Binding var photoData: Data?
@Binding var coverPhotoData: Data?
@Binding var logoData: Data?
@Binding var selectedPhoto: PhotosPickerItem?
@Binding var selectedCoverPhoto: PhotosPickerItem?
@Binding var selectedLogo: PhotosPickerItem?
let onSelectImage: (CardEditorView.ImageType) -> Void
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
@ -392,7 +462,7 @@ private struct ImageActionButtonsRow: View {
subtitle: photoData == nil ? String.localized("Add your headshot") : String.localized("Change or remove"),
systemImage: "person.crop.circle",
hasImage: photoData != nil,
selection: $selectedPhoto,
onTap: { onSelectImage(.profile) },
onRemove: { photoData = nil }
)
@ -402,7 +472,7 @@ private struct ImageActionButtonsRow: View {
subtitle: coverPhotoData == nil ? String.localized("Add banner background") : String.localized("Change or remove"),
systemImage: "photo.fill",
hasImage: coverPhotoData != nil,
selection: $selectedCoverPhoto,
onTap: { onSelectImage(.cover) },
onRemove: { coverPhotoData = nil }
)
@ -412,7 +482,7 @@ private struct ImageActionButtonsRow: View {
subtitle: logoData == nil ? String.localized("Add your logo") : String.localized("Change or remove"),
systemImage: "building.2",
hasImage: logoData != nil,
selection: $selectedLogo,
onTap: { onSelectImage(.logo) },
onRemove: { logoData = nil }
)
}
@ -426,10 +496,11 @@ private struct ImageActionRow: View {
let subtitle: String
let systemImage: String
let hasImage: Bool
@Binding var selection: PhotosPickerItem?
let onTap: () -> Void
let onRemove: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.font(.title3)
@ -448,25 +519,24 @@ private struct ImageActionRow: View {
Spacer()
if hasImage {
Button {
onRemove()
} label: {
Image(systemName: "xmark.circle.fill")
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel(String.localized("Remove \(title.lowercased())"))
}
PhotosPicker(selection: $selection, matching: .images) {
Image(systemName: hasImage ? "arrow.triangle.2.circlepath" : "plus.circle.fill")
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.accessibilityLabel(hasImage ? String.localized("Change \(title.lowercased())") : String.localized("Add \(title.lowercased())"))
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("\(title): \(subtitle)")
.contextMenu {
if hasImage {
Button(role: .destructive) {
onRemove()
} label: {
Label(String.localized("Remove"), systemImage: "trash")
}
}
}
}
}
@ -640,9 +710,39 @@ private extension CardEditorView {
selectedTheme = card.theme
selectedLayout = card.layoutStyle
photoData = card.photoData
coverPhotoData = card.coverPhotoData
logoData = card.logoData
}
// MARK: - Photo Helpers
func hasExistingPhoto(for imageType: ImageType?) -> Bool {
guard let imageType else { return false }
switch imageType {
case .profile: return photoData != nil
case .cover: return coverPhotoData != nil
case .logo: return logoData != nil
}
}
func removePhoto(for imageType: ImageType?) {
guard let imageType else { return }
switch imageType {
case .profile: photoData = nil
case .cover: coverPhotoData = nil
case .logo: logoData = nil
}
}
func savePhoto(_ data: Data, for imageType: ImageType?) {
guard let imageType else { return }
switch imageType {
case .profile: photoData = data
case .cover: coverPhotoData = data
case .logo: logoData = data
}
}
func saveCard() {
if let existingCard = card {
updateCard(existingCard)

View File

@ -0,0 +1,55 @@
import SwiftUI
import AVFoundation
/// A camera capture view that takes a photo and returns the image data.
struct CameraCaptureView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
let onCapture: (Data?) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
picker.allowsEditing = false
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: CameraCaptureView
init(_ parent: CameraCaptureView) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage {
let data = image.jpegData(compressionQuality: 0.9)
parent.onCapture(data)
} else {
parent.onCapture(nil)
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.onCapture(nil)
parent.dismiss()
}
}
}
// MARK: - Camera Availability Check
extension CameraCaptureView {
/// Checks if camera is available on this device
static var isCameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
}

View File

@ -0,0 +1,79 @@
import SwiftUI
import PhotosUI
import Bedrock
/// A combined photo picker and cropper flow.
/// Shows the system PhotosPicker, and when an image is selected,
/// immediately overlays the cropper. Both dismiss together when done.
struct PhotoPickerWithCropper: View {
@Environment(\.dismiss) private var dismiss
let onSave: (Data) -> Void
let onCancel: () -> Void
@State private var selectedItem: PhotosPickerItem?
@State private var imageData: Data?
@State private var showingCropper = false
var body: some View {
PhotosPicker(
selection: $selectedItem,
matching: .images,
photoLibrary: .shared()
) {
// This is never shown - we use it in inline mode
EmptyView()
}
.photosPickerStyle(.inline)
.photosPickerDisabledCapabilities([.selectionActions])
.ignoresSafeArea()
.onChange(of: selectedItem) { _, newValue in
guard let newValue else { return }
Task { @MainActor in
if let data = try? await newValue.loadTransferable(type: Data.self) {
imageData = data
showingCropper = true
}
}
}
.overlay {
// Cropper overlay
if showingCropper, let imageData {
PhotoCropperSheet(
imageData: imageData,
shouldDismissOnComplete: false
) { croppedData in
if let croppedData {
onSave(croppedData)
} else {
// User cancelled cropper, go back to picker
showingCropper = false
self.imageData = nil
self.selectedItem = nil
}
}
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
.toolbar {
// Only show cancel when cropper is NOT showing (cropper has its own toolbar)
if !showingCropper {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
onCancel()
}
}
}
}
}
}
#Preview {
NavigationStack {
PhotoPickerWithCropper(
onSave: { _ in print("Saved") },
onCancel: { print("Cancelled") }
)
}
}

View File

@ -0,0 +1,215 @@
import SwiftUI
import PhotosUI
import Bedrock
/// A reusable photo source picker that shows options before accessing the camera roll.
/// Supports: photo library, camera, and remove (if photo exists).
/// Can be extended with additional custom options per use case.
struct PhotoSourcePicker: View {
@Environment(\.dismiss) private var dismiss
let title: String
let hasExistingPhoto: Bool
let additionalOptions: [PhotoSourceOption]
let onSelectFromLibrary: () -> Void
let onTakePhoto: () -> Void
let onRemovePhoto: (() -> Void)?
let onOptionSelected: ((PhotoSourceOption) -> Void)?
init(
title: String = "Add photo",
hasExistingPhoto: Bool = false,
additionalOptions: [PhotoSourceOption] = [],
onSelectFromLibrary: @escaping () -> Void,
onTakePhoto: @escaping () -> Void,
onRemovePhoto: (() -> Void)? = nil,
onOptionSelected: ((PhotoSourceOption) -> Void)? = nil
) {
self.title = title
self.hasExistingPhoto = hasExistingPhoto
self.additionalOptions = additionalOptions
self.onSelectFromLibrary = onSelectFromLibrary
self.onTakePhoto = onTakePhoto
self.onRemovePhoto = onRemovePhoto
self.onOptionSelected = onOptionSelected
}
private var optionCount: Int {
var count = 2 // Library + Camera
count += additionalOptions.count
if hasExistingPhoto { count += 1 } // Remove option
return count
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Options list
VStack(spacing: 0) {
// Select from photo library
OptionRow(
icon: "photo.on.rectangle",
title: String.localized("Select from photo library"),
action: {
onSelectFromLibrary()
dismiss()
}
)
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
// Take photo
OptionRow(
icon: "camera",
title: String.localized("Take photo"),
action: {
onTakePhoto()
dismiss()
}
)
// Additional custom options
ForEach(additionalOptions) { option in
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
OptionRow(
icon: option.icon,
title: option.title,
action: {
onOptionSelected?(option)
dismiss()
}
)
}
// Remove photo (if exists)
if hasExistingPhoto, let onRemovePhoto {
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
OptionRow(
icon: "trash",
title: String.localized("Remove photo"),
isDestructive: true,
action: {
onRemovePhoto()
dismiss()
}
)
}
}
.background(Color.AppBackground.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
Spacer()
}
.background(Color.AppBackground.secondary)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.body.bold())
.foregroundStyle(Color.Text.primary)
}
}
}
}
.presentationDetents([.height(CGFloat(optionCount * 56 + 100))])
.presentationDragIndicator(.visible)
}
}
// MARK: - Option Row
private struct OptionRow: View {
let icon: String
let title: String
var isDestructive: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: icon)
.font(.body)
.foregroundStyle(isDestructive ? Color.red : Color.Text.secondary)
.frame(width: Design.CardSize.socialIconSize)
Text(title)
.font(.body)
.foregroundStyle(isDestructive ? Color.red : Color.Text.primary)
Spacer()
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - Photo Source Option
/// A custom option that can be added to the photo source picker.
struct PhotoSourceOption: Identifiable {
let id = UUID()
let icon: String
let title: String
let action: String // Identifier for handling the action
init(icon: String, title: String, action: String) {
self.icon = icon
self.title = title
self.action = action
}
}
// MARK: - Common Option Presets
extension PhotoSourceOption {
/// Option to use a default avatar icon
static let useIcon = PhotoSourceOption(
icon: "person.crop.circle",
title: String.localized("Use icon instead"),
action: "useIcon"
)
/// Option to choose from stock photos
static let stockPhotos = PhotoSourceOption(
icon: "photo.stack",
title: String.localized("Choose from stock photos"),
action: "stockPhotos"
)
/// Option to import from Files
static let importFromFiles = PhotoSourceOption(
icon: "folder",
title: String.localized("Import from Files"),
action: "importFromFiles"
)
}
// MARK: - Preview
#Preview {
Text("Tap to show picker")
.sheet(isPresented: .constant(true)) {
PhotoSourcePicker(
title: "Add profile picture",
hasExistingPhoto: true,
additionalOptions: [],
onSelectFromLibrary: { print("Library") },
onTakePhoto: { print("Camera") },
onRemovePhoto: { print("Remove") }
)
}
}

View File

@ -1,6 +1,7 @@
import SwiftUI
import Bedrock
import SwiftData
import PhotosUI
struct ContactDetailView: View {
@Environment(AppState.self) private var appState
@ -14,12 +15,28 @@ struct ContactDetailView: View {
@State private var showingAddNote = false
@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 {
case library
case camera
}
var body: some View {
ZStack(alignment: .bottom) {
ScrollView {
VStack(spacing: 0) {
// Header banner
ContactBannerView(contact: contact)
// Header banner with photo
ContactBannerView(
contact: contact,
onEditPhoto: { showingPhotoSourcePicker = true }
)
// Content
VStack(alignment: .leading, spacing: Design.Spacing.large) {
@ -169,6 +186,67 @@ struct ContactDetailView: View {
.sheet(isPresented: $showingAddNote) {
AddNoteSheet(notes: $contact.notes)
}
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
guard let action = pendingAction else { return }
pendingAction = nil
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
switch action {
case .library:
showingPhotoPicker = true
case .camera:
showingCamera = true
}
}
}) {
PhotoSourcePicker(
title: String.localized("Add profile picture"),
hasExistingPhoto: contact.photoData != nil,
onSelectFromLibrary: {
pendingAction = .library
},
onTakePhoto: {
pendingAction = .camera
},
onRemovePhoto: {
contact.photoData = nil
}
)
}
.fullScreenCover(isPresented: $showingPhotoPicker) {
NavigationStack {
PhotoPickerWithCropper(
onSave: { croppedData in
contact.photoData = croppedData
showingPhotoPicker = false
},
onCancel: {
showingPhotoPicker = false
}
)
}
}
.fullScreenCover(isPresented: $showingCamera) {
CameraCaptureView { imageData in
if let imageData {
pendingPhotoData = imageData
showingPhotoCropper = true
}
showingCamera = false
}
}
.fullScreenCover(isPresented: $showingPhotoCropper) {
if let pendingPhotoData {
PhotoCropperSheet(imageData: pendingPhotoData) { croppedData in
if let croppedData {
contact.photoData = croppedData
}
self.pendingPhotoData = nil
showingPhotoCropper = false
}
}
}
}
private func openURL(_ urlString: String) {
@ -199,6 +277,7 @@ struct ContactDetailView: View {
private struct ContactBannerView: View {
let contact: Contact
let onEditPhoto: () -> Void
private var initials: String {
let parts = contact.name.split(separator: " ")
@ -212,6 +291,21 @@ private struct ContactBannerView: View {
var body: some View {
ZStack {
// Background: photo or gradient
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(height: Design.CardSize.bannerHeight * 1.5)
.clipped()
.overlay(
LinearGradient(
colors: [.clear, .black.opacity(Design.Opacity.light)],
startPoint: .top,
endPoint: .bottom
)
)
} else {
// Gradient background
LinearGradient(
colors: [
@ -237,6 +331,27 @@ private struct ContactBannerView: View {
}
.foregroundStyle(Color.white.opacity(Design.Opacity.accent))
}
// Edit photo button
VStack {
HStack {
Spacer()
Button(action: onEditPhoto) {
Image(systemName: contact.photoData == nil ? "camera.fill" : "pencil")
.font(.body)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial)
.clipShape(.circle)
}
.buttonStyle(.plain)
.accessibilityLabel(contact.photoData == nil ? String.localized("Add photo") : String.localized("Change photo"))
}
.padding(Design.Spacing.large)
.padding(.top, Design.Spacing.xLarge)
Spacer()
}
}
.frame(height: Design.CardSize.bannerHeight * 1.5)
}
}

View File

@ -1,11 +1,26 @@
import SwiftUI
import SwiftData
import Bedrock
import PhotosUI
struct AddContactSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
// 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 {
case library
case camera
}
// Name fields
@State private var firstName = ""
@State private var lastName = ""
@ -37,6 +52,16 @@ struct AddContactSheet: View {
var body: some View {
NavigationStack {
Form {
// Photo section
Section {
ContactPhotoRow(
photoData: $photoData,
onTap: { showingPhotoSourcePicker = true }
)
} header: {
Text("Photo")
}
// Name section
Section {
TextField(String.localized("First name"), text: $firstName)
@ -134,6 +159,67 @@ struct AddContactSheet: View {
}
.navigationTitle(String.localized("New contact"))
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
guard let action = pendingAction else { return }
pendingAction = nil
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
switch action {
case .library:
showingPhotoPicker = true
case .camera:
showingCamera = true
}
}
}) {
PhotoSourcePicker(
title: String.localized("Add profile picture"),
hasExistingPhoto: photoData != nil,
onSelectFromLibrary: {
pendingAction = .library
},
onTakePhoto: {
pendingAction = .camera
},
onRemovePhoto: {
photoData = nil
}
)
}
.fullScreenCover(isPresented: $showingPhotoPicker) {
NavigationStack {
PhotoPickerWithCropper(
onSave: { croppedData in
photoData = croppedData
showingPhotoPicker = false
},
onCancel: {
showingPhotoPicker = false
}
)
}
}
.fullScreenCover(isPresented: $showingCamera) {
CameraCaptureView { imageData in
if let imageData {
pendingPhotoData = imageData
showingPhotoCropper = true
}
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) {
Button(String.localized("Cancel")) {
@ -181,12 +267,61 @@ struct AddContactSheet: View {
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
contactFields: contactFields
contactFields: contactFields,
photoData: photoData
)
dismiss()
}
}
// MARK: - Contact Photo Row
private struct ContactPhotoRow: View {
@Binding var photoData: Data?
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Design.Spacing.medium) {
// Photo preview
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: Design.BaseFontSize.display))
.foregroundStyle(Color.Text.tertiary)
}
}
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
.clipShape(.circle)
.overlay(Circle().stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin))
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text("Profile Photo")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
Text(photoData == nil ? String.localized("Add a photo") : String.localized("Tap to change"))
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.padding(.vertical, Design.Spacing.xSmall)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - Labeled Entry Model
private struct LabeledEntry: Identifiable {

View File

@ -0,0 +1,244 @@
import SwiftUI
import Bedrock
/// A sheet that allows the user to crop an image to a square.
/// Supports pinch-to-zoom and drag gestures for positioning.
struct PhotoCropperSheet: View {
@Environment(\.dismiss) private var dismiss
let imageData: Data
let onComplete: (Data?) -> Void // nil = cancelled, Data = saved
let shouldDismissOnComplete: Bool
init(imageData: Data, shouldDismissOnComplete: Bool = true, onComplete: @escaping (Data?) -> Void) {
self.imageData = imageData
self.shouldDismissOnComplete = shouldDismissOnComplete
self.onComplete = onComplete
}
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
@State private var containerSize: CGSize = .zero
// Crop area size (square)
private let cropSize: CGFloat = 280
private var uiImage: UIImage? {
UIImage(data: imageData)
}
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
// Dark background
Color.black.ignoresSafeArea()
// Image with gestures
if let uiImage {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.scaleEffect(scale)
.offset(offset)
.gesture(dragGesture)
.gesture(magnificationGesture)
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
// Overlay with crop area cutout
CropOverlay(cropSize: cropSize, containerSize: geometry.size)
// Grid lines inside crop area
CropGridLines(cropSize: cropSize)
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
}
.foregroundStyle(.white)
}
ToolbarItem(placement: .principal) {
Button {
resetTransform()
} label: {
Image(systemName: "arrow.counterclockwise")
.foregroundStyle(.white)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Done")) {
cropAndSave()
}
.bold()
.foregroundStyle(.white)
}
}
}
.preferredColorScheme(.dark)
}
// MARK: - Gestures
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
}
private var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = lastScale * value
// Limit scale between 0.5x and 5x
scale = min(max(newScale, 0.5), 5.0)
}
.onEnded { _ in
lastScale = scale
}
}
// MARK: - Actions
private func resetTransform() {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
scale = 1.0
lastScale = 1.0
offset = .zero
lastOffset = .zero
}
}
private func cropAndSave() {
guard let uiImage else {
onComplete(nil)
if shouldDismissOnComplete {
dismiss()
}
return
}
// Get the screen's main bounds for rendering
let screenScale = UIScreen.main.scale
// 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)
}
} else {
onComplete(nil)
}
if shouldDismissOnComplete {
dismiss()
}
}
@MainActor
private func croppedImageView(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.scaledToFill()
.scaleEffect(scale)
.offset(offset)
.frame(width: cropSize, height: cropSize)
.clipped()
}
}
// MARK: - Crop Overlay
private struct CropOverlay: View {
let cropSize: CGFloat
let containerSize: CGSize
var body: some View {
ZStack {
// Semi-transparent overlay
Rectangle()
.fill(Color.black.opacity(Design.Opacity.accent))
// Clear square in center
Rectangle()
.fill(Color.clear)
.frame(width: cropSize, height: cropSize)
.blendMode(.destinationOut)
}
.compositingGroup()
.allowsHitTesting(false)
// Border around crop area
Rectangle()
.stroke(Color.white, lineWidth: Design.LineWidth.thin)
.frame(width: cropSize, height: cropSize)
.allowsHitTesting(false)
}
}
// MARK: - Crop Grid Lines
private struct CropGridLines: View {
let cropSize: CGFloat
var body: some View {
ZStack {
// Vertical lines (rule of thirds)
HStack(spacing: cropSize / 3 - Design.LineWidth.thin) {
ForEach(0..<2, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(Design.Opacity.light))
.frame(width: Design.LineWidth.thin, height: cropSize)
}
}
// Horizontal lines (rule of thirds)
VStack(spacing: cropSize / 3 - Design.LineWidth.thin) {
ForEach(0..<2, id: \.self) { _ in
Rectangle()
.fill(Color.white.opacity(Design.Opacity.light))
.frame(width: cropSize, height: Design.LineWidth.thin)
}
}
}
.frame(width: cropSize, height: cropSize)
.allowsHitTesting(false)
}
}
// MARK: - Preview
#Preview {
// Create a sample image for preview
let sampleImage = UIImage(systemName: "person.fill")!
let sampleData = sampleImage.pngData()!
return PhotoCropperSheet(imageData: sampleData) { data in
print(data != nil ? "Saved" : "Cancelled")
}
}

View File

@ -17,9 +17,10 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
- Tap the **plus icon** to create a new card
- Set a default card for sharing
- **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
- **Profile photos**: Add a headshot from your library or use an icon
- **Cover photos**: Add a custom banner background image
- **Company logos**: Upload a logo to overlay on your card's banner
- **Profile photos**: Add a headshot from library or camera with crop/zoom editor
- **Cover photos**: Add a custom banner background from library or camera
- **Company logos**: Upload a logo from library or camera
- **3-step photo workflow**: Choose source (library/camera) → crop/position → save
- **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app
@ -69,6 +70,7 @@ Each field has:
### Contacts
- **Add contacts manually**: Tap + to create contacts with name, role, company, email, phone
- **Profile photos**: Add or edit a photo for each contact
- Track who you've shared your card with
- **Scan QR codes** to save someone else's business card
- **Notes & annotations**: Add notes about each contact

View File

@ -76,7 +76,7 @@ App-specific extensions are in `Design/DesignConstants.swift`:
- Basic fields: name, role, company
- Annotations: notes, tags (comma-separated), followUpDate, whereYouMet
- Received cards: isReceivedCard, email, phone
- Photo: `photoData`
- Photo: `photoData` stored with `@Attribute(.externalStorage)` - editable via PhotosPicker in ContactDetailView and AddContactSheet
- Computed: `tagList`, `hasFollowUp`, `isFollowUpOverdue`
- Static: `fromVCard(_:)` parser
@ -125,11 +125,14 @@ Reusable components (in `Views/Components/`):
- `ContactFieldPickerView.swift` — grid picker for selecting contact field types
- `ContactFieldsManagerView.swift` — orchestrates picker + added fields list
- `AddedContactFieldsView.swift` — displays added fields with drag-to-reorder
- `PhotoSourcePicker.swift` — generic photo source picker sheet (library, camera, remove)
- `CameraCaptureView.swift` — UIImagePickerController wrapper for camera capture
Sheets (in `Views/Sheets/`):
- `RecordContactSheet.swift` — track share recipient
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
- `AddContactSheet.swift` — manually add a new contact
- `PhotoCropperSheet.swift` — 2-step photo editor with pinch-to-zoom and square crop
Small utilities:
- `Views/EmptyStateView.swift` — empty state placeholder