diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/youtube/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/youtube.fill.svg similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.fill.symbolset/youtube.fill.svg rename to BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.fill.symbolset/youtube.fill.svg diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/Contents.json similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/Contents.json rename to BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/Contents.json diff --git a/BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg b/BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/youtube.svg similarity index 100% rename from BusinessCard/Assets.xcassets/SocialSymbols/youtube/youtube.symbolset/youtube.svg rename to BusinessCard/Assets.xcassets/SocialSymbols/twitter/youtube/youtube.symbolset/youtube.svg diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift index d0f2ad2..eed0129 100644 --- a/BusinessCard/BusinessCardApp.swift +++ b/BusinessCard/BusinessCardApp.swift @@ -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, @@ -79,20 +65,6 @@ struct BusinessCardApp: App { let context = container.mainContext 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 { diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index ac66d57..0e09547 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -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) } diff --git a/BusinessCard/Models/ContactField.swift b/BusinessCard/Models/ContactField.swift index 7ff81ca..1abe272 100644 --- a/BusinessCard/Models/ContactField.swift +++ b/BusinessCard/Models/ContactField.swift @@ -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? { diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift index 871f4dc..cb98b59 100644 --- a/BusinessCard/Models/ContactFieldType.swift +++ b/BusinessCard/Models/ContactFieldType.swift @@ -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"), diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 38cd6a7..cd8b840 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -24,6 +24,16 @@ } } }, + "%@: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@" + } + } + } + }, "%@%@%@" : { "localizations" : { "en" : { @@ -318,6 +328,9 @@ } } } + }, + "Opens %@" : { + }, "Other" : { diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 3b6b12a..6cb4d4d 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -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,126 +156,214 @@ 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) { + // New contact fields array (preferred) + ForEach(card.orderedContactFields) { field in + ContactFieldRowView(field: field) { + if let url = field.buildURL() { + openURL(url) + } + } + } + + // Legacy properties fallback (for backward compatibility) + if card.orderedContactFields.isEmpty { + LegacyContactDetailsView(card: card) + } + } + } +} +/// 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) { + // Icon with brand color + field.iconImage() + .font(.body) + .foregroundStyle(.white) + .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) + .background(field.iconColor) + .clipShape(.circle) + + VStack(alignment: .leading, spacing: 0) { + // 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 { - ContactRowView( - systemImage: "envelope.fill", - text: card.email, + LegacyContactRowView( + fieldType: .email, + value: card.email, label: card.emailLabel - ) + ) { + if let url = URL(string: "mailto:\(card.email)") { + openURL(url) + } + } } if !card.phone.isEmpty { - ContactRowView( - systemImage: "phone.fill", - text: card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)", + 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 { - ContactRowView( - systemImage: "link", - text: card.website, + 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 { - ContactRowView( - systemImage: "location.fill", - text: card.location, + LegacyContactRowView( + fieldType: .address, + value: card.location, label: nil - ) - } - } - } -} - -private struct ContactRowView: View { - let systemImage: String - let text: String - let label: String? - - var body: some View { - HStack(spacing: Design.Spacing.medium) { - Image(systemName: systemImage) - .font(.body) - .foregroundStyle(Color.Accent.red) - .frame(width: Design.CardSize.socialIconSize, height: Design.CardSize.socialIconSize) - .background(Color.AppBackground.accent) - .clipShape(.circle) - - VStack(alignment: .leading, spacing: 0) { - Text(text) - .font(.subheadline) - .foregroundStyle(Color.Text.primary) - - if let label, !label.isEmpty { - Text(label) - .font(.caption) - .foregroundStyle(Color.Text.secondary) + ) { + let query = card.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + if let url = URL(string: "maps://?q=\(query)") { + openURL(url) + } } } + + // Legacy social links + LegacySocialLinksView(card: card) } } } -// MARK: - Social Links +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) + + if let label, !label.isEmpty { + 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) + } +} -private struct SocialLinksRow: View { +private struct LegacySocialLinksView: View { + @Environment(\.openURL) private var openURL let card: BusinessCard + 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 + } + var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: Design.Spacing.small) { - if !card.linkedIn.isEmpty { - SocialIconButton(name: "LinkedIn", color: Color.Social.linkedIn) - } - 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) + 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) } } } } } -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) - } -} - // MARK: - Preview #Preview { diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift index 0327e97..85932e9 100644 --- a/BusinessCard/Views/Components/AddedContactFieldsView.swift +++ b/BusinessCard/Views/Components/AddedContactFieldsView.swift @@ -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) ) diff --git a/BusinessCard/Views/Components/ContactFieldPickerView.swift b/BusinessCard/Views/Components/ContactFieldPickerView.swift index 92bfa00..5d3c4de 100644 --- a/BusinessCard/Views/Components/ContactFieldPickerView.swift +++ b/BusinessCard/Views/Components/ContactFieldPickerView.swift @@ -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) ) diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift index cb7a80b..256895a 100644 --- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift +++ b/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift @@ -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) )