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

This commit is contained in:
Matt Bruce 2026-01-09 15:39:04 -06:00
parent 92c941d7aa
commit 4fe7fde9b9
6 changed files with 434 additions and 192 deletions

View File

@ -531,13 +531,13 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist; INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes"; INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -567,13 +567,13 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist; INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes"; INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSCameraUsageDescription</key>
<string>BusinessCard uses the camera to scan QR codes on other people's business cards.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>BusinessCard uses your photo library to add a profile photo to your business card.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>

View File

@ -361,6 +361,142 @@ final class BusinessCard {
return lines.joined(separator: "\r\n") return lines.joined(separator: "\r\n")
} }
/// Generates a vCard payload with embedded profile photo for file-based sharing.
/// This payload is too large for QR codes but works perfectly for AirDrop,
/// Messages, Email attachments, and other file-based sharing methods.
@MainActor
var vCardFilePayload: String {
var lines = [
"BEGIN:VCARD",
"VERSION:3.0"
]
// N: Structured name - REQUIRED for proper contact import
let structuredName = [lastName, firstName, middleName, prefix, suffix]
.map { escapeVCardValue($0) }
.joined(separator: ";")
lines.append("N:\(structuredName)")
// FN: Formatted name
let formattedName = simpleName.isEmpty ? displayName : simpleName
lines.append("FN:\(escapeVCardValue(formattedName))")
// PHOTO: Embedded profile photo as base64-encoded JPEG
if let photoData {
let base64Photo = photoData.base64EncodedString()
lines.append("PHOTO;ENCODING=b;TYPE=JPEG:\(base64Photo)")
}
// NICKNAME: Preferred name
if !preferredName.isEmpty {
lines.append("NICKNAME:\(escapeVCardValue(preferredName))")
}
// ORG: Organization
if !company.isEmpty {
if !department.isEmpty {
lines.append("ORG:\(escapeVCardValue(company));\(escapeVCardValue(department))")
} else {
lines.append("ORG:\(escapeVCardValue(company))")
}
}
// TITLE: Job title/role
if !role.isEmpty {
lines.append("TITLE:\(escapeVCardValue(role))")
}
// Contact fields from the array
for field in orderedContactFields {
let value = field.value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
switch field.typeId {
case "email":
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
lines.append("EMAIL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
case "phone":
let typeLabel = field.title.isEmpty ? "CELL" : field.title.uppercased()
lines.append("TEL;TYPE=\(typeLabel):\(escapeVCardValue(value))")
case "website":
lines.append("URL:\(escapeVCardValue(value))")
case "address":
if let postalAddress = field.postalAddress {
let typeLabel = field.title.isEmpty ? "WORK" : field.title.uppercased()
lines.append(postalAddress.vCardADRLine(type: typeLabel))
} else {
lines.append("ADR;TYPE=WORK:;;\(escapeVCardValue(value));;;;")
}
case "linkedIn":
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(escapeVCardValue(value))")
case "twitter":
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(escapeVCardValue(value))")
case "instagram":
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(escapeVCardValue(value))")
case "facebook":
lines.append("X-SOCIALPROFILE;TYPE=facebook:\(escapeVCardValue(value))")
case "tiktok":
lines.append("X-SOCIALPROFILE;TYPE=tiktok:\(escapeVCardValue(value))")
case "github":
lines.append("X-SOCIALPROFILE;TYPE=github:\(escapeVCardValue(value))")
case "threads":
lines.append("X-SOCIALPROFILE;TYPE=threads:\(escapeVCardValue(value))")
case "telegram":
lines.append("X-SOCIALPROFILE;TYPE=telegram:\(escapeVCardValue(value))")
case "bluesky":
lines.append("X-SOCIALPROFILE;TYPE=bluesky:\(escapeVCardValue(value))")
case "mastodon":
lines.append("X-SOCIALPROFILE;TYPE=mastodon:\(escapeVCardValue(value))")
case "youtube":
lines.append("X-SOCIALPROFILE;TYPE=youtube:\(escapeVCardValue(value))")
case "twitch":
lines.append("X-SOCIALPROFILE;TYPE=twitch:\(escapeVCardValue(value))")
case "reddit":
lines.append("X-SOCIALPROFILE;TYPE=reddit:\(escapeVCardValue(value))")
case "snapchat":
lines.append("X-SOCIALPROFILE;TYPE=snapchat:\(escapeVCardValue(value))")
case "pinterest":
lines.append("X-SOCIALPROFILE;TYPE=pinterest:\(escapeVCardValue(value))")
case "discord":
lines.append("X-SOCIALPROFILE;TYPE=discord:\(escapeVCardValue(value))")
case "slack":
lines.append("X-SOCIALPROFILE;TYPE=slack:\(escapeVCardValue(value))")
case "whatsapp":
lines.append("X-SOCIALPROFILE;TYPE=whatsapp:\(escapeVCardValue(value))")
case "signal":
lines.append("X-SOCIALPROFILE;TYPE=signal:\(escapeVCardValue(value))")
case "venmo":
lines.append("X-SOCIALPROFILE;TYPE=venmo:\(escapeVCardValue(value))")
case "cashApp":
lines.append("X-SOCIALPROFILE;TYPE=cashapp:\(escapeVCardValue(value))")
case "paypal":
lines.append("X-SOCIALPROFILE;TYPE=paypal:\(escapeVCardValue(value))")
case "calendly":
lines.append("URL;TYPE=calendly:\(escapeVCardValue(value))")
case "customLink":
let label = field.title.isEmpty ? "OTHER" : field.title.uppercased()
lines.append("URL;TYPE=\(label):\(escapeVCardValue(value))")
default:
if value.contains("://") || value.contains(".") {
lines.append("URL:\(escapeVCardValue(value))")
}
}
}
// NOTE: Bio, headline, and accreditations
var notes: [String] = []
if !headline.isEmpty { notes.append(headline) }
if !bio.isEmpty { notes.append(bio) }
if !accreditations.isEmpty { notes.append("Credentials: \(accreditations)") }
if !pronouns.isEmpty { notes.append("Pronouns: \(pronouns)") }
if !notes.isEmpty {
lines.append("NOTE:\(escapeVCardValue(notes.joined(separator: "\\n")))")
}
lines.append("END:VCARD")
return lines.joined(separator: "\r\n")
}
/// Escapes special characters for vCard format /// Escapes special characters for vCard format
private func escapeVCardValue(_ value: String) -> String { private func escapeVCardValue(_ value: String) -> String {
value value

View File

@ -546,9 +546,6 @@
} }
} }
} }
},
"Record who received your card" : {
}, },
"Removes this field" : { "Removes this field" : {
@ -565,8 +562,28 @@
"Selected" : { "Selected" : {
}, },
"Share card offline" : { "Share card" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Share card"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Compartir tarjeta"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Partager la carte"
}
}
}
}, },
"Share using widgets on your phone or watch" : { "Share using widgets on your phone or watch" : {
"localizations" : { "localizations" : {
@ -615,6 +632,75 @@
}, },
"Shared With" : { "Shared With" : {
},
"ShareEmailBodySimple" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Here's my contact card. Open the attached file to add me to your contacts."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aquí está mi tarjeta de contacto. Abre el archivo adjunto para agregarme a tus contactos."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voici ma carte de contact. Ouvrez le fichier joint pour m'ajouter à vos contacts."
}
}
}
},
"ShareEmailSubjectSimple" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact Card - %@"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tarjeta de Contacto - %@"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Carte de Contact - %@"
}
}
}
},
"ShareMessageBody" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Here's my contact card!"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "¡Aquí está mi tarjeta de contacto!"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voici ma carte de contact!"
}
}
}
}, },
"Social Media" : { "Social Media" : {
@ -705,9 +791,6 @@
}, },
"Title (optional)" : { "Title (optional)" : {
},
"Track this share" : {
}, },
"URL" : { "URL" : {

View File

@ -0,0 +1,43 @@
import Foundation
/// Service for creating temporary vCard files for sharing via AirDrop, Messages, etc.
/// These files include embedded profile photos, which are too large for QR codes.
struct VCardFileService {
/// Creates a temporary .vcf file for the given card and returns its URL.
/// The file includes the embedded profile photo if available.
@MainActor
func createVCardFile(for card: BusinessCard) throws -> URL {
let payload = card.vCardFilePayload
// Create a sanitized filename from the card's name
let sanitizedName = sanitizeFilename(card.simpleName.isEmpty ? card.displayName : card.simpleName)
let filename = sanitizedName.isEmpty ? "contact" : sanitizedName
// Write to temp directory with .vcf extension
let tempURL = FileManager.default.temporaryDirectory
.appending(path: "\(filename).vcf")
// Remove existing file if present
try? FileManager.default.removeItem(at: tempURL)
// Write the vCard data
try payload.write(to: tempURL, atomically: true, encoding: .utf8)
return tempURL
}
/// Cleans up a temporary vCard file after sharing is complete.
func cleanupVCardFile(at url: URL) {
try? FileManager.default.removeItem(at: url)
}
/// Sanitizes a string for use as a filename by removing invalid characters.
private func sanitizeFilename(_ name: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/\\:*?\"<>|")
return name
.components(separatedBy: invalidCharacters)
.joined()
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -1,16 +1,23 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
import SwiftData import SwiftData
import MessageUI
/// Wrapper to make URL identifiable for sheet presentation
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
struct ShareCardView: View { struct ShareCardView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@State private var showingWalletAlert = false @State private var showingWalletAlert = false
@State private var showingNfcAlert = false @State private var showingNfcAlert = false
@State private var showingContactSheet = false
@State private var shareOffline = false // Share state
@State private var recipientName = "" @State private var vCardFileURL: URL?
@State private var recipientRole = "" @State private var messageComposeURL: IdentifiableURL?
@State private var recipientCompany = "" @State private var mailComposeURL: IdentifiableURL?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -28,14 +35,10 @@ struct ShareCardView: View {
// Share options // Share options
ShareOptionsSection( ShareOptionsSection(
card: card, card: card,
shareLinkService: appState.shareLinkService, vCardFileURL: $vCardFileURL,
shareOffline: $shareOffline, messageComposeURL: $messageComposeURL,
showWallet: { showingWalletAlert = true }, mailComposeURL: $mailComposeURL
showNfc: { showingNfcAlert = true }
) )
// Track share
TrackShareSection { showingContactSheet = true }
} else { } else {
EmptyShareState() EmptyShareState()
} }
@ -59,29 +62,24 @@ struct ShareCardView: View {
} message: { } message: {
Text("Hold your phone near another device to share instantly. NFC setup is on the way.") Text("Hold your phone near another device to share instantly. NFC setup is on the way.")
} }
.sheet(isPresented: $showingContactSheet) { .sheet(item: $messageComposeURL) { url in
RecordContactSheet( MessageComposeView(vCardURL: url)
recipientName: $recipientName, .ignoresSafeArea()
recipientRole: $recipientRole, }
recipientCompany: $recipientCompany .sheet(item: $mailComposeURL) { url in
) { MailComposeView(vCardURL: url, cardName: appState.cardStore.selectedCard?.simpleName ?? "Contact")
saveContact() .ignoresSafeArea()
} }
.onAppear {
prepareVCardFile()
} }
} }
} }
private func saveContact() { private func prepareVCardFile() {
guard !recipientName.isEmpty, let card = appState.cardStore.selectedCard else { return } guard let card = appState.cardStore.selectedCard, vCardFileURL == nil else { return }
appState.contactsStore.recordShare( let service = VCardFileService()
for: recipientName, vCardFileURL = try? service.createVCardFile(for: card)
role: recipientRole,
company: recipientCompany,
cardLabel: card.label
)
recipientName = ""
recipientRole = ""
recipientCompany = ""
} }
} }
@ -116,74 +114,68 @@ private struct QRCodeSection: View {
private struct ShareOptionsSection: View { private struct ShareOptionsSection: View {
let card: BusinessCard let card: BusinessCard
let shareLinkService: ShareLinkProviding @Binding var vCardFileURL: URL?
@Binding var shareOffline: Bool @Binding var messageComposeURL: IdentifiableURL?
let showWallet: () -> Void @Binding var mailComposeURL: IdentifiableURL?
let showNfc: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Offline toggle // Share card (system share sheet - like Contacts app)
ShareOfflineToggle(isOn: $shareOffline) if let url = vCardFileURL {
ShareLink(item: url) {
RowContent(
title: String.localized("Share card"),
systemImage: "square.and.arrow.up",
iconColor: Color.ShareSheet.secondaryText
)
}
.buttonStyle(.plain)
} else {
RowContent(
title: String.localized("Share card"),
systemImage: "square.and.arrow.up",
iconColor: Color.ShareSheet.secondaryText.opacity(Design.Opacity.medium)
)
}
Divider() Divider()
.overlay(Color.ShareSheet.rowBackground) .overlay(Color.ShareSheet.rowBackground)
// Copy link // Text your card (with .vcf attachment)
ShareOptionButton.share( if MFMessageComposeViewController.canSendText() {
title: String.localized("Copy link"), Button {
systemImage: "doc.on.doc", if let url = vCardFileURL {
item: shareLinkService.shareURL(for: card) messageComposeURL = IdentifiableURL(url: url)
) }
} label: {
Divider() RowContent(
.overlay(Color.ShareSheet.rowBackground) title: String.localized("Text your card"),
systemImage: "message.fill",
// Message options group iconColor: Color.ShareSheet.secondaryText
VStack(spacing: 0) { )
ShareOptionButton.link( }
title: String.localized("Text your card"), .buttonStyle(.plain)
systemImage: "message.fill", .disabled(vCardFileURL == nil)
url: shareLinkService.smsURL(for: card)
)
Divider() Divider()
.overlay(Color.ShareSheet.rowBackground) .overlay(Color.ShareSheet.rowBackground)
}
ShareOptionButton.link( // Email your card (with .vcf attachment)
title: String.localized("Email your card"), if MFMailComposeViewController.canSendMail() {
systemImage: "envelope.fill", Button {
url: shareLinkService.emailURL(for: card) if let url = vCardFileURL {
) mailComposeURL = IdentifiableURL(url: url)
}
Divider() } label: {
.overlay(Color.ShareSheet.rowBackground) RowContent(
title: String.localized("Email your card"),
ShareOptionButton.link( systemImage: "envelope.fill",
title: String.localized("Send via WhatsApp"), iconColor: Color.ShareSheet.secondaryText
systemImage: "ellipsis.message.fill", )
iconColor: Color.Social.whatsapp, }
url: shareLinkService.whatsappURL(for: card) .buttonStyle(.plain)
) .disabled(vCardFileURL == nil)
Divider()
.overlay(Color.ShareSheet.rowBackground)
ShareOptionButton.link(
title: String.localized("Send via LinkedIn"),
systemImage: "person.2.fill",
iconColor: Color.Social.linkedIn,
url: shareLinkService.linkedInURL(for: card)
)
Divider()
.overlay(Color.ShareSheet.rowBackground)
ShareOptionButton.action(
title: String.localized("Send another way"),
systemImage: "ellipsis",
action: {}
)
} }
} }
.background(Color.ShareSheet.cardBackground) .background(Color.ShareSheet.cardBackground)
@ -191,62 +183,6 @@ private struct ShareOptionsSection: View {
} }
} }
// MARK: - Share Offline Toggle
private struct ShareOfflineToggle: View {
@Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "wifi.slash")
.foregroundStyle(Color.ShareSheet.secondaryText)
Text("Share card offline")
.foregroundStyle(Color.ShareSheet.text)
}
}
.tint(Color.Accent.red)
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
}
}
// MARK: - Track Share Section
private struct TrackShareSection: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "person.badge.plus")
.font(.title3)
.foregroundStyle(Color.Accent.red)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Track this share")
.font(.headline)
.foregroundStyle(Color.ShareSheet.text)
Text("Record who received your card")
.font(.caption)
.foregroundStyle(Color.ShareSheet.secondaryText)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Color.ShareSheet.secondaryText)
}
.padding(Design.Spacing.large)
.background(Color.ShareSheet.cardBackground)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
.buttonStyle(.plain)
.accessibilityHint(String.localized("Opens a form to record who you shared your card with"))
}
}
// MARK: - Empty State // MARK: - Empty State
private struct EmptyShareState: View { private struct EmptyShareState: View {
@ -269,35 +205,7 @@ private struct EmptyShareState: View {
} }
} }
// MARK: - Share Option Button // MARK: - Row Content
private enum ShareOptionButton {
static func link(
title: String,
systemImage: String,
iconColor: Color = Color.ShareSheet.secondaryText,
url: URL
) -> some View {
Link(destination: url) {
RowContent(title: title, systemImage: systemImage, iconColor: iconColor)
}
.buttonStyle(.plain)
}
static func share(title: String, systemImage: String, item: URL) -> some View {
ShareLink(item: item) {
RowContent(title: title, systemImage: systemImage, iconColor: Color.ShareSheet.secondaryText)
}
.buttonStyle(.plain)
}
static func action(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
RowContent(title: title, systemImage: systemImage, iconColor: Color.ShareSheet.secondaryText)
}
.buttonStyle(.plain)
}
}
private struct RowContent: View { private struct RowContent: View {
let title: String let title: String
@ -322,6 +230,82 @@ private struct RowContent: View {
} }
} }
// MARK: - Message Compose View
private struct MessageComposeView: UIViewControllerRepresentable {
let vCardURL: IdentifiableURL
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> MFMessageComposeViewController {
let controller = MFMessageComposeViewController()
controller.messageComposeDelegate = context.coordinator
controller.body = String.localized("ShareMessageBody")
if let data = try? Data(contentsOf: vCardURL.url) {
controller.addAttachmentData(data, typeIdentifier: "public.vcard", filename: vCardURL.url.lastPathComponent)
}
return controller
}
func updateUIViewController(_ uiViewController: MFMessageComposeViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(dismiss: dismiss)
}
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate {
let dismiss: DismissAction
init(dismiss: DismissAction) {
self.dismiss = dismiss
}
func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) {
dismiss()
}
}
}
// MARK: - Mail Compose View
private struct MailComposeView: UIViewControllerRepresentable {
let vCardURL: IdentifiableURL
let cardName: String
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> MFMailComposeViewController {
let controller = MFMailComposeViewController()
controller.mailComposeDelegate = context.coordinator
controller.setSubject(String.localized("ShareEmailSubjectSimple", cardName))
controller.setMessageBody(String.localized("ShareEmailBodySimple"), isHTML: false)
if let data = try? Data(contentsOf: vCardURL.url) {
controller.addAttachmentData(data, mimeType: "text/vcard", fileName: vCardURL.url.lastPathComponent)
}
return controller
}
func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(dismiss: dismiss)
}
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
let dismiss: DismissAction
init(dismiss: DismissAction) {
self.dismiss = dismiss
}
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
dismiss()
}
}
}
// MARK: - Preview // MARK: - Preview
#Preview { #Preview {