478 lines
17 KiB
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"
|
|
)
|
|
}
|