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(
|
static let youtube = ContactFieldType(
|
||||||
id: "youtube",
|
id: "youtube",
|
||||||
displayName: "YouTube",
|
displayName: "YouTube",
|
||||||
systemImage: "youtube.fill",
|
systemImage: "youtube",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 1.0, green: 0.0, blue: 0.0),
|
iconColor: Color(red: 1.0, green: 0.0, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -342,7 +343,7 @@ extension ContactFieldType {
|
|||||||
static let mastodon = ContactFieldType(
|
static let mastodon = ContactFieldType(
|
||||||
id: "mastodon",
|
id: "mastodon",
|
||||||
displayName: "Mastodon",
|
displayName: "Mastodon",
|
||||||
systemImage: "mastodon.fill",
|
systemImage: "mastodon",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
|
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
|
||||||
category: .social,
|
category: .social,
|
||||||
@ -365,7 +366,7 @@ extension ContactFieldType {
|
|||||||
static let reddit = ContactFieldType(
|
static let reddit = ContactFieldType(
|
||||||
id: "reddit",
|
id: "reddit",
|
||||||
displayName: "Reddit",
|
displayName: "Reddit",
|
||||||
systemImage: "reddit.fill",
|
systemImage: "reddit",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
|
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
@ -474,7 +475,7 @@ extension ContactFieldType {
|
|||||||
static let discord = ContactFieldType(
|
static let discord = ContactFieldType(
|
||||||
id: "discord",
|
id: "discord",
|
||||||
displayName: "Discord",
|
displayName: "Discord",
|
||||||
systemImage: "discord.fill",
|
systemImage: "discord",
|
||||||
isCustomSymbol: true,
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
|
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
|
|||||||
@ -167,9 +167,6 @@
|
|||||||
},
|
},
|
||||||
"Contact Fields" : {
|
"Contact Fields" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Cover photo" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Create multiple business cards" : {
|
"Create multiple business cards" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -314,6 +311,9 @@
|
|||||||
},
|
},
|
||||||
"Link" : {
|
"Link" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Links" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Maiden Name" : {
|
"Maiden Name" : {
|
||||||
|
|
||||||
@ -372,6 +372,9 @@
|
|||||||
},
|
},
|
||||||
"Personal details" : {
|
"Personal details" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Phone" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Phone Number" : {
|
"Phone Number" : {
|
||||||
|
|
||||||
@ -400,6 +403,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Photo" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Please allow camera access in Settings to scan QR codes." : {
|
"Please allow camera access in Settings to scan QR codes." : {
|
||||||
|
|
||||||
@ -443,6 +449,9 @@
|
|||||||
},
|
},
|
||||||
"Profile Link" : {
|
"Profile Link" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Profile Photo" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Pronouns (e.g. she/her)" : {
|
"Pronouns (e.g. she/her)" : {
|
||||||
|
|
||||||
|
|||||||
@ -85,7 +85,8 @@ final class ContactsStore: ContactTracking {
|
|||||||
notes: String = "",
|
notes: String = "",
|
||||||
tags: String = "",
|
tags: String = "",
|
||||||
followUpDate: Date? = nil,
|
followUpDate: Date? = nil,
|
||||||
contactFields: [ContactField] = []
|
contactFields: [ContactField] = [],
|
||||||
|
photoData: Data? = nil
|
||||||
) {
|
) {
|
||||||
let contact = Contact(
|
let contact = Contact(
|
||||||
name: name,
|
name: name,
|
||||||
@ -96,7 +97,8 @@ final class ContactsStore: ContactTracking {
|
|||||||
tags: tags,
|
tags: tags,
|
||||||
followUpDate: followUpDate,
|
followUpDate: followUpDate,
|
||||||
email: email,
|
email: email,
|
||||||
phone: phone
|
phone: phone,
|
||||||
|
photoData: photoData
|
||||||
)
|
)
|
||||||
modelContext.insert(contact)
|
modelContext.insert(contact)
|
||||||
|
|
||||||
|
|||||||
@ -40,15 +40,42 @@ struct CardEditorView: View {
|
|||||||
@State private var selectedLayout: CardLayoutStyle = .stacked
|
@State private var selectedLayout: CardLayoutStyle = .stacked
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
@State private var selectedPhoto: PhotosPickerItem?
|
|
||||||
@State private var photoData: Data?
|
@State private var photoData: Data?
|
||||||
@State private var selectedCoverPhoto: PhotosPickerItem?
|
|
||||||
@State private var coverPhotoData: Data?
|
@State private var coverPhotoData: Data?
|
||||||
@State private var selectedLogo: PhotosPickerItem?
|
|
||||||
@State private var logoData: Data?
|
@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
|
@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 isEditing: Bool { card != nil }
|
||||||
private var isFormValid: Bool {
|
private var isFormValid: Bool {
|
||||||
!effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!effectiveDisplayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
@ -90,14 +117,14 @@ struct CardEditorView: View {
|
|||||||
// Images & Layout section
|
// Images & Layout section
|
||||||
Section {
|
Section {
|
||||||
ImageLayoutRow(
|
ImageLayoutRow(
|
||||||
selectedPhoto: $selectedPhoto,
|
|
||||||
photoData: $photoData,
|
photoData: $photoData,
|
||||||
selectedCoverPhoto: $selectedCoverPhoto,
|
|
||||||
coverPhotoData: $coverPhotoData,
|
coverPhotoData: $coverPhotoData,
|
||||||
selectedLogo: $selectedLogo,
|
|
||||||
logoData: $logoData,
|
logoData: $logoData,
|
||||||
avatarSystemName: avatarSystemName,
|
avatarSystemName: avatarSystemName,
|
||||||
selectedTheme: selectedTheme
|
selectedTheme: selectedTheme,
|
||||||
|
onSelectImage: { imageType in
|
||||||
|
pendingImageType = imageType
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Images & layout")
|
Text("Images & layout")
|
||||||
@ -190,24 +217,71 @@ struct CardEditorView: View {
|
|||||||
.disabled(!isFormValid)
|
.disabled(!isFormValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedPhoto) { _, newValue in
|
.sheet(item: $pendingImageType, onDismiss: {
|
||||||
Task {
|
// After source picker dismisses, show the appropriate picker
|
||||||
if let data = try? await newValue?.loadTransferable(type: Data.self) {
|
guard let action = pendingAction else { return }
|
||||||
photoData = data
|
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
|
||||||
.onChange(of: selectedCoverPhoto) { _, newValue in
|
PhotoSourcePicker(
|
||||||
Task {
|
title: imageType.title,
|
||||||
if let data = try? await newValue?.loadTransferable(type: Data.self) {
|
hasExistingPhoto: hasExistingPhoto(for: imageType),
|
||||||
coverPhotoData = data
|
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
|
.fullScreenCover(isPresented: $showingCamera) {
|
||||||
Task {
|
CameraCaptureView { imageData in
|
||||||
if let data = try? await newValue?.loadTransferable(type: Data.self) {
|
if let imageData {
|
||||||
logoData = data
|
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
|
// MARK: - Image Layout Row
|
||||||
|
|
||||||
private struct ImageLayoutRow: View {
|
private struct ImageLayoutRow: View {
|
||||||
@Binding var selectedPhoto: PhotosPickerItem?
|
|
||||||
@Binding var photoData: Data?
|
@Binding var photoData: Data?
|
||||||
@Binding var selectedCoverPhoto: PhotosPickerItem?
|
|
||||||
@Binding var coverPhotoData: Data?
|
@Binding var coverPhotoData: Data?
|
||||||
@Binding var selectedLogo: PhotosPickerItem?
|
|
||||||
@Binding var logoData: Data?
|
@Binding var logoData: Data?
|
||||||
let avatarSystemName: String
|
let avatarSystemName: String
|
||||||
let selectedTheme: CardTheme
|
let selectedTheme: CardTheme
|
||||||
|
let onSelectImage: (CardEditorView.ImageType) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
@ -270,15 +342,17 @@ private struct ImageLayoutRow: View {
|
|||||||
coverPhotoData: coverPhotoData,
|
coverPhotoData: coverPhotoData,
|
||||||
logoData: logoData,
|
logoData: logoData,
|
||||||
selectedTheme: selectedTheme,
|
selectedTheme: selectedTheme,
|
||||||
selectedCoverPhoto: $selectedCoverPhoto,
|
onEditCover: { onSelectImage(.cover) },
|
||||||
selectedLogo: $selectedLogo
|
onEditLogo: { onSelectImage(.logo) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile photo with edit button
|
// Profile photo with edit button
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
|
ProfilePhotoView(photoData: photoData, avatarSystemName: avatarSystemName, theme: selectedTheme)
|
||||||
|
|
||||||
PhotosPicker(selection: $selectedPhoto, matching: .images) {
|
Button {
|
||||||
|
onSelectImage(.profile)
|
||||||
|
} label: {
|
||||||
Image(systemName: "pencil")
|
Image(systemName: "pencil")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(Design.Spacing.xSmall)
|
.padding(Design.Spacing.xSmall)
|
||||||
@ -296,9 +370,7 @@ private struct ImageLayoutRow: View {
|
|||||||
photoData: $photoData,
|
photoData: $photoData,
|
||||||
coverPhotoData: $coverPhotoData,
|
coverPhotoData: $coverPhotoData,
|
||||||
logoData: $logoData,
|
logoData: $logoData,
|
||||||
selectedPhoto: $selectedPhoto,
|
onSelectImage: onSelectImage
|
||||||
selectedCoverPhoto: $selectedCoverPhoto,
|
|
||||||
selectedLogo: $selectedLogo
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -310,11 +382,11 @@ private struct BannerPreviewView: View {
|
|||||||
let coverPhotoData: Data?
|
let coverPhotoData: Data?
|
||||||
let logoData: Data?
|
let logoData: Data?
|
||||||
let selectedTheme: CardTheme
|
let selectedTheme: CardTheme
|
||||||
@Binding var selectedCoverPhoto: PhotosPickerItem?
|
let onEditCover: () -> Void
|
||||||
@Binding var selectedLogo: PhotosPickerItem?
|
let onEditLogo: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background: cover photo or gradient
|
// Background: cover photo or gradient
|
||||||
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
@ -323,26 +395,26 @@ private struct BannerPreviewView: View {
|
|||||||
.frame(height: Design.CardSize.bannerHeight)
|
.frame(height: Design.CardSize.bannerHeight)
|
||||||
.clipped()
|
.clipped()
|
||||||
} else {
|
} else {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
|
colors: [selectedTheme.primaryColor, selectedTheme.secondaryColor],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Company logo overlay
|
// Company logo overlay
|
||||||
if let logoData, let uiImage = UIImage(data: logoData) {
|
if let logoData, let uiImage = UIImage(data: logoData) {
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(height: Design.CardSize.logoSize)
|
.frame(height: Design.CardSize.logoSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit buttons overlay
|
// Edit buttons overlay
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
// Edit cover photo button (top-left)
|
// Edit cover photo button (top-left)
|
||||||
PhotosPicker(selection: $selectedCoverPhoto, matching: .images) {
|
Button(action: onEditCover) {
|
||||||
Image(systemName: "photo")
|
Image(systemName: "photo")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(Design.Spacing.small)
|
.padding(Design.Spacing.small)
|
||||||
@ -352,25 +424,25 @@ private struct BannerPreviewView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(String.localized("Edit cover photo"))
|
.accessibilityLabel(String.localized("Edit cover photo"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Edit logo button (top-right)
|
// Edit logo button (top-right)
|
||||||
PhotosPicker(selection: $selectedLogo, matching: .images) {
|
Button(action: onEditLogo) {
|
||||||
Image(systemName: "building.2")
|
Image(systemName: "building.2")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(Design.Spacing.small)
|
.padding(Design.Spacing.small)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
.clipShape(.circle)
|
.clipShape(.circle)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(String.localized("Edit company logo"))
|
.accessibilityLabel(String.localized("Edit company logo"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.small)
|
||||||
}
|
}
|
||||||
Spacer()
|
.frame(height: Design.CardSize.bannerHeight)
|
||||||
}
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
.padding(Design.Spacing.small)
|
|
||||||
}
|
|
||||||
.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 photoData: Data?
|
||||||
@Binding var coverPhotoData: Data?
|
@Binding var coverPhotoData: Data?
|
||||||
@Binding var logoData: Data?
|
@Binding var logoData: Data?
|
||||||
@Binding var selectedPhoto: PhotosPickerItem?
|
let onSelectImage: (CardEditorView.ImageType) -> Void
|
||||||
@Binding var selectedCoverPhoto: PhotosPickerItem?
|
|
||||||
@Binding var selectedLogo: PhotosPickerItem?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
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"),
|
subtitle: photoData == nil ? String.localized("Add your headshot") : String.localized("Change or remove"),
|
||||||
systemImage: "person.crop.circle",
|
systemImage: "person.crop.circle",
|
||||||
hasImage: photoData != nil,
|
hasImage: photoData != nil,
|
||||||
selection: $selectedPhoto,
|
onTap: { onSelectImage(.profile) },
|
||||||
onRemove: { photoData = nil }
|
onRemove: { photoData = nil }
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -402,7 +472,7 @@ private struct ImageActionButtonsRow: View {
|
|||||||
subtitle: coverPhotoData == nil ? String.localized("Add banner background") : String.localized("Change or remove"),
|
subtitle: coverPhotoData == nil ? String.localized("Add banner background") : String.localized("Change or remove"),
|
||||||
systemImage: "photo.fill",
|
systemImage: "photo.fill",
|
||||||
hasImage: coverPhotoData != nil,
|
hasImage: coverPhotoData != nil,
|
||||||
selection: $selectedCoverPhoto,
|
onTap: { onSelectImage(.cover) },
|
||||||
onRemove: { coverPhotoData = nil }
|
onRemove: { coverPhotoData = nil }
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -412,7 +482,7 @@ private struct ImageActionButtonsRow: View {
|
|||||||
subtitle: logoData == nil ? String.localized("Add your logo") : String.localized("Change or remove"),
|
subtitle: logoData == nil ? String.localized("Add your logo") : String.localized("Change or remove"),
|
||||||
systemImage: "building.2",
|
systemImage: "building.2",
|
||||||
hasImage: logoData != nil,
|
hasImage: logoData != nil,
|
||||||
selection: $selectedLogo,
|
onTap: { onSelectImage(.logo) },
|
||||||
onRemove: { logoData = nil }
|
onRemove: { logoData = nil }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -426,47 +496,47 @@ private struct ImageActionRow: View {
|
|||||||
let subtitle: String
|
let subtitle: String
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
let hasImage: Bool
|
let hasImage: Bool
|
||||||
@Binding var selection: PhotosPickerItem?
|
let onTap: () -> Void
|
||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
Button(action: onTap) {
|
||||||
Image(systemName: systemImage)
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
.font(.title3)
|
Image(systemName: systemImage)
|
||||||
.foregroundStyle(hasImage ? Color.accentColor : Color.Text.secondary)
|
.font(.title3)
|
||||||
.frame(width: Design.CardSize.socialIconSize)
|
.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)
|
|
||||||
|
|
||||||
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)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
Spacer()
|
.contentShape(.rect)
|
||||||
|
|
||||||
if hasImage {
|
|
||||||
Button {
|
|
||||||
onRemove()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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
|
selectedTheme = card.theme
|
||||||
selectedLayout = card.layoutStyle
|
selectedLayout = card.layoutStyle
|
||||||
photoData = card.photoData
|
photoData = card.photoData
|
||||||
|
coverPhotoData = card.coverPhotoData
|
||||||
logoData = card.logoData
|
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() {
|
func saveCard() {
|
||||||
if let existingCard = card {
|
if let existingCard = card {
|
||||||
updateCard(existingCard)
|
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 SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct ContactDetailView: View {
|
struct ContactDetailView: View {
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@ -14,12 +15,28 @@ struct ContactDetailView: View {
|
|||||||
@State private var showingAddNote = false
|
@State private var showingAddNote = false
|
||||||
@State private var newTag = ""
|
@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 {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header banner
|
// Header banner with photo
|
||||||
ContactBannerView(contact: contact)
|
ContactBannerView(
|
||||||
|
contact: contact,
|
||||||
|
onEditPhoto: { showingPhotoSourcePicker = true }
|
||||||
|
)
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
@ -169,6 +186,67 @@ struct ContactDetailView: View {
|
|||||||
.sheet(isPresented: $showingAddNote) {
|
.sheet(isPresented: $showingAddNote) {
|
||||||
AddNoteSheet(notes: $contact.notes)
|
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) {
|
private func openURL(_ urlString: String) {
|
||||||
@ -199,6 +277,7 @@ struct ContactDetailView: View {
|
|||||||
|
|
||||||
private struct ContactBannerView: View {
|
private struct ContactBannerView: View {
|
||||||
let contact: Contact
|
let contact: Contact
|
||||||
|
let onEditPhoto: () -> Void
|
||||||
|
|
||||||
private var initials: String {
|
private var initials: String {
|
||||||
let parts = contact.name.split(separator: " ")
|
let parts = contact.name.split(separator: " ")
|
||||||
@ -212,6 +291,21 @@ private struct ContactBannerView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
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
|
// Gradient background
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
@ -236,6 +330,27 @@ private struct ContactBannerView: View {
|
|||||||
.font(.system(size: Design.BaseFontSize.display, weight: .light))
|
.font(.system(size: Design.BaseFontSize.display, weight: .light))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.white.opacity(Design.Opacity.accent))
|
.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)
|
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,26 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
struct AddContactSheet: View {
|
struct AddContactSheet: View {
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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
|
// Name fields
|
||||||
@State private var firstName = ""
|
@State private var firstName = ""
|
||||||
@State private var lastName = ""
|
@State private var lastName = ""
|
||||||
@ -37,6 +52,16 @@ struct AddContactSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
// Photo section
|
||||||
|
Section {
|
||||||
|
ContactPhotoRow(
|
||||||
|
photoData: $photoData,
|
||||||
|
onTap: { showingPhotoSourcePicker = true }
|
||||||
|
)
|
||||||
|
} header: {
|
||||||
|
Text("Photo")
|
||||||
|
}
|
||||||
|
|
||||||
// Name section
|
// Name section
|
||||||
Section {
|
Section {
|
||||||
TextField(String.localized("First name"), text: $firstName)
|
TextField(String.localized("First name"), text: $firstName)
|
||||||
@ -134,6 +159,67 @@ struct AddContactSheet: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(String.localized("New contact"))
|
.navigationTitle(String.localized("New contact"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button(String.localized("Cancel")) {
|
Button(String.localized("Cancel")) {
|
||||||
@ -181,12 +267,61 @@ struct AddContactSheet: View {
|
|||||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
contactFields: contactFields
|
contactFields: contactFields,
|
||||||
|
photoData: photoData
|
||||||
)
|
)
|
||||||
dismiss()
|
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
|
// MARK: - Labeled Entry Model
|
||||||
|
|
||||||
private struct LabeledEntry: Identifiable {
|
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
|
- Tap the **plus icon** to create a new card
|
||||||
- Set a default card for sharing
|
- Set a default card for sharing
|
||||||
- **Modern card design**: Banner with optional cover photo, company logo, overlapping profile photo, clean contact rows
|
- **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
|
- **Profile photos**: Add a headshot from library or camera with crop/zoom editor
|
||||||
- **Cover photos**: Add a custom banner background image
|
- **Cover photos**: Add a custom banner background from library or camera
|
||||||
- **Company logos**: Upload a logo to overlay on your card's banner
|
- **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
|
- **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
|
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ Each field has:
|
|||||||
### Contacts
|
### Contacts
|
||||||
|
|
||||||
- **Add contacts manually**: Tap + to create contacts with name, role, company, email, phone
|
- **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
|
- Track who you've shared your card with
|
||||||
- **Scan QR codes** to save someone else's business card
|
- **Scan QR codes** to save someone else's business card
|
||||||
- **Notes & annotations**: Add notes about each contact
|
- **Notes & annotations**: Add notes about each contact
|
||||||
|
|||||||
@ -76,7 +76,7 @@ App-specific extensions are in `Design/DesignConstants.swift`:
|
|||||||
- Basic fields: name, role, company
|
- Basic fields: name, role, company
|
||||||
- Annotations: notes, tags (comma-separated), followUpDate, whereYouMet
|
- Annotations: notes, tags (comma-separated), followUpDate, whereYouMet
|
||||||
- Received cards: isReceivedCard, email, phone
|
- Received cards: isReceivedCard, email, phone
|
||||||
- Photo: `photoData`
|
- Photo: `photoData` stored with `@Attribute(.externalStorage)` - editable via PhotosPicker in ContactDetailView and AddContactSheet
|
||||||
- Computed: `tagList`, `hasFollowUp`, `isFollowUpOverdue`
|
- Computed: `tagList`, `hasFollowUp`, `isFollowUpOverdue`
|
||||||
- Static: `fromVCard(_:)` parser
|
- Static: `fromVCard(_:)` parser
|
||||||
|
|
||||||
@ -125,11 +125,14 @@ Reusable components (in `Views/Components/`):
|
|||||||
- `ContactFieldPickerView.swift` — grid picker for selecting contact field types
|
- `ContactFieldPickerView.swift` — grid picker for selecting contact field types
|
||||||
- `ContactFieldsManagerView.swift` — orchestrates picker + added fields list
|
- `ContactFieldsManagerView.swift` — orchestrates picker + added fields list
|
||||||
- `AddedContactFieldsView.swift` — displays added fields with drag-to-reorder
|
- `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/`):
|
Sheets (in `Views/Sheets/`):
|
||||||
- `RecordContactSheet.swift` — track share recipient
|
- `RecordContactSheet.swift` — track share recipient
|
||||||
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
|
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
|
||||||
- `AddContactSheet.swift` — manually add a new contact
|
- `AddContactSheet.swift` — manually add a new contact
|
||||||
|
- `PhotoCropperSheet.swift` — 2-step photo editor with pinch-to-zoom and square crop
|
||||||
|
|
||||||
Small utilities:
|
Small utilities:
|
||||||
- `Views/EmptyStateView.swift` — empty state placeholder
|
- `Views/EmptyStateView.swift` — empty state placeholder
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user