diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/youtube.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/youtube.fill.svg rename to BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/youtube.svg b/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/youtube.svg rename to BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index c94d83b..bc05788 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -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, diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 53408e3..20a0dfd 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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)" : { diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift index c531a74..c59f7b0 100644 --- a/BusinessCard/State/ContactsStore.swift +++ b/BusinessCard/State/ContactsStore.swift @@ -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) diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index 0b74f82..f139878 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -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 } } - } - .onChange(of: selectedCoverPhoto) { _, newValue in - Task { - if let data = try? await newValue?.loadTransferable(type: Data.self) { - coverPhotoData = data + }) { imageType in + PhotoSourcePicker( + title: imageType.title, + hasExistingPhoto: hasExistingPhoto(for: imageType), + onSelectFromLibrary: { + activeImageType = imageType + pendingAction = .library + }, + onTakePhoto: { + activeImageType = imageType + pendingAction = .camera + }, + onRemovePhoto: { + removePhoto(for: imageType) } + ) + } + .fullScreenCover(isPresented: $showingPhotoPicker) { + NavigationStack { + PhotoPickerWithCropper( + onSave: { croppedData in + savePhoto(croppedData, for: activeImageType) + showingPhotoPicker = false + activeImageType = nil + }, + onCancel: { + showingPhotoPicker = false + activeImageType = nil + } + ) } } - .onChange(of: selectedLogo) { _, newValue in - Task { - if let data = try? await newValue?.loadTransferable(type: Data.self) { - logoData = data + .fullScreenCover(isPresented: $showingCamera) { + CameraCaptureView { imageData in + if let imageData { + pendingImageData = imageData + showingPhotoCropper = true + } + 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,11 +382,11 @@ 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 { + ZStack { // Background: cover photo or gradient if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { Image(uiImage: uiImage) @@ -323,26 +395,26 @@ private struct BannerPreviewView: View { .frame(height: Design.CardSize.bannerHeight) .clipped() } else { - LinearGradient( - colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + LinearGradient( + colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) } - + // Company logo overlay - if let logoData, let uiImage = UIImage(data: logoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(height: Design.CardSize.logoSize) - } - + if let logoData, let uiImage = UIImage(data: logoData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(height: Design.CardSize.logoSize) + } + // Edit buttons overlay - VStack { - HStack { + 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) @@ -352,25 +424,25 @@ private struct BannerPreviewView: View { .buttonStyle(.plain) .accessibilityLabel(String.localized("Edit cover photo")) - Spacer() + Spacer() // Edit logo button (top-right) - PhotosPicker(selection: $selectedLogo, matching: .images) { + Button(action: onEditLogo) { Image(systemName: "building.2") - .font(.caption) - .padding(Design.Spacing.small) - .background(.ultraThinMaterial) - .clipShape(.circle) - } - .buttonStyle(.plain) + .font(.caption) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial) + .clipShape(.circle) + } + .buttonStyle(.plain) .accessibilityLabel(String.localized("Edit company logo")) + } + Spacer() + } + .padding(Design.Spacing.small) } - Spacer() - } - .padding(Design.Spacing.small) - } - .frame(height: Design.CardSize.bannerHeight) - .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + .frame(height: Design.CardSize.bannerHeight) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) } } @@ -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,47 +496,47 @@ 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 { - HStack(spacing: Design.Spacing.medium) { - Image(systemName: systemImage) - .font(.title3) - .foregroundStyle(hasImage ? Color.accentColor : Color.Text.secondary) - .frame(width: Design.CardSize.socialIconSize) - - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(title) - .font(.subheadline) - .foregroundStyle(Color.Text.primary) + Button(action: onTap) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(hasImage ? Color.accentColor : Color.Text.secondary) + .frame(width: Design.CardSize.socialIconSize) - Text(subtitle) + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(title) + .font(.subheadline) + .foregroundStyle(Color.Text.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(Color.Text.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") .font(.caption) - .foregroundStyle(Color.Text.secondary) + .foregroundStyle(Color.Text.tertiary) } - - Spacer() - - if hasImage { - Button { - onRemove() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Color.Text.tertiary) + .padding(.vertical, Design.Spacing.xSmall) + .contentShape(.rect) } .buttonStyle(.plain) - .accessibilityLabel(String.localized("Remove \(title.lowercased())")) + .accessibilityLabel("\(title): \(subtitle)") + .contextMenu { + if hasImage { + Button(role: .destructive) { + onRemove() + } label: { + Label(String.localized("Remove"), systemImage: "trash") + } } - - 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) } } @@ -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) diff --git a/BusinessCard/Views/Components/CameraCaptureView.swift b/BusinessCard/Views/Components/CameraCaptureView.swift new file mode 100644 index 0000000..e9e483a --- /dev/null +++ b/BusinessCard/Views/Components/CameraCaptureView.swift @@ -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) + } +} diff --git a/BusinessCard/Views/Components/PhotoPickerWithCropper.swift b/BusinessCard/Views/Components/PhotoPickerWithCropper.swift new file mode 100644 index 0000000..a15d314 --- /dev/null +++ b/BusinessCard/Views/Components/PhotoPickerWithCropper.swift @@ -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") } + ) + } +} diff --git a/BusinessCard/Views/Components/PhotoSourcePicker.swift b/BusinessCard/Views/Components/PhotoSourcePicker.swift new file mode 100644 index 0000000..75da050 --- /dev/null +++ b/BusinessCard/Views/Components/PhotoSourcePicker.swift @@ -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") } + ) + } +} diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift index 42ba798..2f62b6a 100644 --- a/BusinessCard/Views/ContactDetailView.swift +++ b/BusinessCard/Views/ContactDetailView.swift @@ -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: [ @@ -236,6 +330,27 @@ private struct ContactBannerView: View { .font(.system(size: Design.BaseFontSize.display, weight: .light)) } .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) } diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Sheets/AddContactSheet.swift index ed14907..df84873 100644 --- a/BusinessCard/Views/Sheets/AddContactSheet.swift +++ b/BusinessCard/Views/Sheets/AddContactSheet.swift @@ -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 { diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift new file mode 100644 index 0000000..23ce34b --- /dev/null +++ b/BusinessCard/Views/Sheets/PhotoCropperSheet.swift @@ -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") + } +} diff --git a/README.md b/README.md index f912c8f..c284b69 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ai_implmentation.md b/ai_implmentation.md index 2cb8049..3d65330 100644 --- a/ai_implmentation.md +++ b/ai_implmentation.md @@ -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