BusinessCard/BusinessCard/Views/Features/Share/ShareCardView.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))
}