440 lines
16 KiB
Swift
440 lines
16 KiB
Swift
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
|
|
|
|
// Share state
|
|
@State private var vCardFileURL: URL?
|
|
@State private var messageComposeURL: IdentifiableURL?
|
|
@State private var mailComposeURL: IdentifiableURL?
|
|
|
|
// App Clip share state
|
|
@State private var appClipState = AppClipShareState()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
// Dark background
|
|
Color.ShareSheet.background
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: Design.Spacing.xLarge) {
|
|
if let card = appState.cardStore.selectedCard {
|
|
// QR Code section (vCard, no photo)
|
|
QRCodeSection(card: card)
|
|
|
|
// App Clip section (includes photo)
|
|
AppClipSection(card: card, appClipState: appClipState)
|
|
|
|
// Share options
|
|
ShareOptionsSection(
|
|
card: card,
|
|
preferredAction: appState.appSettings.preferredShareAction,
|
|
vCardFileURL: $vCardFileURL,
|
|
messageComposeURL: $messageComposeURL,
|
|
mailComposeURL: $mailComposeURL
|
|
)
|
|
} else {
|
|
EmptyShareState()
|
|
}
|
|
}
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
.padding(.vertical, Design.Spacing.xLarge)
|
|
}
|
|
}
|
|
.navigationTitle(String.localized("Send Your Card"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
.toolbarBackground(Color.ShareSheet.background, for: .navigationBar)
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
.alert(String.localized("Apple Wallet"), isPresented: $showingWalletAlert) {
|
|
Button(String.localized("OK")) { }
|
|
} message: {
|
|
Text("Wallet export is coming soon. We'll let you know as soon as it's ready.")
|
|
}
|
|
.alert(String.localized("NFC Sharing"), isPresented: $showingNfcAlert) {
|
|
Button(String.localized("OK")) { }
|
|
} message: {
|
|
Text("Hold your phone near another device to share instantly. NFC setup is on the way.")
|
|
}
|
|
.sheet(item: $messageComposeURL) { url in
|
|
MessageComposeView(vCardURL: url)
|
|
.ignoresSafeArea()
|
|
}
|
|
.sheet(item: $mailComposeURL) { url in
|
|
MailComposeView(vCardURL: url, cardName: appState.cardStore.selectedCard?.vCardName ?? "Contact")
|
|
.ignoresSafeArea()
|
|
}
|
|
.onAppear {
|
|
prepareVCardFile()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func prepareVCardFile() {
|
|
guard let card = appState.cardStore.selectedCard, vCardFileURL == nil else { return }
|
|
let service = VCardFileService()
|
|
vCardFileURL = try? service.createVCardFile(for: card)
|
|
}
|
|
}
|
|
|
|
// MARK: - QR Code Section
|
|
|
|
private struct QRCodeSection: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
// QR Code
|
|
QRCodeView(payload: card.vCardPayload)
|
|
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
|
.padding(Design.Spacing.large)
|
|
.background(Color.ShareSheet.rowBackground)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
|
|
// Instruction text
|
|
Text("Point your camera at the QR code to receive the card")
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.ShareSheet.secondaryText)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Design.Spacing.xLarge)
|
|
.background(Color.ShareSheet.cardBackground)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
|
}
|
|
}
|
|
|
|
// MARK: - App Clip Section
|
|
|
|
private struct AppClipSection: View {
|
|
let card: BusinessCard
|
|
@Bindable var appClipState: AppClipShareState
|
|
|
|
var body: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
// Header
|
|
HStack {
|
|
Image(systemName: "app.gift")
|
|
.typography(.heading)
|
|
.foregroundStyle(Color.ShareSheet.text)
|
|
Text("App Clip (includes photo)")
|
|
.typography(.heading)
|
|
.foregroundStyle(Color.ShareSheet.text)
|
|
}
|
|
|
|
// Content based on state
|
|
if appClipState.isUploading {
|
|
ProgressView()
|
|
.tint(Color.ShareSheet.text)
|
|
.padding(Design.Spacing.xLarge)
|
|
.accessibilityLabel(Text("Uploading card"))
|
|
} else if let result = appClipState.uploadResult {
|
|
// Show QR code for App Clip URL
|
|
QRCodeView(payload: result.appClipURL.absoluteString)
|
|
.frame(width: Design.CardSize.qrSizeLarge, height: Design.CardSize.qrSizeLarge)
|
|
.padding(Design.Spacing.large)
|
|
.background(Color.ShareSheet.rowBackground)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
|
|
// Expiration notice
|
|
Text("Expires in 7 days")
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.ShareSheet.secondaryText)
|
|
|
|
// Reset button
|
|
Button {
|
|
appClipState.reset()
|
|
} label: {
|
|
Text("Generate New Link")
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.ShareSheet.text)
|
|
}
|
|
} else {
|
|
// Generate button
|
|
Button {
|
|
Task { await appClipState.shareViaAppClip(card: card) }
|
|
} label: {
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Image(systemName: "qrcode")
|
|
Text("Generate App Clip Link")
|
|
}
|
|
.typography(.heading)
|
|
.foregroundStyle(Color.ShareSheet.background)
|
|
.padding(.horizontal, Design.Spacing.xLarge)
|
|
.padding(.vertical, Design.Spacing.medium)
|
|
.background(Color.ShareSheet.text)
|
|
.clipShape(.capsule)
|
|
}
|
|
|
|
// Description
|
|
Text("Creates a link that opens a mini-app for recipients to preview and save your card with photo.")
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.ShareSheet.secondaryText)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
// Error message
|
|
if let error = appClipState.errorMessage {
|
|
Text(error)
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.Accent.red)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Design.Spacing.xLarge)
|
|
.background(Color.ShareSheet.cardBackground)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
|
}
|
|
}
|
|
|
|
// MARK: - Share Options Section
|
|
|
|
private struct ShareOptionsSection: View {
|
|
enum ShareOption: String, CaseIterable, Identifiable {
|
|
case shareSheet
|
|
case textMessage
|
|
case email
|
|
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
let card: BusinessCard
|
|
let preferredAction: PreferredShareAction
|
|
@Binding var vCardFileURL: URL?
|
|
@Binding var messageComposeURL: IdentifiableURL?
|
|
@Binding var mailComposeURL: IdentifiableURL?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(orderedOptions.enumerated()), id: \.element.id) { index, option in
|
|
switch option {
|
|
case .shareSheet:
|
|
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)
|
|
)
|
|
}
|
|
case .textMessage:
|
|
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)
|
|
case .email:
|
|
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)
|
|
}
|
|
|
|
if index < orderedOptions.count - 1 {
|
|
Divider()
|
|
.overlay(Color.ShareSheet.rowBackground)
|
|
}
|
|
}
|
|
}
|
|
.background(Color.ShareSheet.cardBackground)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
|
|
private var orderedOptions: [ShareOption] {
|
|
let supportedOptions = availableOptions
|
|
guard let preferred = mapPreferredAction(preferredAction),
|
|
supportedOptions.contains(preferred) else {
|
|
return supportedOptions
|
|
}
|
|
return [preferred] + supportedOptions.filter { $0 != preferred }
|
|
}
|
|
|
|
private var availableOptions: [ShareOption] {
|
|
var options: [ShareOption] = [.shareSheet]
|
|
if MFMessageComposeViewController.canSendText() {
|
|
options.append(.textMessage)
|
|
}
|
|
if MFMailComposeViewController.canSendMail() {
|
|
options.append(.email)
|
|
}
|
|
return options
|
|
}
|
|
|
|
private func mapPreferredAction(_ action: PreferredShareAction) -> ShareOption? {
|
|
switch action {
|
|
case .shareSheet: .shareSheet
|
|
case .textMessage: .textMessage
|
|
case .email: .email
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
private struct EmptyShareState: View {
|
|
var body: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
Image(systemName: "rectangle.on.rectangle.slash")
|
|
.typography(.title2)
|
|
.foregroundStyle(Color.ShareSheet.secondaryText)
|
|
|
|
Text("No card selected")
|
|
.typography(.heading)
|
|
.foregroundStyle(Color.ShareSheet.text)
|
|
|
|
Text("Choose a card in the My Cards tab to start sharing.")
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.ShareSheet.secondaryText)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(Design.Spacing.xLarge)
|
|
}
|
|
}
|
|
|
|
// MARK: - Row Content
|
|
|
|
private struct RowContent: View {
|
|
let title: String
|
|
let systemImage: String
|
|
let iconColor: Color
|
|
|
|
var body: some View {
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
Image(systemName: systemImage)
|
|
.typography(.body)
|
|
.foregroundStyle(iconColor)
|
|
.frame(width: Design.Spacing.xLarge)
|
|
|
|
Text(title)
|
|
.foregroundStyle(Color.ShareSheet.text)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
.padding(.vertical, Design.Spacing.medium)
|
|
.contentShape(.rect)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
ShareCardView()
|
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
|
|
}
|