BusinessCard/BusinessCard/Views/Components/HeaderLayoutPickerView.swift

478 lines
17 KiB
Swift

import SwiftUI
import Bedrock
/// A sheet that displays header layout options as a live preview carousel.
struct HeaderLayoutPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedLayout: CardHeaderLayout
let photoData: Data?
let coverPhotoData: Data?
let logoData: Data?
let avatarSystemName: String
let theme: CardTheme
let displayName: String
let role: String
let company: String
@State private var currentLayout: CardHeaderLayout
init(
selectedLayout: Binding<CardHeaderLayout>,
photoData: Data?,
coverPhotoData: Data?,
logoData: Data?,
avatarSystemName: String,
theme: CardTheme,
displayName: String,
role: String,
company: String
) {
self._selectedLayout = selectedLayout
self.photoData = photoData
self.coverPhotoData = coverPhotoData
self.logoData = logoData
self.avatarSystemName = avatarSystemName
self.theme = theme
self.displayName = displayName
self.role = role
self.company = company
self._currentLayout = State(initialValue: selectedLayout.wrappedValue)
}
private var suggestedLayout: CardHeaderLayout {
CardHeaderLayout.suggested(
hasProfile: photoData != nil,
hasCover: coverPhotoData != nil,
hasLogo: logoData != nil
)
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Layout carousel
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.large) {
ForEach(CardHeaderLayout.allCases) { layout in
LayoutPreviewCard(
layout: layout,
isSelected: currentLayout == layout,
isSuggested: layout == suggestedLayout,
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
theme: theme,
displayName: displayName,
role: role,
company: company
) {
withAnimation(.snappy(duration: Design.Animation.quick)) {
currentLayout = layout
}
}
}
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.large)
}
.scrollClipDisabled()
Spacer()
// Confirm button
Button {
selectedLayout = currentLayout
dismiss()
} label: {
Text("Confirm layout")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.large)
.background(.black)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.xLarge)
}
.background(Color.AppBackground.base)
.navigationTitle("Choose a layout")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.body)
.foregroundStyle(Color.Text.primary)
}
}
}
}
}
}
// MARK: - Layout Preview Card
private struct LayoutPreviewCard: View {
let layout: CardHeaderLayout
let isSelected: Bool
let isSuggested: Bool
let photoData: Data?
let coverPhotoData: Data?
let logoData: Data?
let avatarSystemName: String
let theme: CardTheme
let displayName: String
let role: String
let company: String
let onSelect: () -> Void
// Layout constants
private let cardWidth: CGFloat = 200
private let cardHeight: CGFloat = 280
private let bannerHeight: CGFloat = 100
private let avatarSize: CGFloat = 56
private let avatarSmall: CGFloat = 44
private let logoSize: CGFloat = 48
private var needsMoreImages: Bool {
(layout.requiresCoverPhoto && coverPhotoData == nil) ||
(layout.benefitsFromLogo && logoData == nil)
}
/// Whether avatar overlaps from banner to content
private var showsAvatarInContent: Bool {
layout.showsAvatarInContent
}
/// Whether this is the side-by-side layout
private var isSideBySideLayout: Bool {
layout == .avatarAndLogoSideBySide
}
var body: some View {
Button(action: onSelect) {
VStack(spacing: 0) {
// Badge overlay
ZStack(alignment: .top) {
// Card preview
VStack(spacing: 0) {
// Banner (just the background, no overlapping elements)
bannerContent
.frame(height: bannerHeight)
.clipped()
// Content area with overlapping avatar
contentWithAvatar
.offset(y: showsAvatarInContent ? -avatarSize / 2 : 0)
.padding(.bottom, showsAvatarInContent ? -avatarSize / 2 : 0)
}
.frame(width: cardWidth, height: cardHeight)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.stroke(
isSelected ? theme.primaryColor : .clear,
lineWidth: Design.LineWidth.thick
)
)
.shadow(
color: Color.Text.secondary.opacity(Design.Opacity.subtle),
radius: Design.Shadow.radiusMedium,
y: Design.Shadow.offsetMedium
)
// Badges
badgeOverlay
.offset(y: -Design.Spacing.small)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(layout.displayName)
.accessibilityValue(isSelected ? String(localized: "Selected") : "")
.accessibilityHint(layout.description)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
// MARK: - Banner Content (no avatar overlay - that's in content area)
@ViewBuilder
private var bannerContent: some View {
switch layout {
case .profileBanner:
profileBannerContent
case .coverWithAvatar:
coverBackground
case .coverWithCenteredLogo:
coverWithCenteredLogoContent
case .coverWithLogoBadge:
coverWithLogoBadgeContent
case .avatarAndLogoSideBySide:
coverBackground
}
}
/// Profile photo fills the entire banner
private var profileBannerContent: some View {
ZStack {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
// Fallback gradient with icon
LinearGradient(
colors: [theme.primaryColor, theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: avatarSystemName)
.font(.system(size: Design.BaseFontSize.display))
.foregroundStyle(theme.textColor.opacity(Design.Opacity.medium))
}
}
}
/// Cover image with logo centered
private var coverWithCenteredLogoContent: some View {
ZStack {
coverBackground
// Logo centered
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: logoSize)
} else {
// Logo placeholder
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(theme.accentColor)
.frame(width: logoSize, height: logoSize)
.overlay(
Image(systemName: "building.2")
.font(.title2)
.foregroundStyle(theme.textColor)
)
}
}
}
/// Cover image with small logo badge in corner
private var coverWithLogoBadgeContent: some View {
ZStack(alignment: .bottomTrailing) {
coverBackground
// Logo badge in corner
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(height: logoSize / 1.5)
.padding(Design.Spacing.xSmall)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
.padding(Design.Spacing.xSmall)
} else {
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(theme.accentColor)
.frame(width: logoSize / 1.5, height: logoSize / 1.5)
.overlay(
Image(systemName: "building.2")
.font(.caption)
.foregroundStyle(theme.textColor)
)
.padding(Design.Spacing.xSmall)
}
}
}
// MARK: - Content With Avatar
/// Content area with avatar overlapping from banner
private var contentWithAvatar: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Avatar row (for layouts that show avatar in content)
if showsAvatarInContent {
HStack(spacing: Design.Spacing.small) {
profileAvatar(size: avatarSize)
// Side-by-side: show logo badge next to avatar
if isSideBySideLayout {
logoBadge(size: avatarSize)
}
}
}
// Placeholder text lines
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.hint))
.frame(height: Design.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xLarge)
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.frame(height: Design.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xxLarge)
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.frame(height: Design.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xLarge)
Spacer()
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.top, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.small)
}
/// Logo badge for side-by-side layout
private func logoBadge(size: CGFloat) -> some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.padding(Design.Spacing.xSmall)
} else {
Image(systemName: "building.2")
.font(.system(size: size / 2.5))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: size, height: size)
.background(theme.accentColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
)
}
// MARK: - Helper Views
private var coverBackground: some View {
Group {
if let coverData = coverPhotoData, let uiImage = UIImage(data: coverData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
// Placeholder with "Cover" indicator
LinearGradient(
colors: [theme.primaryColor.opacity(Design.Opacity.light), theme.secondaryColor.opacity(Design.Opacity.light)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.title2)
.foregroundStyle(Color.Text.tertiary)
Text("Cover")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
}
}
}
.clipped()
}
private func profileAvatar(size: CGFloat) -> some View {
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
} else {
Image(systemName: avatarSystemName)
.font(.system(size: size / 2.5))
.foregroundStyle(theme.textColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.accentColor)
}
}
.frame(width: size, height: size)
.clipShape(.circle)
.overlay(
Circle()
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
)
}
// MARK: - Body Content
// MARK: - Badges
@ViewBuilder
private var badgeOverlay: some View {
if needsMoreImages {
LayoutBadge(
text: "More images required",
iconName: "lock.fill",
backgroundColor: Color.Text.primary.opacity(Design.Opacity.strong)
)
} else if isSuggested && !isSelected {
LayoutBadge(
text: "Suggested",
iconName: "star.fill",
backgroundColor: Color.Badge.star
)
}
}
}
// MARK: - Layout Badge
private struct LayoutBadge: View {
let text: String
let iconName: String
let backgroundColor: Color
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: iconName)
.font(.caption2)
Text(text)
.font(.caption2)
.bold()
}
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(backgroundColor)
.foregroundStyle(.white)
.clipShape(.capsule)
}
}
// MARK: - Preview
#Preview {
@Previewable @State var selectedLayout: CardHeaderLayout = .profileBanner
HeaderLayoutPickerView(
selectedLayout: $selectedLayout,
photoData: nil,
coverPhotoData: nil,
logoData: nil,
avatarSystemName: "person.crop.circle",
theme: .coral,
displayName: "John Doe",
role: "Developer",
company: "Acme Inc"
)
}