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 {
|
enum AppTab: String, CaseIterable, Hashable, Identifiable {
|
||||||
case cards
|
case cards
|
||||||
case share
|
case share
|
||||||
case customize
|
|
||||||
case contacts
|
case contacts
|
||||||
case widgets
|
case widgets
|
||||||
|
|
||||||
|
|||||||
@ -43,9 +43,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"+1 555 123 4567" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
|
|
||||||
@ -57,6 +54,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Add a QR widget so your card is always one tap away." : {
|
"Add a QR widget so your card is always one tap away." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -126,6 +124,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Change image layout" : {
|
"Change image layout" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -160,6 +159,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Create multiple business cards" : {
|
"Create multiple business cards" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -180,11 +180,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Create your first card" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Custom Links" : {
|
"Custom Links" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Customize your card" : {
|
"Customize your card" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -211,6 +215,9 @@
|
|||||||
},
|
},
|
||||||
"Delete Field" : {
|
"Delete Field" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Design and share polished digital business cards for every context." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Developer" : {
|
"Developer" : {
|
||||||
|
|
||||||
@ -229,9 +236,6 @@
|
|||||||
},
|
},
|
||||||
"Email or Username" : {
|
"Email or Username" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Example" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ext." : {
|
"Ext." : {
|
||||||
|
|
||||||
@ -508,9 +512,6 @@
|
|||||||
},
|
},
|
||||||
"Support & Funding" : {
|
"Support & Funding" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Tap \"New Card\" to create your first card" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap a field below to add it" : {
|
"Tap a field below to add it" : {
|
||||||
|
|
||||||
@ -544,6 +545,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"The #1 Digital Business Card App" : {
|
"The #1 Digital Business Card App" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -581,6 +583,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Used by Industry Leaders" : {
|
"Used by Industry Leaders" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -660,21 +663,6 @@
|
|||||||
},
|
},
|
||||||
"Work" : {
|
"Work" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Your card will appear here" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Your Company" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Your Name" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Your Role" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"your@email.com" : {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"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 {
|
struct CardsHomeView: View {
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@State private var showingCreateCard = false
|
@State private var showingCreateCard = false
|
||||||
|
@State private var showingEditCard = false
|
||||||
|
@State private var showingDeleteConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@Bindable var cardStore = appState.cardStore
|
||||||
|
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: Design.Spacing.xLarge) {
|
// Background gradient
|
||||||
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(
|
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
)
|
.ignoresSafeArea()
|
||||||
.navigationTitle(String.localized("My Cards"))
|
|
||||||
|
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) }
|
||||||
|
)
|
||||||
|
.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) {
|
.sheet(isPresented: $showingCreateCard) {
|
||||||
CardEditorView(card: nil) { newCard in
|
CardEditorView(card: nil) { newCard in
|
||||||
appState.cardStore.addCard(newCard)
|
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 {
|
// MARK: - Card Page View
|
||||||
let title: String
|
|
||||||
let subtitle: String
|
private struct CardPageView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
let isDefault: Bool
|
||||||
|
let onSetDefault: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
ScrollView {
|
||||||
Text(title)
|
VStack(spacing: Design.Spacing.large) {
|
||||||
.font(.title3)
|
BusinessCardView(card: card)
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
// Default card toggle
|
||||||
Text(subtitle)
|
Button(
|
||||||
.font(.subheadline)
|
isDefault ? String.localized("Default card") : String.localized("Set as default"),
|
||||||
.foregroundStyle(Color.Text.secondary)
|
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("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()
|
ShareCardView()
|
||||||
}
|
}
|
||||||
|
|
||||||
Tab(String.localized("Customize"), systemImage: "slider.horizontal.3", value: AppTab.customize) {
|
|
||||||
CustomizeCardView()
|
|
||||||
}
|
|
||||||
|
|
||||||
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) {
|
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) {
|
||||||
ContactsView()
|
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
|
### 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
|
- Set a default card for sharing
|
||||||
- **Modern card design**: Banner with company logo, overlapping profile photo, clean contact rows
|
- **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
|
- **Profile photos**: Add a photo from your library or use an icon
|
||||||
- **Company logos**: Upload a logo to display on your card's banner
|
- **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
|
### 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
|
- **Track shares**: Record who received your card and when
|
||||||
- Placeholder actions for Apple Wallet and NFC (alerts included)
|
- 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
|
- **Horizontal color picker**: Scrollable theme swatches for quick selection
|
||||||
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
|
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
|
||||||
- **Expandable name section**: First, middle, last, suffix, preferred name
|
- **Expandable name section**: Prefix, first, middle, last, maiden name, suffix, preferred name, pronouns
|
||||||
- **Edit all card details**: Name, role, department, company, headline, email (with label), phone (with label & extension), website, location
|
- **Edit all card details**: Name, role, department, company, headline, bio
|
||||||
- **Social media links**: LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub, Threads, Telegram
|
- **Accreditations**: Tag-based input with inline editing
|
||||||
- **Payment links**: Venmo, Cash App
|
|
||||||
- **Custom links**: Add up to 2 custom URLs with titles
|
#### Dynamic Contact Fields
|
||||||
- **Suggestion chips**: Quick-fill suggestions for social link titles
|
|
||||||
|
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
|
- **Live preview**: "Preview card" button to see changes before saving
|
||||||
- **Delete cards** you no longer need
|
- **Delete cards** you no longer need
|
||||||
|
|
||||||
@ -103,16 +126,22 @@ App-specific extensions are in `Design/DesignConstants.swift`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
BusinessCard/
|
BusinessCard/
|
||||||
|
├── Assets.xcassets/
|
||||||
|
│ └── SocialSymbols/ # Custom brand icons (LinkedIn, X, Instagram, etc.)
|
||||||
├── Design/ # Design constants (extends Bedrock)
|
├── Design/ # Design constants (extends Bedrock)
|
||||||
├── Localization/ # String helpers
|
├── 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
|
├── Protocols/ # Protocol definitions
|
||||||
├── Resources/ # String Catalogs (.xcstrings)
|
├── Resources/ # String Catalogs (.xcstrings)
|
||||||
├── Services/ # Share link service, watch sync
|
├── Services/ # Share link service, watch sync
|
||||||
├── State/ # Observable stores (CardStore, ContactsStore)
|
├── State/ # Observable stores (CardStore, ContactsStore)
|
||||||
└── Views/
|
└── Views/
|
||||||
├── Components/ # Reusable UI components (AvatarBadgeView, etc.)
|
├── Components/ # Reusable UI (ContactFieldPickerView, AddedContactFieldsView, etc.)
|
||||||
├── Sheets/ # Modal sheets (RecordContactSheet, etc.)
|
├── Sheets/ # Modal sheets (ContactFieldEditorSheet, RecordContactSheet, etc.)
|
||||||
└── [Feature].swift # Feature screens
|
└── [Feature].swift # Feature screens
|
||||||
|
|
||||||
BusinessCardWatch/ # watchOS app target
|
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)
|
### 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
|
- [x] **More card fields** - Social media links, custom URLs, pronouns, bio
|
||||||
- Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub
|
- Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub
|
||||||
- Added: 2 custom link slots (title + URL)
|
- 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] **Bedrock integration** - Design system, QR code generator
|
||||||
- [x] **iOS-Watch sync** via App Groups
|
- [x] **iOS-Watch sync** via App Groups
|
||||||
- [x] **Unit tests** for models, stores, and new features
|
- [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
|
### Planned
|
||||||
|
|
||||||
@ -85,6 +121,7 @@ This document tracks planned features and their implementation status.
|
|||||||
- [ ] **Spotlight indexing** - Search cards from iOS search
|
- [ ] **Spotlight indexing** - Search cards from iOS search
|
||||||
- [ ] **Siri shortcuts** - "Share my work card"
|
- [ ] **Siri shortcuts** - "Share my work card"
|
||||||
- [ ] **App Intents** - iOS 17+ action button support
|
- [ ] **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 marked with 🔲 are planned but not yet implemented
|
||||||
- Features requiring backend are deferred until infrastructure is available
|
- Features requiring backend are deferred until infrastructure is available
|
||||||
- Priority may shift based on user feedback
|
- 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
|
||||||
|
|
||||||
- `Models/BusinessCard.swift` — SwiftData model with:
|
- `Models/BusinessCard.swift` — SwiftData model with:
|
||||||
- Basic fields: name, role, company, email, phone, website, location
|
- Name fields: prefix, firstName, middleName, lastName, maidenName, suffix, preferredName
|
||||||
- Rich fields: pronouns, bio, social links (LinkedIn, Twitter, Instagram, etc.)
|
- Basic fields: role, company, email, phone, website, location
|
||||||
- Custom links: 2 slots for custom URLs
|
- Rich fields: pronouns, bio, headline, accreditations (comma-separated tags)
|
||||||
- Photo: `photoData` stored with `@Attribute(.externalStorage)`
|
- **Dynamic contact fields**: `@Relationship` to array of `ContactField` objects
|
||||||
- Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks`
|
- 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:
|
- `Models/Contact.swift` — SwiftData model with:
|
||||||
- Basic fields: name, role, company
|
- Basic fields: name, role, company
|
||||||
@ -86,10 +104,9 @@ App-specific extensions are in `Design/DesignConstants.swift`:
|
|||||||
### Views
|
### Views
|
||||||
|
|
||||||
Main screens:
|
Main screens:
|
||||||
- `Views/RootTabView.swift` — tabbed shell
|
- `Views/RootTabView.swift` — tabbed shell (4 tabs: My Cards, Share, Contacts, Widgets)
|
||||||
- `Views/CardsHomeView.swift` — hero + card carousel
|
- `Views/CardsHomeView.swift` — full-screen swipeable card view with edit button
|
||||||
- `Views/ShareCardView.swift` — QR + share actions + track share
|
- `Views/ShareCardView.swift` — QR + share actions + track share
|
||||||
- `Views/CustomizeCardView.swift` — theme/layout controls
|
|
||||||
- `Views/ContactsView.swift` — contact list with sections
|
- `Views/ContactsView.swift` — contact list with sections
|
||||||
- `Views/WidgetsView.swift` — widget preview mockups
|
- `Views/WidgetsView.swift` — widget preview mockups
|
||||||
|
|
||||||
@ -105,16 +122,31 @@ Reusable components (in `Views/Components/`):
|
|||||||
- `IconRowView.swift` — icon + text row for details
|
- `IconRowView.swift` — icon + text row for details
|
||||||
- `LabelBadgeView.swift` — small badge labels
|
- `LabelBadgeView.swift` — small badge labels
|
||||||
- `ActionRowView.swift` — generic action row with chevron
|
- `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/`):
|
Sheets (in `Views/Sheets/`):
|
||||||
- `RecordContactSheet.swift` — track share recipient
|
- `RecordContactSheet.swift` — track share recipient
|
||||||
|
- `ContactFieldEditorSheet.swift` — add/edit contact field with type-specific UI
|
||||||
|
|
||||||
Small utilities:
|
Small utilities:
|
||||||
- `Views/EmptyStateView.swift` — empty state placeholder
|
- `Views/EmptyStateView.swift` — empty state placeholder
|
||||||
- `Views/PrimaryActionButton.swift` — styled action button
|
- `Views/PrimaryActionButton.swift` — styled action button
|
||||||
- `Views/HeroBannerView.swift` — home page banner
|
|
||||||
- `Views/CardCarouselView.swift` — card scroll carousel
|
### Assets
|
||||||
- `Views/WidgetsCalloutView.swift` — widget promotion callout
|
|
||||||
|
- `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
|
### Design + Localization
|
||||||
|
|
||||||
@ -141,13 +173,16 @@ Small utilities:
|
|||||||
### Current File Sizes
|
### Current File Sizes
|
||||||
| File | Lines | Status |
|
| 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 |
|
| QRScannerView | ~310 | Camera + parsing, acceptable |
|
||||||
| BusinessCardView | ~245 | Multiple layouts, acceptable |
|
|
||||||
| ShareCardView | ~235 | Good |
|
| ShareCardView | ~235 | Good |
|
||||||
| ContactDetailView | ~235 | Good |
|
| ContactDetailView | ~235 | Good |
|
||||||
| ContactsView | ~220 | 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 |
|
| All others | <110 | Good |
|
||||||
|
|
||||||
## Localization
|
## Localization
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user