diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj index d49680f..3e222ef 100644 --- a/BusinessCard.xcodeproj/project.pbxproj +++ b/BusinessCard.xcodeproj/project.pbxproj @@ -531,13 +531,13 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; 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_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -567,13 +567,13 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; 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_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/BusinessCard/Info.plist b/BusinessCard/Info.plist index 0fc02a1..ca9a074 100644 --- a/BusinessCard/Info.plist +++ b/BusinessCard/Info.plist @@ -2,10 +2,6 @@ - NSCameraUsageDescription - BusinessCard uses the camera to scan QR codes on other people's business cards. - NSPhotoLibraryUsageDescription - BusinessCard uses your photo library to add a profile photo to your business card. UIBackgroundModes remote-notification diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift index 5aea122..d12091b 100644 --- a/BusinessCard/Models/BusinessCard.swift +++ b/BusinessCard/Models/BusinessCard.swift @@ -361,6 +361,142 @@ final class BusinessCard { 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 private func escapeVCardValue(_ value: String) -> String { value diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index d346410..7f298c3 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -546,9 +546,6 @@ } } } - }, - "Record who received your card" : { - }, "Removes this field" : { @@ -565,8 +562,28 @@ "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" : { "localizations" : { @@ -615,6 +632,75 @@ }, "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" : { @@ -705,9 +791,6 @@ }, "Title (optional)" : { - }, - "Track this share" : { - }, "URL" : { diff --git a/BusinessCard/Services/VCardFileService.swift b/BusinessCard/Services/VCardFileService.swift new file mode 100644 index 0000000..7be5c1e --- /dev/null +++ b/BusinessCard/Services/VCardFileService.swift @@ -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) + } +} diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift index e9c4eaf..00aaa64 100644 --- a/BusinessCard/Views/ShareCardView.swift +++ b/BusinessCard/Views/ShareCardView.swift @@ -1,16 +1,23 @@ import SwiftUI import Bedrock 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 { @Environment(AppState.self) private var appState @State private var showingWalletAlert = false @State private var showingNfcAlert = false - @State private var showingContactSheet = false - @State private var shareOffline = false - @State private var recipientName = "" - @State private var recipientRole = "" - @State private var recipientCompany = "" + + // Share state + @State private var vCardFileURL: URL? + @State private var messageComposeURL: IdentifiableURL? + @State private var mailComposeURL: IdentifiableURL? var body: some View { NavigationStack { @@ -28,14 +35,10 @@ struct ShareCardView: View { // Share options ShareOptionsSection( card: card, - shareLinkService: appState.shareLinkService, - shareOffline: $shareOffline, - showWallet: { showingWalletAlert = true }, - showNfc: { showingNfcAlert = true } + vCardFileURL: $vCardFileURL, + messageComposeURL: $messageComposeURL, + mailComposeURL: $mailComposeURL ) - - // Track share - TrackShareSection { showingContactSheet = true } } else { EmptyShareState() } @@ -59,29 +62,24 @@ struct ShareCardView: View { } message: { Text("Hold your phone near another device to share instantly. NFC setup is on the way.") } - .sheet(isPresented: $showingContactSheet) { - RecordContactSheet( - recipientName: $recipientName, - recipientRole: $recipientRole, - recipientCompany: $recipientCompany - ) { - saveContact() - } + .sheet(item: $messageComposeURL) { url in + MessageComposeView(vCardURL: url) + .ignoresSafeArea() + } + .sheet(item: $mailComposeURL) { url in + MailComposeView(vCardURL: url, cardName: appState.cardStore.selectedCard?.simpleName ?? "Contact") + .ignoresSafeArea() + } + .onAppear { + prepareVCardFile() } } } - private func saveContact() { - guard !recipientName.isEmpty, let card = appState.cardStore.selectedCard else { return } - appState.contactsStore.recordShare( - for: recipientName, - role: recipientRole, - company: recipientCompany, - cardLabel: card.label - ) - recipientName = "" - recipientRole = "" - recipientCompany = "" + private func prepareVCardFile() { + guard let card = appState.cardStore.selectedCard, vCardFileURL == nil else { return } + let service = VCardFileService() + vCardFileURL = try? service.createVCardFile(for: card) } } @@ -116,74 +114,68 @@ private struct QRCodeSection: View { private struct ShareOptionsSection: View { let card: BusinessCard - let shareLinkService: ShareLinkProviding - @Binding var shareOffline: Bool - let showWallet: () -> Void - let showNfc: () -> Void + @Binding var vCardFileURL: URL? + @Binding var messageComposeURL: IdentifiableURL? + @Binding var mailComposeURL: IdentifiableURL? var body: some View { VStack(spacing: 0) { - // Offline toggle - ShareOfflineToggle(isOn: $shareOffline) + // Share card (system share sheet - like Contacts app) + 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() .overlay(Color.ShareSheet.rowBackground) - // Copy link - ShareOptionButton.share( - title: String.localized("Copy link"), - systemImage: "doc.on.doc", - item: shareLinkService.shareURL(for: card) - ) + // Text your card (with .vcf attachment) + if MFMessageComposeViewController.canSendText() { + Button { + if let url = vCardFileURL { + messageComposeURL = IdentifiableURL(url: url) + } + } label: { + RowContent( + title: String.localized("Text your card"), + systemImage: "message.fill", + iconColor: Color.ShareSheet.secondaryText + ) + } + .buttonStyle(.plain) + .disabled(vCardFileURL == nil) + + Divider() + .overlay(Color.ShareSheet.rowBackground) + } - Divider() - .overlay(Color.ShareSheet.rowBackground) - - // Message options group - VStack(spacing: 0) { - ShareOptionButton.link( - title: String.localized("Text your card"), - systemImage: "message.fill", - url: shareLinkService.smsURL(for: card) - ) - - Divider() - .overlay(Color.ShareSheet.rowBackground) - - ShareOptionButton.link( - title: String.localized("Email your card"), - systemImage: "envelope.fill", - url: shareLinkService.emailURL(for: card) - ) - - Divider() - .overlay(Color.ShareSheet.rowBackground) - - ShareOptionButton.link( - title: String.localized("Send via WhatsApp"), - systemImage: "ellipsis.message.fill", - iconColor: Color.Social.whatsapp, - url: shareLinkService.whatsappURL(for: card) - ) - - 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: {} - ) + // Email your card (with .vcf attachment) + if MFMailComposeViewController.canSendMail() { + Button { + if let url = vCardFileURL { + mailComposeURL = IdentifiableURL(url: url) + } + } label: { + RowContent( + title: String.localized("Email your card"), + systemImage: "envelope.fill", + iconColor: Color.ShareSheet.secondaryText + ) + } + .buttonStyle(.plain) + .disabled(vCardFileURL == nil) } } .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 private struct EmptyShareState: View { @@ -269,35 +205,7 @@ private struct EmptyShareState: View { } } -// MARK: - Share Option Button - -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) - } -} +// MARK: - Row Content private struct RowContent: View { 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 #Preview {