Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b26f15cec6
commit
77f70386d2
@ -3,7 +3,6 @@ import Foundation
|
||||
enum AppTab: String, CaseIterable, Hashable, Identifiable {
|
||||
case cards
|
||||
case share
|
||||
case customize
|
||||
case contacts
|
||||
case widgets
|
||||
|
||||
|
||||
@ -43,9 +43,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+1 555 123 4567" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
|
||||
@ -57,6 +54,7 @@
|
||||
|
||||
},
|
||||
"Add a QR widget so your card is always one tap away." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -126,6 +124,7 @@
|
||||
|
||||
},
|
||||
"Change image layout" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -160,6 +159,7 @@
|
||||
|
||||
},
|
||||
"Create multiple business cards" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -180,11 +180,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Create your first card" : {
|
||||
|
||||
},
|
||||
"Custom Links" : {
|
||||
|
||||
},
|
||||
"Customize your card" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -211,6 +215,9 @@
|
||||
},
|
||||
"Delete Field" : {
|
||||
|
||||
},
|
||||
"Design and share polished digital business cards for every context." : {
|
||||
|
||||
},
|
||||
"Developer" : {
|
||||
|
||||
@ -229,9 +236,6 @@
|
||||
},
|
||||
"Email or Username" : {
|
||||
|
||||
},
|
||||
"Example" : {
|
||||
|
||||
},
|
||||
"Ext." : {
|
||||
|
||||
@ -508,9 +512,6 @@
|
||||
},
|
||||
"Support & Funding" : {
|
||||
|
||||
},
|
||||
"Tap \"New Card\" to create your first card" : {
|
||||
|
||||
},
|
||||
"Tap a field below to add it" : {
|
||||
|
||||
@ -544,6 +545,7 @@
|
||||
|
||||
},
|
||||
"The #1 Digital Business Card App" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -581,6 +583,7 @@
|
||||
|
||||
},
|
||||
"Used by Industry Leaders" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -660,21 +663,6 @@
|
||||
},
|
||||
"Work" : {
|
||||
|
||||
},
|
||||
"Your card will appear here" : {
|
||||
|
||||
},
|
||||
"Your Company" : {
|
||||
|
||||
},
|
||||
"Your Name" : {
|
||||
|
||||
},
|
||||
"Your Role" : {
|
||||
|
||||
},
|
||||
"your@email.com" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import SwiftData
|
||||
|
||||
struct CardCarouselView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
@Bindable var cardStore = appState.cardStore
|
||||
let hasCards = !cardStore.cards.isEmpty
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
HStack {
|
||||
Text(hasCards ? "Create multiple business cards" : "Your card will appear here")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if hasCards {
|
||||
TabView(selection: $cardStore.selectedCardID) {
|
||||
ForEach(cardStore.cards) { card in
|
||||
BusinessCardView(card: card, isCompact: true)
|
||||
.tag(Optional(card.id))
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
.frame(height: Design.CardSize.cardHeightCompact + Design.Spacing.xxLarge)
|
||||
|
||||
if let selected = cardStore.selectedCard {
|
||||
CardDefaultToggleView(card: selected) {
|
||||
cardStore.setDefaultCard(selected)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DemoCardView()
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CardDefaultToggleView: View {
|
||||
let card: BusinessCard
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(
|
||||
card.isDefault ? String.localized("Default card") : String.localized("Set as default"),
|
||||
systemImage: card.isDefault ? "checkmark.seal.fill" : "checkmark.seal",
|
||||
action: action
|
||||
)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.Accent.red)
|
||||
.accessibilityHint(String.localized("Sets this card as your default sharing card"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A demo card shown when the user has no cards yet.
|
||||
/// Displays placeholder content with an "Example" badge to prompt card creation.
|
||||
private struct DemoCardView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Circle()
|
||||
.fill(Color.AppText.inverted)
|
||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||
.overlay(
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundStyle(Color.CardPalette.coral)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Your Name")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
Text("Your Role")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.almostFull))
|
||||
Text("Your Company")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.medium))
|
||||
}
|
||||
|
||||
Spacer(minLength: Design.Spacing.small)
|
||||
|
||||
Text("Example")
|
||||
.font(.caption)
|
||||
.bold()
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(Color.AppText.inverted.opacity(Design.Opacity.hint))
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
|
||||
Divider()
|
||||
.overlay(Color.AppText.inverted.opacity(Design.Opacity.medium))
|
||||
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "envelope")
|
||||
.font(.caption)
|
||||
Text("your@email.com")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.heavy))
|
||||
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "phone")
|
||||
.font(.caption)
|
||||
Text("+1 555 123 4567")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(Color.AppText.inverted.opacity(Design.Opacity.heavy))
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.CardPalette.coral, Color.CardPalette.sand],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||
.shadow(
|
||||
color: Color.AppText.secondary.opacity(Design.Opacity.hint),
|
||||
radius: Design.Shadow.radiusLarge,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetMedium
|
||||
)
|
||||
.opacity(Design.Opacity.strong)
|
||||
|
||||
Text("Tap \"New Card\" to create your first card")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.AppText.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String.localized("Example business card"))
|
||||
.accessibilityHint(String.localized("Create a new card to replace this example"))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CardCarouselView()
|
||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||
}
|
||||
@ -5,81 +5,156 @@ import SwiftData
|
||||
struct CardsHomeView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showingCreateCard = false
|
||||
@State private var showingEditCard = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var cardStore = appState.cardStore
|
||||
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
HeroBannerView()
|
||||
SectionTitleView(
|
||||
title: String.localized("Create your digital business card"),
|
||||
subtitle: String.localized("Design and share polished cards for every context.")
|
||||
)
|
||||
CardCarouselView()
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
if appState.cardStore.cards.isEmpty {
|
||||
PrimaryActionButton(
|
||||
title: String.localized("Create Card"),
|
||||
systemImage: "plus"
|
||||
) {
|
||||
showingCreateCard = true
|
||||
}
|
||||
} else {
|
||||
PrimaryActionButton(
|
||||
title: String.localized("Send my card"),
|
||||
systemImage: "paperplane.fill"
|
||||
) {
|
||||
appState.selectedTab = .share
|
||||
}
|
||||
|
||||
Button(String.localized("New Card"), systemImage: "plus") {
|
||||
showingCreateCard = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.Accent.ink)
|
||||
.controlSize(.large)
|
||||
.accessibilityHint(String.localized("Create a new business card"))
|
||||
}
|
||||
}
|
||||
|
||||
WidgetsCalloutView()
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.background(
|
||||
ZStack {
|
||||
// Background gradient
|
||||
LinearGradient(
|
||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if cardStore.cards.isEmpty {
|
||||
EmptyCardsView(onCreateCard: { showingCreateCard = true })
|
||||
} else {
|
||||
// Full-screen swipeable cards
|
||||
TabView(selection: $cardStore.selectedCardID) {
|
||||
ForEach(cardStore.cards) { card in
|
||||
CardPageView(
|
||||
card: card,
|
||||
isDefault: card.isDefault,
|
||||
onSetDefault: { cardStore.setDefaultCard(card) }
|
||||
)
|
||||
.navigationTitle(String.localized("My Cards"))
|
||||
.tag(Optional(card.id))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .automatic))
|
||||
.indexViewStyle(.page(backgroundDisplayMode: .automatic))
|
||||
}
|
||||
}
|
||||
.navigationTitle(cardStore.selectedCard?.label ?? String.localized("My Cards"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(String.localized("Add Card"), systemImage: "plus") {
|
||||
showingCreateCard = true
|
||||
}
|
||||
.accessibilityHint(String.localized("Create a new business card"))
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
if cardStore.selectedCard != nil {
|
||||
if cardStore.cards.count > 1 {
|
||||
Button(String.localized("Delete"), systemImage: "trash") {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
.accessibilityHint(String.localized("Delete this card"))
|
||||
}
|
||||
|
||||
Button(String.localized("Edit"), systemImage: "pencil") {
|
||||
showingEditCard = true
|
||||
}
|
||||
.accessibilityHint(String.localized("Edit this card"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingCreateCard) {
|
||||
CardEditorView(card: nil) { newCard in
|
||||
appState.cardStore.addCard(newCard)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEditCard) {
|
||||
if let card = cardStore.selectedCard {
|
||||
CardEditorView(card: card) { updatedCard in
|
||||
appState.cardStore.updateCard(updatedCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(String.localized("Delete Card"), isPresented: $showingDeleteConfirmation) {
|
||||
Button(String.localized("Cancel"), role: .cancel) { }
|
||||
Button(String.localized("Delete"), role: .destructive) {
|
||||
if let card = cardStore.selectedCard {
|
||||
appState.cardStore.deleteCard(card)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this card? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SectionTitleView: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
// MARK: - Card Page View
|
||||
|
||||
private struct CardPageView: View {
|
||||
let card: BusinessCard
|
||||
let isDefault: Bool
|
||||
let onSetDefault: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(title)
|
||||
.font(.title3)
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
BusinessCardView(card: card)
|
||||
|
||||
// Default card toggle
|
||||
Button(
|
||||
isDefault ? String.localized("Default card") : String.localized("Set as default"),
|
||||
systemImage: isDefault ? "checkmark.seal.fill" : "checkmark.seal",
|
||||
action: onSetDefault
|
||||
)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.Accent.red)
|
||||
.accessibilityHint(String.localized("Sets this card as your default sharing card"))
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private struct EmptyCardsView: View {
|
||||
let onCreateCard: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "rectangle.stack.badge.plus")
|
||||
.font(.system(size: Design.BaseFontSize.display))
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Create your first card")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
Text(subtitle)
|
||||
|
||||
Text("Design and share polished digital business cards for every context.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
|
||||
PrimaryActionButton(
|
||||
title: String.localized("Create Card"),
|
||||
systemImage: "plus"
|
||||
) {
|
||||
onCreateCard()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import SwiftData
|
||||
|
||||
struct CustomizeCardView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showingEditCard = false
|
||||
@State private var showingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
Text("Customize your card")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
BusinessCardView(card: card)
|
||||
|
||||
CardActionsView(
|
||||
onEdit: { showingEditCard = true },
|
||||
onDelete: { showingDeleteConfirmation = true },
|
||||
canDelete: appState.cardStore.cards.count > 1
|
||||
)
|
||||
|
||||
CardStylePickerView(selectedTheme: card.theme) { theme in
|
||||
appState.cardStore.setSelectedTheme(theme)
|
||||
}
|
||||
CardLayoutPickerView(selectedLayout: card.layoutStyle) { layout in
|
||||
appState.cardStore.setSelectedLayout(layout)
|
||||
}
|
||||
} else {
|
||||
EmptyStateView(
|
||||
title: String.localized("No card selected"),
|
||||
message: String.localized("Select a card to start customizing.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.background(Color.AppBackground.base)
|
||||
.navigationTitle(String.localized("Edit your card"))
|
||||
.sheet(isPresented: $showingEditCard) {
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
CardEditorView(card: card) { updatedCard in
|
||||
appState.cardStore.updateCard(updatedCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(String.localized("Delete Card"), isPresented: $showingDeleteConfirmation) {
|
||||
Button(String.localized("Cancel"), role: .cancel) { }
|
||||
Button(String.localized("Delete"), role: .destructive) {
|
||||
if let card = appState.cardStore.selectedCard {
|
||||
appState.cardStore.deleteCard(card)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this card? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CardActionsView: View {
|
||||
let onEdit: () -> Void
|
||||
let onDelete: () -> Void
|
||||
let canDelete: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Button(String.localized("Edit Details"), systemImage: "pencil", action: onEdit)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.Accent.ink)
|
||||
.accessibilityHint(String.localized("Edit card name, email, and other details"))
|
||||
|
||||
if canDelete {
|
||||
Button(String.localized("Delete"), systemImage: "trash", role: .destructive, action: onDelete)
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityHint(String.localized("Permanently delete this card"))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CardStylePickerView: View {
|
||||
let selectedTheme: CardTheme
|
||||
let onSelect: (CardTheme) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
Text("Card style")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
LazyVGrid(columns: gridColumns, spacing: Design.Spacing.small) {
|
||||
ForEach(CardTheme.all) { theme in
|
||||
Button(action: { onSelect(theme) }) {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(theme.primaryColor)
|
||||
.frame(height: Design.CardSize.avatarSize)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.stroke(
|
||||
selectedTheme.id == theme.id ? Color.Accent.red : Color.Text.inverted.opacity(Design.Opacity.medium),
|
||||
lineWidth: Design.LineWidth.medium
|
||||
)
|
||||
)
|
||||
|
||||
Text(theme.localizedName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String.localized("Card style"))
|
||||
.accessibilityValue(theme.localizedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
private var gridColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.small), count: 3)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CardLayoutPickerView: View {
|
||||
let selectedLayout: CardLayoutStyle
|
||||
let onSelect: (CardLayoutStyle) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
Text("Images & layout")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
Picker(String.localized("Layout"), selection: Binding(
|
||||
get: { selectedLayout },
|
||||
set: { onSelect($0) }
|
||||
)) {
|
||||
ForEach(CardLayoutStyle.allCases) { layout in
|
||||
Text(layout.displayName)
|
||||
.tag(layout)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text("Change image layout")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CustomizeCardView()
|
||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct HeroBannerView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
Text("The #1 Digital Business Card App")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
StatBadgeView(
|
||||
title: String.localized("4.9"),
|
||||
subtitle: String.localized("App Rating"),
|
||||
systemImage: "star.fill",
|
||||
badgeColor: Color.Badge.star
|
||||
)
|
||||
|
||||
StatBadgeView(
|
||||
title: String.localized("100k+"),
|
||||
subtitle: String.localized("Reviews"),
|
||||
systemImage: "person.3.fill",
|
||||
badgeColor: Color.Badge.neutral
|
||||
)
|
||||
}
|
||||
|
||||
Text("Used by Industry Leaders")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
BrandChipView(label: "Google")
|
||||
BrandChipView(label: "Tesla")
|
||||
BrandChipView(label: "Citi")
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.shadow(
|
||||
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||
radius: Design.Shadow.radiusMedium,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatBadgeView: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
let badgeColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
HStack(spacing: Design.Spacing.xxSmall) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(badgeColor)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrandChipView: View {
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.background(Color.AppBackground.accent)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HeroBannerView()
|
||||
}
|
||||
@ -16,10 +16,6 @@ struct RootTabView: View {
|
||||
ShareCardView()
|
||||
}
|
||||
|
||||
Tab(String.localized("Customize"), systemImage: "slider.horizontal.3", value: AppTab.customize) {
|
||||
CustomizeCardView()
|
||||
}
|
||||
|
||||
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) {
|
||||
ContactsView()
|
||||
}
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
struct WidgetsCalloutView: View {
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text("Share using widgets on your phone or watch")
|
||||
.font(.headline)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
Text("Add a QR widget so your card is always one tap away.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "applewatch")
|
||||
.font(.title)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.shadow(
|
||||
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||
radius: Design.Shadow.radiusMedium,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WidgetsCalloutView()
|
||||
.padding()
|
||||
.background(Color.AppBackground.base)
|
||||
}
|
||||
53
README.md
53
README.md
@ -12,12 +12,15 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
|
||||
|
||||
### My Cards
|
||||
|
||||
- Create and browse multiple cards in a compact carousel view
|
||||
- **Full-screen card display**: Swipe between your business cards in a full-screen view
|
||||
- Tap the **edit icon** (pencil) in the top right to edit the current card
|
||||
- Tap the **plus icon** to create a new card
|
||||
- Set a default card for sharing
|
||||
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows
|
||||
- **Profile photos**: Add a photo from your library or use an icon
|
||||
- **Company logos**: Upload a logo to display on your card's banner
|
||||
- **Rich profiles**: First/middle/last name, pronouns, headline, bio, accreditations
|
||||
- **Rich profiles**: First/middle/last name, prefix, maiden name, preferred name, pronouns, headline, bio, accreditations
|
||||
- **Clickable contact fields**: Tap any field to call, email, open link, or launch app
|
||||
|
||||
### Share
|
||||
|
||||
@ -27,16 +30,36 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
|
||||
- **Track shares**: Record who received your card and when
|
||||
- Placeholder actions for Apple Wallet and NFC (alerts included)
|
||||
|
||||
### Customize
|
||||
### Card Editor
|
||||
|
||||
Access via the edit (pencil) icon in the My Cards tab:
|
||||
|
||||
- **Horizontal color picker**: Scrollable theme swatches for quick selection
|
||||
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
|
||||
- **Expandable name section**: First, middle, last, suffix, preferred name
|
||||
- **Edit all card details**: Name, role, department, company, headline, email (with label), phone (with label & extension), website, location
|
||||
- **Social media links**: LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub, Threads, Telegram
|
||||
- **Payment links**: Venmo, Cash App
|
||||
- **Custom links**: Add up to 2 custom URLs with titles
|
||||
- **Suggestion chips**: Quick-fill suggestions for social link titles
|
||||
- **Expandable name section**: Prefix, first, middle, last, maiden name, suffix, preferred name, pronouns
|
||||
- **Edit all card details**: Name, role, department, company, headline, bio
|
||||
- **Accreditations**: Tag-based input with inline editing
|
||||
|
||||
#### Dynamic Contact Fields
|
||||
|
||||
Add unlimited contact fields of any type, reorder by drag-and-drop:
|
||||
|
||||
- **Contact**: Email, phone, website, address
|
||||
- **Social Media**: LinkedIn, X/Twitter, Instagram, Facebook, TikTok, Threads, Bluesky, Mastodon, Reddit, Twitch, YouTube, Snapchat, Pinterest
|
||||
- **Developer**: GitHub, GitLab, Stack Overflow, CodePen
|
||||
- **Messaging**: Telegram, WhatsApp, Discord, Slack, Matrix, Signal
|
||||
- **Support & Funding**: Patreon, Ko-fi, Buy Me a Coffee, GitHub Sponsors
|
||||
- **Payment**: Venmo, Cash App, PayPal, Zelle
|
||||
- **Scheduling**: Calendly
|
||||
- **Other**: Custom links with any URL
|
||||
|
||||
Each field has:
|
||||
- **Custom icons**: Brand-colored icons from asset catalog (not generic SF Symbols)
|
||||
- **Title/Label**: Add context like "Work", "Personal", or custom call-to-action
|
||||
- **Deep linking**: Opens native apps when available (X, Instagram, etc.)
|
||||
- **Drag-to-reorder**: Long press and drag to change order
|
||||
|
||||
- **Suggestion chips**: Quick-fill suggestions for field titles
|
||||
- **Live preview**: "Preview card" button to see changes before saving
|
||||
- **Delete cards** you no longer need
|
||||
|
||||
@ -103,16 +126,22 @@ App-specific extensions are in `Design/DesignConstants.swift`.
|
||||
|
||||
```
|
||||
BusinessCard/
|
||||
├── Assets.xcassets/
|
||||
│ └── SocialSymbols/ # Custom brand icons (LinkedIn, X, Instagram, etc.)
|
||||
├── Design/ # Design constants (extends Bedrock)
|
||||
├── Localization/ # String helpers
|
||||
├── Models/ # SwiftData models (BusinessCard, Contact)
|
||||
├── Models/
|
||||
│ ├── BusinessCard.swift # Main card model
|
||||
│ ├── Contact.swift # Received contacts
|
||||
│ ├── ContactField.swift # Dynamic contact fields (SwiftData)
|
||||
│ └── ContactFieldType.swift # Field type definitions with icons & URLs
|
||||
├── Protocols/ # Protocol definitions
|
||||
├── Resources/ # String Catalogs (.xcstrings)
|
||||
├── Services/ # Share link service, watch sync
|
||||
├── State/ # Observable stores (CardStore, ContactsStore)
|
||||
└── Views/
|
||||
├── Components/ # Reusable UI components (AvatarBadgeView, etc.)
|
||||
├── Sheets/ # Modal sheets (RecordContactSheet, etc.)
|
||||
├── Components/ # Reusable UI (ContactFieldPickerView, AddedContactFieldsView, etc.)
|
||||
├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
|
||||
└── [Feature].swift # Feature screens
|
||||
|
||||
BusinessCardWatch/ # watchOS app target
|
||||
|
||||
40
ROADMAP.md
40
ROADMAP.md
@ -6,6 +6,39 @@ This document tracks planned features and their implementation status.
|
||||
|
||||
### High Priority (Core User Value)
|
||||
|
||||
- [x] **Dynamic contact fields system** - Unlimited fields with custom ordering
|
||||
- New `ContactField` SwiftData model for flexible field storage
|
||||
- `ContactFieldType` struct with 30+ field types across 8 categories
|
||||
- Drag-to-reorder support with haptic feedback
|
||||
- Sheet-based field editor with type-specific keyboards and placeholders
|
||||
- Multiple fields of the same type allowed (e.g., multiple emails)
|
||||
|
||||
- [x] **Custom social icons** - Brand-colored icons from asset catalog
|
||||
- Custom `.symbolset` assets for all major platforms
|
||||
- LinkedIn, X/Twitter, Instagram, Facebook, TikTok, Threads
|
||||
- Bluesky, Mastodon, Reddit, Twitch, YouTube, Discord
|
||||
- GitHub, Telegram, Slack, Matrix, Patreon, Ko-fi
|
||||
- Proper `Image()` vs `Image(systemName:)` handling via `iconImage()` helper
|
||||
|
||||
- [x] **Extended name fields** - Prefix, maiden name, pronouns
|
||||
- Expandable name section in editor
|
||||
- Prefix (Dr., Mr., Mrs., etc.)
|
||||
- Maiden name with parentheses formatting in display name
|
||||
- Preferred name with quotes formatting
|
||||
- Pronouns displayed next to name on card
|
||||
|
||||
- [x] **Accreditations as tags** - Tag bubble UI with inline editing
|
||||
- Horizontal ScrollView of tag chips
|
||||
- Tap to edit, check to save, x to delete
|
||||
- Comma-separated storage for vCard compatibility
|
||||
|
||||
- [x] **Clickable contact fields** - Deep linking to apps and actions
|
||||
- Email → mailto:
|
||||
- Phone → tel:
|
||||
- Social links → app deep links or web fallback
|
||||
- Address → Maps
|
||||
- Website → Safari
|
||||
|
||||
- [x] **More card fields** - Social media links, custom URLs, pronouns, bio
|
||||
- Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub
|
||||
- Added: 2 custom link slots (title + URL)
|
||||
@ -78,6 +111,9 @@ This document tracks planned features and their implementation status.
|
||||
- [x] **Bedrock integration** - Design system, QR code generator
|
||||
- [x] **iOS-Watch sync** via App Groups
|
||||
- [x] **Unit tests** for models, stores, and new features
|
||||
- [x] **Fixed data persistence** - Removed aggressive store deletion on startup
|
||||
- [x] **Custom symbol assets** - Brand icons in asset catalog with proper rendering
|
||||
- [x] **Drag-and-drop reordering** - Using `draggable` and `dropDestination` modifiers
|
||||
|
||||
### Planned
|
||||
|
||||
@ -85,6 +121,7 @@ This document tracks planned features and their implementation status.
|
||||
- [ ] **Spotlight indexing** - Search cards from iOS search
|
||||
- [ ] **Siri shortcuts** - "Share my work card"
|
||||
- [ ] **App Intents** - iOS 17+ action button support
|
||||
- [ ] **Migrate legacy properties** - Move all cards to new ContactField array
|
||||
|
||||
---
|
||||
|
||||
@ -93,7 +130,8 @@ This document tracks planned features and their implementation status.
|
||||
- Features marked with 🔲 are planned but not yet implemented
|
||||
- Features requiring backend are deferred until infrastructure is available
|
||||
- Priority may shift based on user feedback
|
||||
- Legacy contact properties (email, phone, linkedIn, etc.) maintained for backward compatibility
|
||||
|
||||
---
|
||||
|
||||
*Last updated: January 2026*
|
||||
*Last updated: January 8, 2026*
|
||||
|
||||
@ -48,11 +48,29 @@ App-specific extensions are in `Design/DesignConstants.swift`:
|
||||
### Models
|
||||
|
||||
- `Models/BusinessCard.swift` — SwiftData model with:
|
||||
- Basic fields: name, role, company, email, phone, website, location
|
||||
- Rich fields: pronouns, bio, social links (LinkedIn, Twitter, Instagram, etc.)
|
||||
- Custom links: 2 slots for custom URLs
|
||||
- Photo: `photoData` stored with `@Attribute(.externalStorage)`
|
||||
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`
|
||||
- Name fields: prefix, firstName, middleName, lastName, maidenName, suffix, preferredName
|
||||
- Basic fields: role, company, email, phone, website, location
|
||||
- Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
|
||||
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
|
||||
- Legacy fields: social links (LinkedIn, Twitter, etc.) for backward compatibility
|
||||
- Photo: `photoData` and `logoData` stored with `@Attribute(.externalStorage)`
|
||||
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`, `orderedContactFields`
|
||||
- Helper methods: `addContactField`, `removeContactField`, `reorderContactFields`
|
||||
|
||||
- `Models/ContactField.swift` — SwiftData model for dynamic contact fields:
|
||||
- Properties: `typeId`, `value`, `title`, `orderIndex`
|
||||
- Relationship: `card` (inverse to BusinessCard)
|
||||
- Computed: `fieldType`, `displayName`, `iconImage()`, `iconColor`, `buildURL()`
|
||||
- Supports multiple fields of same type, drag-to-reorder
|
||||
|
||||
- `Models/ContactFieldType.swift` — Struct defining 30+ field types:
|
||||
- Categories: contact, social, developer, messaging, payment, creator, scheduling, other
|
||||
- Properties: `id`, `displayName`, `systemImage`, `isCustomSymbol`, `iconColor`
|
||||
- Properties: `valueLabel`, `valuePlaceholder`, `titleSuggestions`, `keyboardType`
|
||||
- Method: `iconImage()` — returns correct Image (asset vs SF Symbol)
|
||||
- Method: `urlBuilder` — closure to build deep link URLs
|
||||
- Static instances: `.email`, `.phone`, `.linkedIn`, `.twitter`, `.instagram`, etc.
|
||||
- Uses custom assets from `Assets.xcassets/SocialSymbols/`
|
||||
|
||||
- `Models/Contact.swift` — SwiftData model with:
|
||||
- Basic fields: name, role, company
|
||||
@ -86,10 +104,9 @@ App-specific extensions are in `Design/DesignConstants.swift`:
|
||||
### Views
|
||||
|
||||
Main screens:
|
||||
- `Views/RootTabView.swift` — tabbed shell
|
||||
- `Views/CardsHomeView.swift` — hero + card carousel
|
||||
- `Views/RootTabView.swift` — tabbed shell (4 tabs: My Cards, Share, Contacts, Widgets)
|
||||
- `Views/CardsHomeView.swift` — full-screen swipeable card view with edit button
|
||||
- `Views/ShareCardView.swift` — QR + share actions + track share
|
||||
- `Views/CustomizeCardView.swift` — theme/layout controls
|
||||
- `Views/ContactsView.swift` — contact list with sections
|
||||
- `Views/WidgetsView.swift` — widget preview mockups
|
||||
|
||||
@ -105,16 +122,31 @@ Reusable components (in `Views/Components/`):
|
||||
- `IconRowView.swift` — icon + text row for details
|
||||
- `LabelBadgeView.swift` — small badge labels
|
||||
- `ActionRowView.swift` — generic action row with chevron
|
||||
- `ContactFieldPickerView.swift` — grid picker for selecting contact field types
|
||||
- `ContactFieldsManagerView.swift` — orchestrates picker + added fields list
|
||||
- `AddedContactFieldsView.swift` — displays added fields with drag-to-reorder
|
||||
|
||||
Sheets (in `Views/Sheets/`):
|
||||
- `RecordContactSheet.swift` — track share recipient
|
||||
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
|
||||
|
||||
Small utilities:
|
||||
- `Views/EmptyStateView.swift` — empty state placeholder
|
||||
- `Views/PrimaryActionButton.swift` — styled action button
|
||||
- `Views/HeroBannerView.swift` — home page banner
|
||||
- `Views/CardCarouselView.swift` — card scroll carousel
|
||||
- `Views/WidgetsCalloutView.swift` — widget promotion callout
|
||||
|
||||
### Assets
|
||||
|
||||
- `Assets.xcassets/SocialSymbols/` — custom brand icons as `.symbolset` files:
|
||||
- Social: linkedin, x-twitter, instagram, facebook, tiktok, threads, bluesky, mastodon, reddit, twitch
|
||||
- Developer: github.fill
|
||||
- Messaging: telegram, discord.fill, slack, matrix
|
||||
- Creator: patreon.fill, ko-fi
|
||||
|
||||
**Icon Rendering:**
|
||||
- Custom symbols use `Image("symbolName")` (asset catalog)
|
||||
- SF Symbols use `Image(systemName: "symbolName")`
|
||||
- `ContactFieldType.isCustomSymbol` flag determines which to use
|
||||
- `ContactFieldType.iconImage()` returns the correct Image type
|
||||
|
||||
### Design + Localization
|
||||
|
||||
@ -141,13 +173,16 @@ Small utilities:
|
||||
### Current File Sizes
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| CardEditorView | ~420 | Complex form, acceptable |
|
||||
| ContactFieldType | ~650 | 30+ field definitions, acceptable |
|
||||
| CardEditorView | ~520 | Complex form + field manager, acceptable |
|
||||
| BusinessCardView | ~320 | Clickable fields + legacy fallback, acceptable |
|
||||
| QRScannerView | ~310 | Camera + parsing, acceptable |
|
||||
| BusinessCardView | ~245 | Multiple layouts, acceptable |
|
||||
| ShareCardView | ~235 | Good |
|
||||
| ContactDetailView | ~235 | Good |
|
||||
| ContactsView | ~220 | Good |
|
||||
| CustomizeCardView | ~170 | Good |
|
||||
| CardsHomeView | ~150 | Full-screen swipeable cards, good |
|
||||
| AddedContactFieldsView | ~135 | Drag-to-reorder, good |
|
||||
| ContactFieldPickerView | ~100 | Grid picker, good |
|
||||
| All others | <110 | Good |
|
||||
|
||||
## Localization
|
||||
|
||||
Loading…
Reference in New Issue
Block a user