Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
d4908a02d5
commit
b26f15cec6
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -24,6 +24,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@%@%@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -318,6 +328,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Opens %@" : {
|
||||
|
||||
},
|
||||
"Other" : {
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user