Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6d9c956c06
commit
7231f50a07
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
@ -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,
|
||||
|
||||
@ -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)" : {
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
55
BusinessCard/Views/Components/CameraCaptureView.swift
Normal file
55
BusinessCard/Views/Components/CameraCaptureView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
79
BusinessCard/Views/Components/PhotoPickerWithCropper.swift
Normal file
79
BusinessCard/Views/Components/PhotoPickerWithCropper.swift
Normal 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") }
|
||||
)
|
||||
}
|
||||
}
|
||||
215
BusinessCard/Views/Components/PhotoSourcePicker.swift
Normal file
215
BusinessCard/Views/Components/PhotoSourcePicker.swift
Normal 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") }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
244
BusinessCard/Views/Sheets/PhotoCropperSheet.swift
Normal file
244
BusinessCard/Views/Sheets/PhotoCropperSheet.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user