import SwiftUI import Bedrock /// Displays a preview of the shared card with option to save to Contacts. struct ClipCardPreview: View { private struct ContactSection { let title: String let rows: [SharedCardSnapshot.ContactInfoRow] } let snapshot: SharedCardSnapshot /// Called when share sheet is dismissed; use for CloudKit cleanup. let onSave: () -> Void @State private var showShareSheet = false var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: ClipDesign.Spacing.xLarge) { previewCard .frame(maxWidth: ClipDesign.Size.previewMaxWidth) VStack(spacing: ClipDesign.Spacing.medium) { // Add to Contacts - uses share sheet because App Clips cannot use CNContactStore ClipPrimaryButton( title: String(localized: "Add to Contacts"), systemImage: "person.crop.circle.badge.plus", action: { showShareSheet = true } ) .accessibilityLabel(Text("Add \(snapshot.displayName) to contacts")) // Get full app prompt Button { openAppStore() } label: { Text(String(localized: "Get the full app")) .styled(.subheading) .foregroundStyle(Color.Clip.accent) } } .padding(.horizontal, ClipDesign.Spacing.xLarge) } .padding(.horizontal, ClipDesign.Spacing.large) } .sheet(isPresented: $showShareSheet) { ClipShareSheet(vCardData: snapshot.vCardData) { showShareSheet = false onSave() } } } private var previewCard: some View { VStack(spacing: 0) { previewHeader VStack(alignment: .leading, spacing: ClipDesign.Spacing.medium) { Text(snapshot.displayName) .styled(.title2) .foregroundStyle(Color.Clip.text) .multilineTextAlignment(.center) .lineLimit(2) .minimumScaleFactor(0.8) .frame(maxWidth: .infinity, alignment: .center) if !snapshot.role.isEmpty || !snapshot.company.isEmpty { VStack(spacing: ClipDesign.Spacing.xSmall) { if !snapshot.role.isEmpty { Text(snapshot.role) .styled(.headingEmphasis) .foregroundStyle(Color.Clip.text) .multilineTextAlignment(.center) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .center) } if !snapshot.company.isEmpty { Text(snapshot.company) .styled(.subheading) .foregroundStyle(Color.Clip.secondaryText) .multilineTextAlignment(.center) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .center) } } .frame(maxWidth: .infinity, alignment: .center) } Divider() .overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint)) if !contactSections.isEmpty { contactSectionsView Divider() .overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint)) } Text(String(localized: "Shared business card")) .styled(.caption) .foregroundStyle(Color.Clip.secondaryText) .frame(maxWidth: .infinity, alignment: .center) } .padding(.horizontal, ClipDesign.Spacing.xLarge) .padding(.bottom, ClipDesign.Spacing.xLarge) } .frame(minHeight: ClipDesign.Size.previewCardMinHeight) .background(Color.Clip.cardBackground) .clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.xLarge)) .shadow( color: Color.Clip.text.opacity(ClipDesign.Opacity.faint), radius: ClipDesign.Shadow.radius, x: 0, y: ClipDesign.Shadow.y ) .overlay( RoundedRectangle(cornerRadius: ClipDesign.CornerRadius.xLarge) .stroke(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.subtle), lineWidth: ClipDesign.Size.cardStrokeWidth) ) } private var contactSectionsView: some View { VStack(alignment: .leading, spacing: ClipDesign.Spacing.medium) { ForEach(contactSections, id: \.title) { section in VStack(alignment: .leading, spacing: ClipDesign.Spacing.xSmall) { Text(section.title.uppercased()) .styled(.caption) .foregroundStyle(Color.Clip.secondaryText) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { ForEach(Array(section.rows.enumerated()), id: \.element.id) { index, row in contactRow(row) .padding(.horizontal, ClipDesign.Spacing.medium) .padding(.vertical, ClipDesign.Spacing.small) if index < section.rows.count - 1 { Divider() .overlay(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint)) .padding(.leading, ClipDesign.Spacing.medium) } } } .background(Color.Clip.background.opacity(ClipDesign.Opacity.faint)) .clipShape(.rect(cornerRadius: ClipDesign.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: ClipDesign.CornerRadius.medium) .stroke(Color.Clip.secondaryText.opacity(ClipDesign.Opacity.faint), lineWidth: ClipDesign.Size.cardStrokeWidth) ) } } } .frame(maxWidth: .infinity, alignment: .leading) } private func contactRow(_ row: SharedCardSnapshot.ContactInfoRow) -> some View { HStack(alignment: .top, spacing: ClipDesign.Spacing.small) { Image(systemName: row.systemImage) .font(.system(size: ClipDesign.Size.contactRowIconSize, weight: .semibold)) .foregroundStyle(Color.Clip.secondaryText) .frame(width: ClipDesign.Size.contactRowIconSize + ClipDesign.Spacing.small) VStack(alignment: .leading, spacing: ClipDesign.Spacing.xSmall) { Text(row.value) .styled(.subheading) .foregroundStyle(Color.Clip.text) .lineLimit(row.kind == .note ? nil : 2) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) Text(row.label) .styled(.caption) .foregroundStyle(Color.Clip.secondaryText) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) } private var contactSections: [ContactSection] { let groups = Dictionary(grouping: snapshot.contactInfoRows, by: \.kind) let order: [(SharedCardSnapshot.ContactInfoRow.Kind, String)] = [ (.phone, String(localized: "Phone")), (.email, String(localized: "Email")), (.address, String(localized: "Address")), (.website, String(localized: "Links")), (.social, String(localized: "Social Profiles")), (.note, String(localized: "Notes")) ] return order.compactMap { kind, title in guard let rows = groups[kind], !rows.isEmpty else { return nil } return ContactSection(title: title, rows: rows) } } private var previewHeader: some View { ZStack(alignment: .bottom) { LinearGradient( colors: [ Color.Clip.accent.opacity(ClipDesign.Opacity.strong), Color.Clip.accent.opacity(ClipDesign.Opacity.subtle) ], startPoint: .topLeading, endPoint: .bottomTrailing ) .frame(height: ClipDesign.Size.previewBannerHeight) avatarView .offset(y: ClipDesign.Size.previewAvatarOverlap) } .padding(.bottom, ClipDesign.Size.previewAvatarOverlap) } private var avatarView: some View { ZStack { Circle() .fill(Color.Clip.background.opacity(ClipDesign.Opacity.medium)) if let photoData = snapshot.photoData, let uiImage = UIImage(data: photoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() } else { Image(systemName: "person.fill") .resizable() .scaledToFit() .frame(width: ClipDesign.Size.avatarFallbackSymbolSize, height: ClipDesign.Size.avatarFallbackSymbolSize) .foregroundStyle(Color.Clip.secondaryText) } } .frame(width: ClipDesign.Size.previewAvatarSize, height: ClipDesign.Size.previewAvatarSize) .clipShape(.circle) .overlay( Circle() .stroke(Color.Clip.cardBackground, lineWidth: ClipDesign.Size.avatarStrokeWidth) ) .shadow( color: Color.Clip.text.opacity(ClipDesign.Opacity.faint), radius: ClipDesign.Shadow.radius, x: 0, y: ClipDesign.Shadow.y ) } private func openAppStore() { if let url = URL(string: ClipDesign.URL.appStore) { UIApplication.shared.open(url) } } } #Preview { ZStack { Color.Clip.background .ignoresSafeArea() ClipCardPreview( snapshot: SharedCardSnapshot( recordName: "test", vCardData: """ BEGIN:VCARD VERSION:3.0 N:Sullivan;Daniel;;; FN:Daniel Sullivan ORG:WR Construction TITLE:Property Developer END:VCARD """ ) ) { print("Save tapped") } } }