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
|
@State private var appState: AppState
|
||||||
|
|
||||||
init() {
|
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])
|
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?
|
var container: ModelContainer?
|
||||||
|
|
||||||
// Strategy 1: Try with App Group + CloudKit
|
|
||||||
if let appGroupURL = FileManager.default.containerURL(
|
if let appGroupURL = FileManager.default.containerURL(
|
||||||
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
|
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
|
||||||
) {
|
) {
|
||||||
@ -22,47 +20,35 @@ struct BusinessCardApp: App {
|
|||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: .automatic
|
cloudKitDatabase: .none // Disable CloudKit until properly configured
|
||||||
)
|
)
|
||||||
container = try? ModelContainer(for: schema, configurations: [config])
|
|
||||||
|
|
||||||
// If failed, try deleting old store
|
do {
|
||||||
if container == nil {
|
container = try ModelContainer(for: schema, configurations: [config])
|
||||||
Self.deleteStoreFiles(at: storeURL)
|
} catch {
|
||||||
Self.deleteStoreFiles(at: appGroupURL.appending(path: "default.store"))
|
print("Failed to create container with App Group: \(error)")
|
||||||
container = try? ModelContainer(for: schema, configurations: [config])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try with App Group but no CloudKit
|
// Fallback: Default location (Application Support)
|
||||||
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
|
|
||||||
if container == nil {
|
if container == nil {
|
||||||
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
let storeURL = URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
||||||
Self.deleteStoreFiles(at: storeURL)
|
|
||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: .none
|
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 {
|
if container == nil {
|
||||||
|
print("WARNING: Using in-memory store - data will not persist!")
|
||||||
let config = ModelConfiguration(
|
let config = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
isStoredInMemoryOnly: true,
|
isStoredInMemoryOnly: true,
|
||||||
@ -79,20 +65,6 @@ struct BusinessCardApp: App {
|
|||||||
let context = container.mainContext
|
let context = container.mainContext
|
||||||
self._appState = State(initialValue: AppState(modelContext: context))
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
|||||||
@ -78,6 +78,7 @@ extension Color {
|
|||||||
enum AppText {
|
enum AppText {
|
||||||
static let primary = Color(red: 0.14, green: 0.14, blue: 0.17)
|
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 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)
|
static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,10 +48,10 @@ final class ContactField {
|
|||||||
fieldType?.displayName ?? typeId
|
fieldType?.displayName ?? typeId
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Icon from the field type
|
/// Returns an Image view for this field's icon
|
||||||
@MainActor
|
@MainActor
|
||||||
var systemImage: String {
|
func iconImage() -> Image {
|
||||||
fieldType?.systemImage ?? "link"
|
fieldType?.iconImage() ?? Image(systemName: "link")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Icon color from the field type
|
/// Icon color from the field type
|
||||||
@ -60,6 +60,18 @@ final class ContactField {
|
|||||||
fieldType?.iconColor ?? Color.gray
|
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
|
/// Builds the URL for this field
|
||||||
@MainActor
|
@MainActor
|
||||||
func buildURL() -> URL? {
|
func buildURL() -> URL? {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
|
|||||||
let id: String
|
let id: String
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let systemImage: String
|
let systemImage: String
|
||||||
|
let isCustomSymbol: Bool // true = asset catalog symbol, false = SF Symbol
|
||||||
let iconColor: Color
|
let iconColor: Color
|
||||||
let category: ContactFieldCategory
|
let category: ContactFieldCategory
|
||||||
let valueLabel: String
|
let valueLabel: String
|
||||||
@ -43,6 +44,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
|
|||||||
id: String,
|
id: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
systemImage: String,
|
systemImage: String,
|
||||||
|
isCustomSymbol: Bool = false,
|
||||||
iconColor: Color,
|
iconColor: Color,
|
||||||
category: ContactFieldCategory,
|
category: ContactFieldCategory,
|
||||||
valueLabel: String,
|
valueLabel: String,
|
||||||
@ -55,6 +57,7 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
|
|||||||
self.id = id
|
self.id = id
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.systemImage = systemImage
|
self.systemImage = systemImage
|
||||||
|
self.isCustomSymbol = isCustomSymbol
|
||||||
self.iconColor = iconColor
|
self.iconColor = iconColor
|
||||||
self.category = category
|
self.category = category
|
||||||
self.valueLabel = valueLabel
|
self.valueLabel = valueLabel
|
||||||
@ -65,6 +68,16 @@ struct ContactFieldType: Identifiable, Hashable, Sendable {
|
|||||||
self.urlBuilder = urlBuilder
|
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
|
// MARK: - Hashable & Equatable
|
||||||
|
|
||||||
static func == (lhs: ContactFieldType, rhs: ContactFieldType) -> Bool {
|
static func == (lhs: ContactFieldType, rhs: ContactFieldType) -> Bool {
|
||||||
@ -178,6 +191,7 @@ extension ContactFieldType {
|
|||||||
id: "linkedIn",
|
id: "linkedIn",
|
||||||
displayName: "LinkedIn",
|
displayName: "LinkedIn",
|
||||||
systemImage: "linkedin",
|
systemImage: "linkedin",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.47, blue: 0.71),
|
iconColor: Color(red: 0.0, green: 0.47, blue: 0.71),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -191,6 +205,7 @@ extension ContactFieldType {
|
|||||||
id: "twitter",
|
id: "twitter",
|
||||||
displayName: "X",
|
displayName: "X",
|
||||||
systemImage: "x-twitter",
|
systemImage: "x-twitter",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -204,6 +219,7 @@ extension ContactFieldType {
|
|||||||
id: "instagram",
|
id: "instagram",
|
||||||
displayName: "Instagram",
|
displayName: "Instagram",
|
||||||
systemImage: "instagram",
|
systemImage: "instagram",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.88, green: 0.19, blue: 0.42),
|
iconColor: Color(red: 0.88, green: 0.19, blue: 0.42),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -217,6 +233,7 @@ extension ContactFieldType {
|
|||||||
id: "facebook",
|
id: "facebook",
|
||||||
displayName: "Facebook",
|
displayName: "Facebook",
|
||||||
systemImage: "facebook",
|
systemImage: "facebook",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.26, green: 0.40, blue: 0.70),
|
iconColor: Color(red: 0.26, green: 0.40, blue: 0.70),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -230,6 +247,7 @@ extension ContactFieldType {
|
|||||||
id: "tiktok",
|
id: "tiktok",
|
||||||
displayName: "TikTok",
|
displayName: "TikTok",
|
||||||
systemImage: "tiktok",
|
systemImage: "tiktok",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -243,6 +261,7 @@ extension ContactFieldType {
|
|||||||
id: "threads",
|
id: "threads",
|
||||||
displayName: "Threads",
|
displayName: "Threads",
|
||||||
systemImage: "threads",
|
systemImage: "threads",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
iconColor: Color(red: 0.0, green: 0.0, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -295,6 +314,7 @@ extension ContactFieldType {
|
|||||||
id: "twitch",
|
id: "twitch",
|
||||||
displayName: "Twitch",
|
displayName: "Twitch",
|
||||||
systemImage: "twitch",
|
systemImage: "twitch",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.57, green: 0.27, blue: 1.0),
|
iconColor: Color(red: 0.57, green: 0.27, blue: 1.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -308,6 +328,7 @@ extension ContactFieldType {
|
|||||||
id: "bluesky",
|
id: "bluesky",
|
||||||
displayName: "Bluesky",
|
displayName: "Bluesky",
|
||||||
systemImage: "bluesky",
|
systemImage: "bluesky",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.52, blue: 1.0),
|
iconColor: Color(red: 0.0, green: 0.52, blue: 1.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -321,6 +342,7 @@ extension ContactFieldType {
|
|||||||
id: "mastodon",
|
id: "mastodon",
|
||||||
displayName: "Mastodon",
|
displayName: "Mastodon",
|
||||||
systemImage: "mastodon.fill",
|
systemImage: "mastodon.fill",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
|
iconColor: Color(red: 0.38, green: 0.28, blue: 0.85),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -343,6 +365,7 @@ extension ContactFieldType {
|
|||||||
id: "reddit",
|
id: "reddit",
|
||||||
displayName: "Reddit",
|
displayName: "Reddit",
|
||||||
systemImage: "reddit.fill",
|
systemImage: "reddit.fill",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
|
iconColor: Color(red: 1.0, green: 0.27, blue: 0.0),
|
||||||
category: .social,
|
category: .social,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -358,6 +381,7 @@ extension ContactFieldType {
|
|||||||
id: "github",
|
id: "github",
|
||||||
displayName: "GitHub",
|
displayName: "GitHub",
|
||||||
systemImage: "github.fill",
|
systemImage: "github.fill",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.13, green: 0.13, blue: 0.13),
|
iconColor: Color(red: 0.13, green: 0.13, blue: 0.13),
|
||||||
category: .developer,
|
category: .developer,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -399,6 +423,7 @@ extension ContactFieldType {
|
|||||||
id: "telegram",
|
id: "telegram",
|
||||||
displayName: "Telegram",
|
displayName: "Telegram",
|
||||||
systemImage: "telegram",
|
systemImage: "telegram",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.16, green: 0.63, blue: 0.89),
|
iconColor: Color(red: 0.16, green: 0.63, blue: 0.89),
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -449,6 +474,7 @@ extension ContactFieldType {
|
|||||||
id: "discord",
|
id: "discord",
|
||||||
displayName: "Discord",
|
displayName: "Discord",
|
||||||
systemImage: "discord.fill",
|
systemImage: "discord.fill",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
|
iconColor: Color(red: 0.34, green: 0.40, blue: 0.95),
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -462,6 +488,7 @@ extension ContactFieldType {
|
|||||||
id: "slack",
|
id: "slack",
|
||||||
displayName: "Slack",
|
displayName: "Slack",
|
||||||
systemImage: "slack",
|
systemImage: "slack",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.38, green: 0.11, blue: 0.44),
|
iconColor: Color(red: 0.38, green: 0.11, blue: 0.44),
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -475,6 +502,7 @@ extension ContactFieldType {
|
|||||||
id: "matrix",
|
id: "matrix",
|
||||||
displayName: "Matrix",
|
displayName: "Matrix",
|
||||||
systemImage: "matrix",
|
systemImage: "matrix",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 0.0, green: 0.73, blue: 0.58),
|
iconColor: Color(red: 0.0, green: 0.73, blue: 0.58),
|
||||||
category: .messaging,
|
category: .messaging,
|
||||||
valueLabel: String(localized: "Username/Link"),
|
valueLabel: String(localized: "Username/Link"),
|
||||||
@ -556,6 +584,7 @@ extension ContactFieldType {
|
|||||||
id: "patreon",
|
id: "patreon",
|
||||||
displayName: "Patreon",
|
displayName: "Patreon",
|
||||||
systemImage: "patreon.fill",
|
systemImage: "patreon.fill",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 1.0, green: 0.27, blue: 0.33),
|
iconColor: Color(red: 1.0, green: 0.27, blue: 0.33),
|
||||||
category: .creator,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
@ -569,6 +598,7 @@ extension ContactFieldType {
|
|||||||
id: "kofi",
|
id: "kofi",
|
||||||
displayName: "Ko-fi",
|
displayName: "Ko-fi",
|
||||||
systemImage: "ko-fi",
|
systemImage: "ko-fi",
|
||||||
|
isCustomSymbol: true,
|
||||||
iconColor: Color(red: 1.0, green: 0.35, blue: 0.45),
|
iconColor: Color(red: 1.0, green: 0.35, blue: 0.45),
|
||||||
category: .creator,
|
category: .creator,
|
||||||
valueLabel: String(localized: "Profile Link"),
|
valueLabel: String(localized: "Profile Link"),
|
||||||
|
|||||||
@ -24,6 +24,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@: %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@: %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%@%@%@" : {
|
"%@%@%@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -318,6 +328,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Opens %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Other" : {
|
"Other" : {
|
||||||
|
|
||||||
|
|||||||
@ -113,14 +113,8 @@ private struct CardContentView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
|
||||||
// Contact details
|
// Contact fields from array (preferred) or legacy properties
|
||||||
ContactDetailsView(card: card)
|
ContactFieldsListView(card: card)
|
||||||
|
|
||||||
// Social links
|
|
||||||
if card.hasSocialLinks {
|
|
||||||
SocialLinksRow(card: card)
|
|
||||||
.padding(.top, Design.Spacing.xSmall)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.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
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
if !card.email.isEmpty {
|
if !card.email.isEmpty {
|
||||||
ContactRowView(
|
LegacyContactRowView(
|
||||||
systemImage: "envelope.fill",
|
fieldType: .email,
|
||||||
text: card.email,
|
value: card.email,
|
||||||
label: card.emailLabel
|
label: card.emailLabel
|
||||||
)
|
) {
|
||||||
|
if let url = URL(string: "mailto:\(card.email)") {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !card.phone.isEmpty {
|
if !card.phone.isEmpty {
|
||||||
ContactRowView(
|
let phoneDisplay = card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)"
|
||||||
systemImage: "phone.fill",
|
LegacyContactRowView(
|
||||||
text: card.phoneExtension.isEmpty ? card.phone : "\(card.phone) ext. \(card.phoneExtension)",
|
fieldType: .phone,
|
||||||
|
value: phoneDisplay,
|
||||||
label: card.phoneLabel
|
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 {
|
if !card.website.isEmpty {
|
||||||
ContactRowView(
|
LegacyContactRowView(
|
||||||
systemImage: "link",
|
fieldType: .website,
|
||||||
text: card.website,
|
value: card.website,
|
||||||
label: nil
|
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 {
|
if !card.location.isEmpty {
|
||||||
ContactRowView(
|
LegacyContactRowView(
|
||||||
systemImage: "location.fill",
|
fieldType: .address,
|
||||||
text: card.location,
|
value: card.location,
|
||||||
label: nil
|
label: nil
|
||||||
)
|
) {
|
||||||
}
|
let query = card.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
}
|
if let url = URL(string: "maps://?q=\(query)") {
|
||||||
}
|
openURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
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 {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ForEach(socialItems, id: \.fieldType.id) { item in
|
||||||
HStack(spacing: Design.Spacing.small) {
|
LegacyContactRowView(
|
||||||
if !card.linkedIn.isEmpty {
|
fieldType: item.fieldType,
|
||||||
SocialIconButton(name: "LinkedIn", color: Color.Social.linkedIn)
|
value: item.value,
|
||||||
}
|
label: nil
|
||||||
if !card.twitter.isEmpty {
|
) {
|
||||||
SocialIconButton(name: "X", color: Color.Social.twitter)
|
if let url = item.fieldType.urlBuilder(item.value) {
|
||||||
}
|
openURL(url)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#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 {
|
struct AddedContactFieldsView: View {
|
||||||
@Binding var fields: [AddedContactField]
|
@Binding var fields: [AddedContactField]
|
||||||
let onEdit: (AddedContactField) -> Void
|
let onEdit: (AddedContactField) -> Void
|
||||||
|
|
||||||
|
@State private var draggingField: AddedContactField?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if fields.isEmpty {
|
if fields.isEmpty {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
@ -36,6 +38,24 @@ struct AddedContactFieldsView: View {
|
|||||||
onTap: { onEdit(field) },
|
onTap: { onEdit(field) },
|
||||||
onDelete: { deleteField(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 {
|
if field.id != fields.last?.id {
|
||||||
Divider()
|
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 {
|
private struct FieldRow: View {
|
||||||
let field: AddedContactField
|
let field: AddedContactField
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
@ -63,12 +119,18 @@ private struct FieldRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Drag handle
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
.frame(width: Design.Spacing.large)
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
Circle()
|
Circle()
|
||||||
.fill(field.fieldType.iconColor)
|
.fill(field.fieldType.iconColor)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: field.fieldType.systemImage)
|
field.fieldType.iconImage()
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ private struct FieldTypeButton: View {
|
|||||||
.fill(fieldType.iconColor)
|
.fill(fieldType.iconColor)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: fieldType.systemImage)
|
fieldType.iconImage()
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -158,7 +158,7 @@ private struct FieldHeaderView: View {
|
|||||||
.fill(fieldType.iconColor)
|
.fill(fieldType.iconColor)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: fieldType.systemImage)
|
fieldType.iconImage()
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user