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 avatarLarge: CGFloat = 80
static let avatarOverlap: CGFloat = 40 static let avatarOverlap: CGFloat = 40
static let logoSize: CGFloat = 64 static let logoSize: CGFloat = 64
static let bannerHeight: CGFloat = 140 static let bannerHeight: CGFloat = 240
static let qrSize: CGFloat = 200 static let qrSize: CGFloat = 200
static let qrSizeLarge: CGFloat = 260 static let qrSizeLarge: CGFloat = 260
static let colorSwatchSize: CGFloat = 40 static let colorSwatchSize: CGFloat = 40

View File

@ -49,6 +49,7 @@ private struct CardBannerView: View {
} }
} }
.frame(height: Design.CardSize.bannerHeight) .frame(height: Design.CardSize.bannerHeight)
.clipped()
} }
} }
@ -58,11 +59,13 @@ private struct ProfileBannerContent: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
GeometryReader { geometry in
ZStack { ZStack {
if let photoData = card.photoData, let uiImage = UIImage(data: photoData) { if let photoData = card.photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped() .clipped()
} else { } else {
LinearGradient( LinearGradient(
@ -83,6 +86,7 @@ private struct ProfileBannerContent: View {
} }
} }
} }
}
// MARK: - Logo Banner Content // MARK: - Logo Banner Content
@ -90,6 +94,7 @@ private struct LogoBannerContent: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
GeometryReader { geometry in
ZStack { ZStack {
LinearGradient( LinearGradient(
colors: [card.theme.primaryColor, card.theme.secondaryColor], colors: [card.theme.primaryColor, card.theme.secondaryColor],
@ -101,6 +106,7 @@ private struct LogoBannerContent: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped() .clipped()
} else { } else {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: Design.Spacing.xSmall) {
@ -115,6 +121,7 @@ private struct LogoBannerContent: View {
} }
} }
} }
}
// MARK: - Cover Banner Content // MARK: - Cover Banner Content
@ -122,10 +129,12 @@ private struct CoverBannerContent: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
GeometryReader { geometry in
if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { if let coverPhotoData = card.coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped() .clipped()
} else { } else {
ZStack { ZStack {
@ -147,6 +156,7 @@ private struct CoverBannerContent: View {
} }
} }
} }
}
// MARK: - Content View // MARK: - Content View
@ -325,7 +335,7 @@ private struct ContactFieldsListView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(card.orderedContactFields) { field in ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field) { ContactFieldRowView(field: field, themeColor: card.theme.primaryColor) {
if let url = field.buildURL() { if let url = field.buildURL() {
openURL(url) openURL(url)
} }
@ -337,6 +347,7 @@ private struct ContactFieldsListView: View {
private struct ContactFieldRowView: View { private struct ContactFieldRowView: View {
let field: ContactField let field: ContactField
let themeColor: Color
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
@ -346,7 +357,7 @@ private struct ContactFieldRowView: View {
.font(.body) .font(.body)
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(field.iconColor) .background(themeColor)
.clipShape(.circle) .clipShape(.circle)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {

View File

@ -212,7 +212,7 @@ struct CardEditorView: View {
if !contactFields.isEmpty { if !contactFields.isEmpty {
Section { Section {
ForEach(contactFields) { field in ForEach(contactFields) { field in
ContactFieldRowView(field: field) { ContactFieldRowView(field: field, themeColor: selectedTheme.primaryColor) {
fieldToEdit = field fieldToEdit = field
} }
} }
@ -231,7 +231,7 @@ struct CardEditorView: View {
// Add new contact fields // Add new contact fields
Section { Section {
ContactFieldPickerView { fieldType in ContactFieldPickerView(themeColor: selectedTheme.primaryColor) { fieldType in
selectedFieldTypeForAdd = fieldType selectedFieldTypeForAdd = fieldType
} }
} header: { } header: {
@ -284,6 +284,7 @@ struct CardEditorView: View {
fieldType: fieldType, fieldType: fieldType,
initialValue: "", initialValue: "",
initialTitle: fieldType.titleSuggestions.first ?? "", initialTitle: fieldType.titleSuggestions.first ?? "",
themeColor: selectedTheme.primaryColor,
onSave: { value, title in onSave: { value, title in
addContactField(fieldType: fieldType, value: value, title: title) addContactField(fieldType: fieldType, value: value, title: title)
} }
@ -294,6 +295,7 @@ struct CardEditorView: View {
fieldType: field.fieldType, fieldType: field.fieldType,
initialValue: field.value, initialValue: field.value,
initialTitle: field.title, initialTitle: field.title,
themeColor: selectedTheme.primaryColor,
onSave: { value, title in onSave: { value, title in
updateContactField(id: field.id, value: value, title: title) updateContactField(id: field.id, value: value, title: title)
}, },
@ -555,6 +557,7 @@ private struct ImageLayoutRow: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
// Live card preview based on selected layout // Live card preview based on selected layout
VStack(spacing: 0) {
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
// Banner preview based on layout // Banner preview based on layout
EditorBannerPreviewView( EditorBannerPreviewView(
@ -569,10 +572,18 @@ private struct ImageLayoutRow: View {
// Overlay content based on layout // Overlay content based on layout
if hasOverlappingContent { if hasOverlappingContent {
overlayContent overlayContent
.offset(x: Design.Spacing.large, y: Design.CardSize.avatarOverlap) .padding(.leading, Design.Spacing.large)
.padding(.trailing, Design.Spacing.large)
.offset(y: Design.CardSize.avatarOverlap)
}
}
// Spacer to make room for overlapping content
if hasOverlappingContent {
Spacer()
.frame(height: Design.CardSize.avatarOverlap)
} }
} }
.padding(.bottom, hasOverlappingContent ? Design.CardSize.avatarOverlap : 0)
// Layout selector button // Layout selector button
Button(action: onSelectLayout) { Button(action: onSelectLayout) {
@ -628,14 +639,16 @@ private struct EditorBannerPreviewView: View {
let selectedHeaderLayout: CardHeaderLayout let selectedHeaderLayout: CardHeaderLayout
var body: some View { var body: some View {
GeometryReader { geometry in
Group { Group {
switch selectedHeaderLayout.bannerContent { switch selectedHeaderLayout.bannerContent {
case .profile: case .profile:
profileBannerPreview profileBannerPreview(size: geometry.size)
case .logo: case .logo:
logoBannerPreview logoBannerPreview(size: geometry.size)
case .cover: case .cover:
coverBannerPreview coverBannerPreview(size: geometry.size)
}
} }
} }
.frame(height: Design.CardSize.bannerHeight) .frame(height: Design.CardSize.bannerHeight)
@ -645,12 +658,13 @@ private struct EditorBannerPreviewView: View {
// MARK: - Layout Previews // MARK: - Layout Previews
/// Profile photo fills the banner /// Profile photo fills the banner
private var profileBannerPreview: some View { private func profileBannerPreview(size: CGSize) -> some View {
ZStack { ZStack {
if let photoData, let uiImage = UIImage(data: photoData) { if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: size.width, height: size.height)
.clipped() .clipped()
} else { } else {
themeGradient themeGradient
@ -668,7 +682,7 @@ private struct EditorBannerPreviewView: View {
} }
/// Logo (3:2) fills the banner /// Logo (3:2) fills the banner
private var logoBannerPreview: some View { private func logoBannerPreview(size: CGSize) -> some View {
ZStack { ZStack {
themeGradient themeGradient
@ -676,6 +690,7 @@ private struct EditorBannerPreviewView: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: size.width, height: size.height)
.clipped() .clipped()
} else { } else {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: Design.Spacing.xSmall) {
@ -691,12 +706,13 @@ private struct EditorBannerPreviewView: View {
} }
/// Cover photo fills the banner /// Cover photo fills the banner
private var coverBannerPreview: some View { private func coverBannerPreview(size: CGSize) -> some View {
Group { Group {
if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) { if let coverPhotoData, let uiImage = UIImage(data: coverPhotoData) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: size.width, height: size.height)
.clipped() .clipped()
} else { } else {
ZStack { ZStack {
@ -926,6 +942,7 @@ private struct ProfilePhotoView: View {
private struct ContactFieldRowView: View { private struct ContactFieldRowView: View {
let field: AddedContactField let field: AddedContactField
let themeColor: Color
let onTap: () -> Void let onTap: () -> Void
var body: some View { var body: some View {
@ -939,7 +956,7 @@ private struct ContactFieldRowView: View {
// Icon // Icon
Circle() Circle()
.fill(field.fieldType.iconColor) .fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay( .overlay(
field.fieldType.iconImage() 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 /// Displays a vertical list of added contact fields with tap to edit and drag to reorder
struct AddedContactFieldsView: View { struct AddedContactFieldsView: View {
@Binding var fields: [AddedContactField] @Binding var fields: [AddedContactField]
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
let onEdit: (AddedContactField) -> Void let onEdit: (AddedContactField) -> Void
@State private var draggingField: AddedContactField? @State private var draggingField: AddedContactField?
@ -51,12 +52,13 @@ struct AddedContactFieldsView: View {
ForEach(fields) { field in ForEach(fields) { field in
FieldRow( FieldRow(
field: field, field: field,
themeColor: themeColor,
onTap: { onEdit(field) }, onTap: { onEdit(field) },
onDelete: { deleteField(field) } onDelete: { deleteField(field) }
) )
.draggable(field.id.uuidString) { .draggable(field.id.uuidString) {
// Drag preview // Drag preview
FieldRowPreview(field: field) FieldRowPreview(field: field, themeColor: themeColor)
} }
.dropDestination(for: String.self) { items, _ in .dropDestination(for: String.self) { items, _ in
guard let droppedId = items.first, guard let droppedId = items.first,
@ -94,11 +96,12 @@ struct AddedContactFieldsView: View {
/// Preview shown while dragging a field /// Preview shown while dragging a field
private struct FieldRowPreview: View { private struct FieldRowPreview: View {
let field: AddedContactField let field: AddedContactField
let themeColor: Color
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Circle() Circle()
.fill(field.fieldType.iconColor) .fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay( .overlay(
field.fieldType.iconImage() field.fieldType.iconImage()
@ -130,6 +133,7 @@ private struct FieldRowPreview: View {
/// A display row for a contact field - tap to edit, hold to drag /// A display row for a contact field - tap to edit, hold to drag
private struct FieldRow: View { private struct FieldRow: View {
let field: AddedContactField let field: AddedContactField
let themeColor: Color
let onTap: () -> Void let onTap: () -> Void
let onDelete: () -> Void let onDelete: () -> Void
@ -143,7 +147,7 @@ private struct FieldRow: View {
// Icon // Icon
Circle() Circle()
.fill(field.fieldType.iconColor) .fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay( .overlay(
field.fieldType.iconImage() field.fieldType.iconImage()

View File

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

View File

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