618 lines
21 KiB
Swift
618 lines
21 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
import SwiftData
|
|
|
|
struct BusinessCardView: View {
|
|
let card: BusinessCard
|
|
var isCompact: Bool = false
|
|
|
|
private var hasOverlappingContent: Bool {
|
|
card.headerLayout.hasOverlappingContent
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
CardBannerView(card: card)
|
|
|
|
CardContentView(card: card, isCompact: isCompact)
|
|
.offset(y: hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
|
|
.padding(.bottom, hasOverlappingContent ? -Design.CardSize.avatarOverlap : 0)
|
|
}
|
|
.background(Color.AppBackground.elevated)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
|
.shadow(
|
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
|
radius: Design.Shadow.radiusLarge,
|
|
x: Design.Shadow.offsetNone,
|
|
y: Design.Shadow.offsetMedium
|
|
)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String.localized("Business card"))
|
|
.accessibilityValue("\(card.fullName), \(card.role), \(card.company)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Banner View
|
|
|
|
private struct CardBannerView: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch card.headerLayout.bannerContent {
|
|
case .profile:
|
|
ProfileBannerContent(card: card)
|
|
case .logo:
|
|
LogoBannerContent(card: card)
|
|
case .cover:
|
|
CoverBannerContent(card: card)
|
|
}
|
|
}
|
|
.frame(height: Design.CardSize.bannerHeight)
|
|
.clipped()
|
|
}
|
|
}
|
|
|
|
// MARK: - Profile Banner Content
|
|
|
|
private struct ProfileBannerContent: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
.clipped()
|
|
} else {
|
|
LinearGradient(
|
|
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
VStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: "person.fill")
|
|
.typography(.title2Bold)
|
|
Text("Profile")
|
|
.typography(.title3)
|
|
.bold()
|
|
}
|
|
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Logo Banner Content
|
|
|
|
private struct LogoBannerContent: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
.clipped()
|
|
} else {
|
|
VStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: "building.2.fill")
|
|
.typography(.title2Bold)
|
|
Text("Logo")
|
|
.typography(.title3)
|
|
.bold()
|
|
}
|
|
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Cover Banner Content
|
|
|
|
private struct CoverBannerContent: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
.clipped()
|
|
} else {
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
VStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: "photo.fill")
|
|
.typography(.title2Bold)
|
|
Text("Cover")
|
|
.typography(.title3)
|
|
.bold()
|
|
}
|
|
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Content View
|
|
|
|
private struct CardContentView: View {
|
|
let card: BusinessCard
|
|
let isCompact: Bool
|
|
|
|
private var textColor: Color { Color.Text.primary }
|
|
|
|
private var userInfoTopPadding: CGFloat {
|
|
card.headerLayout.contentOverlay == .none ? Design.Spacing.large : 0
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
contentOverlay
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Text(card.fullName)
|
|
.typography(.title2)
|
|
.bold()
|
|
.foregroundStyle(textColor)
|
|
|
|
if !card.pronouns.isEmpty {
|
|
Text("(\(card.pronouns))")
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
}
|
|
}
|
|
|
|
Text(card.role)
|
|
.typography(.heading)
|
|
.foregroundStyle(textColor)
|
|
|
|
Text(card.company)
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
|
|
if !card.headline.isEmpty {
|
|
Text(card.headline)
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
.padding(.top, Design.Spacing.xxSmall)
|
|
}
|
|
}
|
|
.padding(.top, userInfoTopPadding)
|
|
|
|
if !isCompact {
|
|
Divider()
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
|
|
ContactFieldsListView(card: card)
|
|
}
|
|
}
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
.padding(.bottom, Design.Spacing.large)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var contentOverlay: some View {
|
|
switch card.headerLayout.contentOverlay {
|
|
case .none:
|
|
EmptyView()
|
|
case .avatar:
|
|
HStack {
|
|
ProfileAvatarView(card: card)
|
|
Spacer()
|
|
}
|
|
case .logoRectangle:
|
|
HStack {
|
|
LogoRectangleView(card: card)
|
|
Spacer()
|
|
}
|
|
case .avatarAndLogo:
|
|
HStack(alignment: .bottom) {
|
|
ProfileAvatarView(card: card)
|
|
Spacer()
|
|
LogoRectangleView(card: card)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Profile Avatar
|
|
|
|
private struct ProfileAvatarView: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
Image(systemName: card.avatarSystemName)
|
|
.typography(.title3)
|
|
.foregroundStyle(card.theme.textColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(card.theme.accentColor)
|
|
}
|
|
}
|
|
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
|
|
.clipShape(.circle)
|
|
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
|
|
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
|
|
}
|
|
}
|
|
|
|
// MARK: - Logo Badge View
|
|
|
|
private struct LogoBadgeView: View {
|
|
let card: BusinessCard
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.clipped()
|
|
} else {
|
|
VStack(spacing: Design.Spacing.xxSmall) {
|
|
Image(systemName: "building.2")
|
|
.typography(.body)
|
|
Text("Logo")
|
|
.typography(.caption2)
|
|
}
|
|
.foregroundStyle(card.theme.textColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
|
|
.background(card.theme.accentColor)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
|
|
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
|
|
}
|
|
}
|
|
|
|
// MARK: - Logo Rectangle View
|
|
|
|
private struct LogoRectangleView: View {
|
|
let card: BusinessCard
|
|
|
|
private let aspectRatio: CGFloat = Design.CardSize.logoContainerAspectRatio
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let logoData = card.logoData, let uiImage = UIImage(data: logoData) {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.clipped()
|
|
} else {
|
|
VStack(spacing: Design.Spacing.xxSmall) {
|
|
Image(systemName: "building.2")
|
|
.typography(.body)
|
|
Text("Logo")
|
|
.typography(.caption2)
|
|
}
|
|
.foregroundStyle(card.theme.textColor)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.frame(width: Design.CardSize.avatarLarge * aspectRatio, height: Design.CardSize.avatarLarge)
|
|
.background(card.theme.accentColor)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
|
|
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusSmall, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetSmall)
|
|
}
|
|
}
|
|
|
|
// MARK: - Contact Fields List
|
|
|
|
private struct ContactFieldsListView: View {
|
|
@Environment(\.openURL) private var openURL
|
|
@AppStorage("businessCard.contactFieldsViewMode") private var viewModeRawValue = ContactFieldsViewMode.list.rawValue
|
|
let card: BusinessCard
|
|
|
|
private var viewMode: ContactFieldsViewMode {
|
|
get { ContactFieldsViewMode(rawValue: viewModeRawValue) ?? .list }
|
|
nonmutating set { viewModeRawValue = newValue.rawValue }
|
|
}
|
|
|
|
private var columns: [GridItem] {
|
|
[
|
|
GridItem(.flexible(), spacing: Design.Spacing.small),
|
|
GridItem(.flexible(), spacing: Design.Spacing.small)
|
|
]
|
|
}
|
|
|
|
private var primaryContactFields: [ContactField] {
|
|
card.orderedContactFields.filter { ["phone", "email", "address"].contains($0.typeId) }
|
|
}
|
|
|
|
private var secondaryContactFields: [ContactField] {
|
|
card.orderedContactFields.filter { !["phone", "email", "address"].contains($0.typeId) }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxLarge) {
|
|
if !primaryContactFields.isEmpty {
|
|
primaryContactSection(fields: primaryContactFields)
|
|
}
|
|
if !secondaryContactFields.isEmpty {
|
|
secondaryFieldsSection(title: primaryContactFields.isEmpty ? nil : "Profiles & Links", fields: secondaryContactFields)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func primaryContactSection(fields: [ContactField]) -> some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
Text("Contact")
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
.padding(.top, Design.Spacing.xxSmall)
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
|
ForEach(fields.indices, id: \.self) { index in
|
|
let field = fields[index]
|
|
PrimaryContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
|
|
openField(field)
|
|
}
|
|
|
|
if index < fields.count - 1 {
|
|
Divider()
|
|
.opacity(0.45)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func secondaryFieldsSection(title: String?, fields: [ContactField]) -> some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
if let title {
|
|
Text(title)
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
.padding(.top, Design.Spacing.xxSmall)
|
|
}
|
|
|
|
// Always present profiles/links as grid. Keep the view mode enum/storage for future toggling.
|
|
LazyVGrid(columns: columns, spacing: Design.Spacing.small) {
|
|
ForEach(fields) { field in
|
|
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor, style: .grid) {
|
|
openField(field)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func openField(_ field: ContactField) {
|
|
if let url = field.buildURL() {
|
|
openURL(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PrimaryContactFieldRowView: View {
|
|
let field: ContactField
|
|
let themeColor: Color
|
|
let action: () -> Void
|
|
|
|
private var labelText: String {
|
|
let trimmed = field.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? field.displayName : trimmed
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
|
field.iconImage()
|
|
.typography(.subheading)
|
|
.foregroundStyle(themeColor)
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
|
Text(field.displayValue)
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.Text.primary)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(2)
|
|
|
|
Text(labelText)
|
|
.typography(.caption)
|
|
.foregroundStyle(Color.Text.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.vertical, Design.Spacing.xxSmall)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private enum ContactFieldsViewMode: String, CaseIterable, Identifiable {
|
|
case list
|
|
case grid
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .list: return "List"
|
|
case .grid: return "Grid"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ContactFieldRowView: View {
|
|
enum Style {
|
|
case list
|
|
case grid
|
|
}
|
|
|
|
let field: ContactField
|
|
let themeColor: Color
|
|
let style: Style
|
|
let action: () -> Void
|
|
|
|
private var valueText: String {
|
|
field.displayValue
|
|
}
|
|
|
|
private var labelText: String {
|
|
let trimmedTitle = field.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedTitle.isEmpty else { return field.displayName }
|
|
return shouldUseProviderLabel(for: trimmedTitle) ? field.displayName : trimmedTitle
|
|
}
|
|
|
|
private func shouldUseProviderLabel(for title: String) -> Bool {
|
|
let normalized = title
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
|
|
let noisyPrefixes = [
|
|
"connect with",
|
|
"follow me",
|
|
"add me",
|
|
"subscribe",
|
|
"view our work",
|
|
"ask me",
|
|
"pay via",
|
|
"schedule"
|
|
]
|
|
|
|
if noisyPrefixes.contains(where: normalized.hasPrefix) {
|
|
return true
|
|
}
|
|
|
|
// If this is a long, canned suggestion, prefer provider name.
|
|
if let suggestions = field.fieldType?.titleSuggestions {
|
|
let matchedSuggestion = suggestions
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
|
.contains(normalized)
|
|
|
|
if matchedSuggestion {
|
|
let words = normalized.split(whereSeparator: \.isWhitespace)
|
|
if words.count >= 3 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private var valueLineLimit: Int {
|
|
style == .grid ? 2 : 1
|
|
}
|
|
|
|
private var rowMinHeight: CGFloat? {
|
|
style == .grid ? 84 : nil
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(alignment: .center, spacing: Design.Spacing.medium) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(themeColor.opacity(0.22))
|
|
.frame(width: 34, height: 34)
|
|
field.iconImage()
|
|
.typography(.caption)
|
|
.foregroundStyle(themeColor)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
Text(valueText)
|
|
.typography(.subheading)
|
|
.foregroundStyle(Color.Text.primary)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(valueLineLimit)
|
|
.minimumScaleFactor(0.72)
|
|
.allowsTightening(true)
|
|
|
|
Text(labelText)
|
|
.typography(.captionEmphasis)
|
|
.italic()
|
|
.foregroundStyle(Color.Text.secondary)
|
|
.lineLimit(style == .grid ? 2 : 1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.horizontal, Design.Spacing.medium)
|
|
.padding(.vertical, Design.Spacing.medium)
|
|
.frame(maxWidth: .infinity, minHeight: rowMinHeight, alignment: .center)
|
|
.background(Color.AppBackground.base.opacity(0.82))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
|
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
|
)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("\(field.displayName): \(field.value)")
|
|
.accessibilityHint(field.title.isEmpty ? String(localized: "Opens \(field.displayName)") : field.title)
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("Profile Banner") {
|
|
@Previewable @State var card = BusinessCard(
|
|
role: "Lead iOS Developer",
|
|
company: "Toyota",
|
|
themeName: "Coral",
|
|
headerLayoutRawValue: "profileBanner",
|
|
firstName: "Matt",
|
|
lastName: "Bruce"
|
|
)
|
|
|
|
BusinessCardView(card: card)
|
|
.padding()
|
|
.background(Color.AppBackground.base)
|
|
}
|
|
|
|
#Preview("Cover + Avatar + Logo") {
|
|
@Previewable @State var card = BusinessCard(
|
|
role: "Lead iOS Developer",
|
|
company: "Toyota",
|
|
themeName: "Violet",
|
|
headerLayoutRawValue: "coverWithAvatarAndLogo",
|
|
firstName: "Matt",
|
|
lastName: "Bruce"
|
|
)
|
|
|
|
BusinessCardView(card: card)
|
|
.padding()
|
|
.background(Color.AppBackground.base)
|
|
}
|