Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-09 14:56:23 -06:00
parent 4bec90cc31
commit 3807ba38dd
6 changed files with 136 additions and 98 deletions

View File

@ -20,7 +20,7 @@ extension Design {
static let avatarLarge: CGFloat = 80
static let avatarOverlap: CGFloat = 40
static let logoSize: CGFloat = 64
static let bannerHeight: CGFloat = 140
static let bannerHeight: CGFloat = 240
static let qrSize: CGFloat = 200
static let qrSizeLarge: CGFloat = 260
static let colorSwatchSize: CGFloat = 40

View File

@ -49,6 +49,7 @@ private struct CardBannerView: View {
}
}
.frame(height: Design.CardSize.bannerHeight)
.clipped()
}
}
@ -58,27 +59,30 @@ private struct ProfileBannerContent: View {
let card: BusinessCard
var body: some View {
ZStack {
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
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")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Profile")
.font(.title3)
.bold()
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "person.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Profile")
.font(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
@ -90,27 +94,30 @@ private struct LogoBannerContent: View {
let card: BusinessCard
var body: some View {
ZStack {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
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()
.clipped()
} else {
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "building.2.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Logo")
.font(.title3)
.bold()
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")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Logo")
.font(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
@ -122,27 +129,30 @@ private struct CoverBannerContent: View {
let card: BusinessCard
var body: some View {
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipped()
} else {
ZStack {
LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
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")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Cover")
.font(.title3)
.bold()
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "photo.fill")
.font(.system(size: Design.BaseFontSize.display, weight: .bold))
Text("Cover")
.font(.title3)
.bold()
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
.foregroundStyle(card.theme.textColor.opacity(Design.Opacity.medium))
}
}
}
@ -325,7 +335,7 @@ private struct ContactFieldsListView: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field) {
ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
if let url = field.buildURL() {
openURL(url)
}
@ -337,6 +347,7 @@ private struct ContactFieldsListView: View {
private struct ContactFieldRowView: View {
let field: ContactField
let themeColor: Color
let action: () -> Void
var body: some View {
@ -346,7 +357,7 @@ private struct ContactFieldRowView: View {
.font(.body)
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(field.iconColor)
.background(themeColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {

View File

@ -212,7 +212,7 @@ struct CardEditorView: View {
if !contactFields.isEmpty {
Section {
ForEach(contactFields) { field in
ContactFieldRowView(field: field) {
ContactFieldRowView(field: field, themeColor: selectedTheme.primaryColor) {
fieldToEdit = field
}
}
@ -231,7 +231,7 @@ struct CardEditorView: View {
// Add new contact fields
Section {
ContactFieldPickerView { fieldType in
ContactFieldPickerView(themeColor: selectedTheme.primaryColor) { fieldType in
selectedFieldTypeForAdd = fieldType
}
} header: {
@ -284,6 +284,7 @@ struct CardEditorView: View {
fieldType: fieldType,
initialValue: "",
initialTitle: fieldType.titleSuggestions.first ?? "",
themeColor: selectedTheme.primaryColor,
onSave: { value, title in
addContactField(fieldType: fieldType, value: value, title: title)
}
@ -294,6 +295,7 @@ struct CardEditorView: View {
fieldType: field.fieldType,
initialValue: field.value,
initialTitle: field.title,
themeColor: selectedTheme.primaryColor,
onSave: { value, title in
updateContactField(id: field.id, value: value, title: title)
},
@ -555,24 +557,33 @@ private struct ImageLayoutRow: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Live card preview based on selected layout
ZStack(alignment: .bottomLeading) {
// Banner preview based on layout
EditorBannerPreviewView(
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme,
selectedHeaderLayout: selectedHeaderLayout
)
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) {
// Banner preview based on layout
EditorBannerPreviewView(
photoData: photoData,
coverPhotoData: coverPhotoData,
logoData: logoData,
avatarSystemName: avatarSystemName,
selectedTheme: selectedTheme,
selectedHeaderLayout: selectedHeaderLayout
)
// Overlay content based on layout
// Overlay content based on layout
if hasOverlappingContent {
overlayContent
.padding(.leading, Design.Spacing.large)
.padding(.trailing, Design.Spacing.large)
.offset(y: Design.CardSize.avatarOverlap)
}
}
// Spacer to make room for overlapping content
if hasOverlappingContent {
overlayContent
.offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap)
Spacer()
.frame(height: Design.CardSize.avatarOverlap)
}
}
.padding(.bottom, hasOverlappingContent ? Design.CardSize.avatarOverlap : 0)
// Layout selector button
Button(action: onSelectLayout) {
@ -628,14 +639,16 @@ private struct EditorBannerPreviewView: View {
let selectedHeaderLayout: CardHeaderLayout
var body: some View {
Group {
switch selectedHeaderLayout.bannerContent {
case .profile:
profileBannerPreview
case .logo:
logoBannerPreview
case .cover:
coverBannerPreview
GeometryReader { geometry in
Group {
switch selectedHeaderLayout.bannerContent {
case .profile:
profileBannerPreview(size: geometry.size)
case .logo:
logoBannerPreview(size: geometry.size)
case .cover:
coverBannerPreview(size: geometry.size)
}
}
}
.frame(height: Design.CardSize.bannerHeight)
@ -645,12 +658,13 @@ private struct EditorBannerPreviewView: View {
// MARK: - Layout Previews
/// Profile photo fills the banner
private var profileBannerPreview: some View {
private func profileBannerPreview(size: CGSize) -> some View {
ZStack {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
themeGradient
@ -668,7 +682,7 @@ private struct EditorBannerPreviewView: View {
}
/// Logo (3:2) fills the banner
private var logoBannerPreview: some View {
private func logoBannerPreview(size: CGSize) -> some View {
ZStack {
themeGradient
@ -676,6 +690,7 @@ private struct EditorBannerPreviewView: View {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
VStack(spacing: Design.Spacing.xSmall) {
@ -691,12 +706,13 @@ private struct EditorBannerPreviewView: View {
}
/// Cover photo fills the banner
private var coverBannerPreview: some View {
private func coverBannerPreview(size: CGSize) -> some View {
Group {
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height)
.clipped()
} else {
ZStack {
@ -926,6 +942,7 @@ private struct ProfilePhotoView: View {
private struct ContactFieldRowView: View {
let field: AddedContactField
let themeColor: Color
let onTap: () -> Void
var body: some View {
@ -939,7 +956,7 @@ private struct ContactFieldRowView: View {
// Icon
Circle()
.fill(field.fieldType.iconColor)
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()

View File

@ -39,6 +39,7 @@ struct AddedContactField: Identifiable, Equatable {
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
struct AddedContactFieldsView: View {
@Binding var fields: [AddedContactField]
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
let onEdit: (AddedContactField) -> Void
@State private var draggingField: AddedContactField?
@ -51,12 +52,13 @@ struct AddedContactFieldsView: View {
ForEach(fields) { field in
FieldRow(
field: field,
themeColor: themeColor,
onTap: { onEdit(field) },
onDelete: { deleteField(field) }
)
.draggable(field.id.uuidString) {
// Drag preview
FieldRowPreview(field: field)
FieldRowPreview(field: field, themeColor: themeColor)
}
.dropDestination(for: String.self) { items, _ in
guard let droppedId = items.first,
@ -94,11 +96,12 @@ struct AddedContactFieldsView: View {
/// Preview shown while dragging a field
private struct FieldRowPreview: View {
let field: AddedContactField
let themeColor: Color
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(field.fieldType.iconColor)
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()
@ -130,6 +133,7 @@ private struct FieldRowPreview: View {
/// A display row for a contact field - tap to edit, hold to drag
private struct FieldRow: View {
let field: AddedContactField
let themeColor: Color
let onTap: () -> Void
let onDelete: () -> Void
@ -143,7 +147,7 @@ private struct FieldRow: View {
// Icon
Circle()
.fill(field.fieldType.iconColor)
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()

View File

@ -3,6 +3,7 @@ import Bedrock
/// Grid view for selecting contact field types to add
struct ContactFieldPickerView: View {
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
let onSelect: (ContactFieldType) -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.medium), count: 3)
@ -27,7 +28,7 @@ struct ContactFieldPickerView: View {
LazyVGrid(columns: columns, spacing: Design.Spacing.large) {
ForEach(ContactFieldType.allCases) { fieldType in
FieldTypeButton(fieldType: fieldType) {
FieldTypeButton(fieldType: fieldType, themeColor: themeColor) {
onSelect(fieldType)
}
}
@ -40,13 +41,14 @@ struct ContactFieldPickerView: View {
private struct FieldTypeButton: View {
let fieldType: ContactFieldType
let themeColor: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.small) {
Circle()
.fill(fieldType.iconColor)
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
fieldType.iconImage()

View File

@ -8,6 +8,7 @@ struct ContactFieldEditorSheet: View {
let fieldType: ContactFieldType
let initialValue: String
let initialTitle: String
let themeColor: Color
let onSave: (String, String) -> Void
let onDelete: (() -> Void)?
@ -19,12 +20,14 @@ struct ContactFieldEditorSheet: View {
fieldType: ContactFieldType,
initialValue: String = "",
initialTitle: String = "",
themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2),
onSave: @escaping (String, String) -> Void,
onDelete: (() -> Void)? = nil
) {
self.fieldType = fieldType
self.initialValue = initialValue
self.initialTitle = initialTitle
self.themeColor = themeColor
self.onSave = onSave
self.onDelete = onDelete
_value = State(initialValue: initialValue)
@ -61,7 +64,7 @@ struct ContactFieldEditorSheet: View {
NavigationStack {
VStack(spacing: 0) {
// Header with icon
FieldHeaderView(fieldType: fieldType)
FieldHeaderView(fieldType: fieldType, themeColor: themeColor)
Divider()
@ -179,11 +182,12 @@ struct ContactFieldEditorSheet: View {
private struct FieldHeaderView: View {
let fieldType: ContactFieldType
let themeColor: Color
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(fieldType.iconColor)
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
fieldType.iconImage()