Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
92c941d7aa
commit
4fe7fde9b9
@ -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",
|
||||
|
||||
@ -2,10 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
|
||||
43
BusinessCard/Services/VCardFileService.swift
Normal file
43
BusinessCard/Services/VCardFileService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user