From 3807ba38ddce906d07c3e639f8865967cb501e4a Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 9 Jan 2026 14:56:23 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Design/DesignConstants.swift | 2 +- BusinessCard/Views/BusinessCardView.swift | 135 ++++++++++-------- BusinessCard/Views/CardEditorView.swift | 73 ++++++---- .../Components/AddedContactFieldsView.swift | 10 +- .../Components/ContactFieldPickerView.swift | 6 +- .../Sheets/ContactFieldEditorSheet.swift | 8 +- 6 files changed, 136 insertions(+), 98 deletions(-) diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index 8a33c09..ea583df 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -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 diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index f60a643..76c0bd9 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -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 - ) - - VStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "person.fill") - .font(.system(size: Design.BaseFontSize.display, weight: .bold)) - Text("Profile") - .font(.title3) - .bold() + 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() + } + .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 - ) - - 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() + 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") + .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 - ) - - VStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "photo.fill") - .font(.system(size: Design.BaseFontSize.display, weight: .bold)) - Text("Cover") - .font(.title3) - .bold() + 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() + } + .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) { diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index 9ea6dc0..d8138b9 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -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 + if hasOverlappingContent { + overlayContent + .padding(.leading, Design.Spacing.large) + .padding(.trailing, Design.Spacing.large) + .offset(y: Design.CardSize.avatarOverlap) + } + } - // Overlay content based on layout + // 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() diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift index 8a33f44..2c4cb1b 100644 --- a/BusinessCard/Views/Components/AddedContactFieldsView.swift +++ b/BusinessCard/Views/Components/AddedContactFieldsView.swift @@ -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() diff --git a/BusinessCard/Views/Components/ContactFieldPickerView.swift b/BusinessCard/Views/Components/ContactFieldPickerView.swift index 5d3c4de..f38dca0 100644 --- a/BusinessCard/Views/Components/ContactFieldPickerView.swift +++ b/BusinessCard/Views/Components/ContactFieldPickerView.swift @@ -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() diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift index 25e5034..88b40fb 100644 --- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift @@ -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()