BusinessCard/BusinessCardClip/Views/Components/ClipCardPreview.swift

270 lines
10 KiB
Swift

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
let onSave: () -> Void
var body: some View {
VStack(spacing: ClipDesign.Spacing.xLarge) {
Spacer(minLength: ClipDesign.Spacing.medium)
previewCard
.frame(maxWidth: ClipDesign.Size.previewMaxWidth)
.padding(.horizontal, ClipDesign.Spacing.large)
VStack(spacing: ClipDesign.Spacing.medium) {
// Save button
ClipPrimaryButton(
title: String(localized: "Save to Contacts"),
systemImage: "person.crop.circle.badge.plus",
action: onSave
)
.accessibilityLabel(Text("Save \(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)
Spacer(minLength: ClipDesign.Spacing.large)
}
}
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")
}
}
}