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

This commit is contained in:
Matt Bruce 2026-01-08 22:11:24 -06:00
parent d4908a02d5
commit b26f15cec6
14 changed files with 320 additions and 148 deletions

View File

@ -7,14 +7,12 @@ struct BusinessCardApp: App {
@State private var appState: AppState
init() {
// Use a simple configuration first - CloudKit can be enabled later
// when the project is properly configured in Xcode
let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
// Try to create container with various fallback strategies
// Primary strategy: App Group for watch sync (without CloudKit for now)
// CloudKit can be enabled once properly configured in Xcode
var container: ModelContainer?
// Strategy 1: Try with App Group + CloudKit
if let appGroupURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
) {
@ -22,47 +20,35 @@ struct BusinessCardApp: App {
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .automatic
cloudKitDatabase: .none // Disable CloudKit until properly configured
)
container = try? ModelContainer(for: schema, configurations: [config])
// If failed, try deleting old store
if container == nil {
Self.deleteStoreFiles(at: storeURL)
Self.deleteStoreFiles(at: appGroupURL.appending(path: "default.store"))
container = try? ModelContainer(for: schema, configurations: [config])
do {
container = try ModelContainer(for: schema, configurations: [config])
} catch {
print("Failed to create container with App Group: \(error)")
}
}
// Strategy 2: Try with App Group but no CloudKit
if container == nil,
let appGroupURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
) {
let storeURL = appGroupURL.appending(path: "BusinessCard.store")
Self.deleteStoreFiles(at: storeURL)
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none
)
container = try? ModelContainer(for: schema, configurations: [config])
}
// Strategy 3: Try default location without CloudKit
// Fallback: Default location (Application Support)
if container == nil {
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
Self.deleteStoreFiles(at: storeURL)
let config = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none
)
container = try? ModelContainer(for: schema, configurations: [config])
do {
container = try ModelContainer(for: schema, configurations: [config])
} catch {
print("Failed to create container in Application Support: \(error)")
}
}
// Strategy 4: In-memory as last resort
// Last resort: In-memory (data won't persist but app will work)
if container == nil {
print("WARNING: Using in-memory store - data will not persist!")
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
@ -80,20 +66,6 @@ struct BusinessCardApp: App {
self._appState = State(initialValue: AppState(modelContext: context))
}
private static func deleteStoreFiles(at url: URL) {
let fm = FileManager.default
// Delete main store and associated files
try? fm.removeItem(at: url)
try? fm.removeItem(at: url.appendingPathExtension("shm"))
try? fm.removeItem(at: url.appendingPathExtension("wal"))
// Also try common variations
let basePath = url.deletingPathExtension()
try? fm.removeItem(at: basePath.appendingPathExtension("store"))
try? fm.removeItem(at: basePath.appendingPathExtension("store-shm"))
try? fm.removeItem(at: basePath.appendingPathExtension("store-wal"))
}
var body: some Scene {
WindowGroup {
RootTabView()

View File

@ -78,6 +78,7 @@ extension Color {
enum AppText {
static let primary = Color(red: 0.14, green: 0.14, blue: 0.17)
static let secondary = Color(red: 0.32, green: 0.34, blue: 0.4)
static let tertiary = Color(red: 0.56, green: 0.58, blue: 0.62)
static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98)
}

View File

@ -48,10 +48,10 @@ final class ContactField {
fieldType?.displayName ?? typeId
}
/// Icon from the field type
/// Returns an Image view for this field's icon
@MainActor
var systemImage: String {
fieldType?.systemImage ?? "link"
func iconImage() -> Image {
fieldType?.iconImage() ?? Image(systemName: "link")
}
/// Icon color from the field type
@ -60,6 +60,18 @@ final class ContactField {
fieldType?.iconColor ?? Color.gray
}
/// Whether this is a custom symbol (for rendering)
@MainActor
var isCustomSymbol: Bool {
fieldType?.isCustomSymbol ?? false
}
/// System image name for legacy compatibility
@MainActor
var systemImage: String {
fieldType?.systemImage ?? "link"
}
/// Builds the URL for this field
@MainActor
func buildURL() -> URL? {

View File

@ -30,6 +30,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
let id: String
let displayName: String
let systemImage: String
let isCustomSymbol: Bool // true = asset catalog symbol, false = SF Symbol
let iconColor: Color
let category: ContactFieldCategory
let valueLabel: String
@ -43,6 +44,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
id: String,
displayName: String,
systemImage: String,
isCustomSymbol: Bool = false,
iconColor: Color,
category: ContactFieldCategory,
valueLabel: String,
@ -55,6 +57,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
self.id = id
self.displayName = displayName
self.systemImage = systemImage
self.isCustomSymbol = isCustomSymbol
self.iconColor = iconColor
self.category = category
self.valueLabel = valueLabel
@ -65,6 +68,16 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
self.urlBuilder = urlBuilder
}
/// Returns an Image view for this field type's icon
@MainActor
func iconImage() -> Image {
if isCustomSymbol {
return Image(systemImage)
} else {
return Image(systemName: systemImage)
}
}
// MARK: - Hashable & Equatable
static func == (lhs: ContactFieldType, rhs: ContactFieldType) -> Bool {
@ -178,6 +191,7 @@ extension ContactFieldType {
id: "linkedIn",
displayName: "LinkedIn",
systemImage: "linkedin",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.47, blue: 0.71),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -191,6 +205,7 @@ extension ContactFieldType {
id: "twitter",
displayName: "X",
systemImage: "x-twitter",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -204,6 +219,7 @@ extension ContactFieldType {
id: "instagram",
displayName: "Instagram",
systemImage: "instagram",
isCustomSymbol: true,
iconColor: Color(red: 0.88, green: 0.19, blue: 0.42),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -217,6 +233,7 @@ extension ContactFieldType {
id: "facebook",
displayName: "Facebook",
systemImage: "facebook",
isCustomSymbol: true,
iconColor: Color(red: 0.26, green: 0.40, blue: 0.70),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -230,6 +247,7 @@ extension ContactFieldType {
id: "tiktok",
displayName: "TikTok",
systemImage: "tiktok",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -243,6 +261,7 @@ extension ContactFieldType {
id: "threads",
displayName: "Threads",
systemImage: "threads",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -295,6 +314,7 @@ extension ContactFieldType {
id: "twitch",
displayName: "Twitch",
systemImage: "twitch",
isCustomSymbol: true,
iconColor: Color(red: 0.57, green: 0.27, blue: 1.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -308,6 +328,7 @@ extension ContactFieldType {
id: "bluesky",
displayName: "Bluesky",
systemImage: "bluesky",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.52, blue: 1.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -321,6 +342,7 @@ extension ContactFieldType {
id: "mastodon",
displayName: "Mastodon",
systemImage: "mastodon.fill",
isCustomSymbol: true,
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -343,6 +365,7 @@ extension ContactFieldType {
id: "reddit",
displayName: "Reddit",
systemImage: "reddit.fill",
isCustomSymbol: true,
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
category: .social,
valueLabel: String(localized: "Username/Link"),
@ -358,6 +381,7 @@ extension ContactFieldType {
id: "github",
displayName: "GitHub",
systemImage: "github.fill",
isCustomSymbol: true,
iconColor: Color(red: 0.13, green: 0.13, blue: 0.13),
category: .developer,
valueLabel: String(localized: "Username/Link"),
@ -399,6 +423,7 @@ extension ContactFieldType {
id: "telegram",
displayName: "Telegram",
systemImage: "telegram",
isCustomSymbol: true,
iconColor: Color(red: 0.16, green: 0.63, blue: 0.89),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
@ -449,6 +474,7 @@ extension ContactFieldType {
id: "discord",
displayName: "Discord",
systemImage: "discord.fill",
isCustomSymbol: true,
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
@ -462,6 +488,7 @@ extension ContactFieldType {
id: "slack",
displayName: "Slack",
systemImage: "slack",
isCustomSymbol: true,
iconColor: Color(red: 0.38, green: 0.11, blue: 0.44),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
@ -475,6 +502,7 @@ extension ContactFieldType {
id: "matrix",
displayName: "Matrix",
systemImage: "matrix",
isCustomSymbol: true,
iconColor: Color(red: 0.0, green: 0.73, blue: 0.58),
category: .messaging,
valueLabel: String(localized: "Username/Link"),
@ -556,6 +584,7 @@ extension ContactFieldType {
id: "patreon",
displayName: "Patreon",
systemImage: "patreon.fill",
isCustomSymbol: true,
iconColor: Color(red: 1.0, green: 0.27, blue: 0.33),
category: .creator,
valueLabel: String(localized: "Profile Link"),
@ -569,6 +598,7 @@ extension ContactFieldType {
id: "kofi",
displayName: "Ko-fi",
systemImage: "ko-fi",
isCustomSymbol: true,
iconColor: Color(red: 1.0, green: 0.35, blue: 0.45),
category: .creator,
valueLabel: String(localized: "Profile Link"),

View File

@ -24,6 +24,16 @@
}
}
},
"%@: %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@: %2$@"
}
}
}
},
"%@%@%@" : {
"localizations" : {
"en" : {
@ -318,6 +328,9 @@
}
}
}
},
"Opens %@" : {
},
"Other" : {

View File

@ -113,14 +113,8 @@ private struct CardContentView: View {
Divider()
.padding(.vertical, Design.Spacing.xSmall)
// Contact details
ContactDetailsView(card: card)
// Social links
if card.hasSocialLinks {
SocialLinksRow(card: card)
.padding(.top, Design.Spacing.xSmall)
}
// Contact fields from array (preferred) or legacy properties
ContactFieldsListView(card: card)
}
}
.padding(.horizontal, Design.Spacing.large)
@ -162,61 +156,156 @@ private struct ProfileAvatarView: View {
}
}
// MARK: - Contact Details
// MARK: - Contact Fields List
private struct ContactDetailsView: View {
private struct ContactFieldsListView: View {
@Environment(\.openURL) private var openURL
let card: BusinessCard
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
if !card.email.isEmpty {
ContactRowView(
systemImage: "envelope.fill",
text: card.email,
label: card.emailLabel
)
// New contact fields array (preferred)
ForEach(card.orderedContactFields) { field in
ContactFieldRowView(field: field) {
if let url = field.buildURL() {
openURL(url)
}
if !card.phone.isEmpty {
ContactRowView(
systemImage: "phone.fill",
text: card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)",
label: card.phoneLabel
)
}
if !card.website.isEmpty {
ContactRowView(
systemImage: "link",
text: card.website,
label: nil
)
}
if !card.location.isEmpty {
ContactRowView(
systemImage: "location.fill",
text: card.location,
label: nil
)
// Legacy properties fallback (for backward compatibility)
if card.orderedContactFields.isEmpty {
LegacyContactDetailsView(card: card)
}
}
}
}
private struct ContactRowView: View {
let systemImage: String
let text: String
let label: String?
/// A clickable row for a contact field
private struct ContactFieldRowView: View {
let field: ContactField
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
// Icon with brand color
field.iconImage()
.font(.body)
.foregroundStyle(Color.Accent.red)
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(Color.AppBackground.accent)
.background(field.iconColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {
Text(text)
// Value
Text(field.value)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
.lineLimit(1)
// Title/Label
Text(field.title.isEmpty ? field.displayName : field.title)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
Spacer()
// Action indicator
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel("\(field.displayName): \(field.value)")
.accessibilityHint(field.title.isEmpty ? String(localized: "Opens \(field.displayName)") : field.title)
}
}
/// Legacy view for cards that haven't migrated to the new contact fields array
private struct LegacyContactDetailsView: View {
@Environment(\.openURL) private var openURL
let card: BusinessCard
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
if !card.email.isEmpty {
LegacyContactRowView(
fieldType: .email,
value: card.email,
label: card.emailLabel
) {
if let url = URL(string: "mailto:\(card.email)") {
openURL(url)
}
}
}
if !card.phone.isEmpty {
let phoneDisplay = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)"
LegacyContactRowView(
fieldType: .phone,
value: phoneDisplay,
label: card.phoneLabel
) {
let cleaned = card.phone.replacing(try! Regex("[^0-9+]"), with: "")
if let url = URL(string: "tel:\(cleaned)") {
openURL(url)
}
}
}
if !card.website.isEmpty {
LegacyContactRowView(
fieldType: .website,
value: card.website,
label: nil
) {
let urlString = card.website.hasPrefix("http") ? card.website : "https://\(card.website)"
if let url = URL(string: urlString) {
openURL(url)
}
}
}
if !card.location.isEmpty {
LegacyContactRowView(
fieldType: .address,
value: card.location,
label: nil
) {
let query = card.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "maps://?q=\(query)") {
openURL(url)
}
}
}
// Legacy social links
LegacySocialLinksView(card: card)
}
}
}
private struct LegacyContactRowView: View {
let fieldType: ContactFieldType
let value: String
let label: String?
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.medium) {
fieldType.iconImage()
.font(.body)
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(fieldType.iconColor)
.clipShape(.circle)
VStack(alignment: .leading, spacing: 0) {
Text(value)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
@ -224,61 +313,54 @@ private struct ContactRowView: View {
Text(label)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
} else {
Text(fieldType.displayName)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
// MARK: - Social Links
private struct SocialLinksRow: View {
private struct LegacySocialLinksView: View {
@Environment(\.openURL) private var openURL
let card: BusinessCard
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.small) {
if !card.linkedIn.isEmpty {
SocialIconButton(name: "LinkedIn", color: Color.Social.linkedIn)
private var socialItems: [(fieldType: ContactFieldType, value: String)] {
var items: [(ContactFieldType, String)] = []
if !card.linkedIn.isEmpty { items.append((.linkedIn, card.linkedIn)) }
if !card.twitter.isEmpty { items.append((.twitter, card.twitter)) }
if !card.instagram.isEmpty { items.append((.instagram, card.instagram)) }
if !card.facebook.isEmpty { items.append((.facebook, card.facebook)) }
if !card.tiktok.isEmpty { items.append((.tiktok, card.tiktok)) }
if !card.github.isEmpty { items.append((.github, card.github)) }
if !card.threads.isEmpty { items.append((.threads, card.threads)) }
if !card.telegram.isEmpty { items.append((.telegram, card.telegram)) }
return items
}
if !card.twitter.isEmpty {
SocialIconButton(name: "X", color: Color.Social.twitter)
}
if !card.instagram.isEmpty {
SocialIconButton(name: "IG", color: Color.Social.instagram)
}
if !card.facebook.isEmpty {
SocialIconButton(name: "FB", color: Color.Social.facebook)
}
if !card.tiktok.isEmpty {
SocialIconButton(name: "TT", color: Color.Social.tiktok)
}
if !card.github.isEmpty {
SocialIconButton(name: "GH", color: Color.Social.github)
}
if !card.threads.isEmpty {
SocialIconButton(name: "TH", color: Color.Social.threads)
}
if !card.telegram.isEmpty {
SocialIconButton(name: "TG", color: Color.Social.telegram)
}
}
}
}
}
private struct SocialIconButton: View {
let name: String
let color: Color
var body: some View {
Text(name)
.font(.caption2)
.bold()
.foregroundStyle(.white)
.frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize)
.background(color)
.clipShape(.circle)
ForEach(socialItems, id: \.fieldType.id) { item in
LegacyContactRowView(
fieldType: item.fieldType,
value: item.value,
label: nil
) {
if let url = item.fieldType.urlBuilder(item.value) {
openURL(url)
}
}
}
}
}

View File

@ -20,11 +20,13 @@ struct AddedContactField: Identifiable, Equatable {
}
}
/// Displays a vertical list of added contact fields with tap to edit
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
struct AddedContactFieldsView: View {
@Binding var fields: [AddedContactField]
let onEdit: (AddedContactField) -> Void
@State private var draggingField: AddedContactField?
var body: some View {
if fields.isEmpty {
EmptyView()
@ -36,6 +38,24 @@ struct AddedContactFieldsView: View {
onTap: { onEdit(field) },
onDelete: { deleteField(field) }
)
.draggable(field.id.uuidString) {
// Drag preview
FieldRowPreview(field: field)
}
.dropDestination(for: String.self) { items, _ in
guard let droppedId = items.first,
let droppedUUID = UUID(uuidString: droppedId),
let fromIndex = fields.firstIndex(where: { $0.id == droppedUUID }),
let toIndex = fields.firstIndex(where: { $0.id == field.id }),
fromIndex != toIndex else {
return false
}
withAnimation(.spring(duration: Design.Animation.quick)) {
let movedField = fields.remove(at: fromIndex)
fields.insert(movedField, at: toIndex)
}
return true
}
if field.id != fields.last?.id {
Divider()
@ -55,7 +75,43 @@ struct AddedContactFieldsView: View {
}
}
/// A display row for a contact field - tap to edit
/// Preview shown while dragging a field
private struct FieldRowPreview: View {
let field: AddedContactField
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(field.fieldType.iconColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(field.value.isEmpty ? field.fieldType.displayName : field.value)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
.lineLimit(1)
if !field.title.isEmpty {
Text(field.title)
.font(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
}
}
.padding(Design.Spacing.medium)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.shadow(radius: Design.Shadow.radiusMedium)
}
}
/// A display row for a contact field - tap to edit, hold to drag
private struct FieldRow: View {
let field: AddedContactField
let onTap: () -> Void
@ -63,12 +119,18 @@ private struct FieldRow: View {
var body: some View {
HStack(spacing: Design.Spacing.medium) {
// Drag handle
Image(systemName: "line.3.horizontal")
.font(.caption)
.foregroundStyle(Color.Text.tertiary)
.frame(width: Design.Spacing.large)
// Icon
Circle()
.fill(field.fieldType.iconColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
Image(systemName: field.fieldType.systemImage)
field.fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)

View File

@ -49,7 +49,7 @@ private struct FieldTypeButton: View {
.fill(fieldType.iconColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
Image(systemName: fieldType.systemImage)
fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)

View File

@ -158,7 +158,7 @@ private struct FieldHeaderView: View {
.fill(fieldType.iconColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
Image(systemName: fieldType.systemImage)
fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)