diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index 66adfb6..b88fc36 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -626,9 +626,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist;
- INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
- INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
- INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses your camera to scan QR cards and take profile, cover, or logo photos for your card.";
+ INFOPLIST_KEY_NSContactsUsageDescription = "BusinessCard can save shared cards to your Apple Contacts when you choose to add them.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library so you can add profile, cover, and logo images to your card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
@@ -663,9 +663,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist;
- INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
- INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
- INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses your camera to scan QR cards and take profile, cover, or logo photos for your card.";
+ INFOPLIST_KEY_NSContactsUsageDescription = "BusinessCard can save shared cards to your Apple Contacts when you choose to add them.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library so you can add profile, cover, and logo images to your card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
index 4b8d1ac..849d755 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,17 +7,17 @@
BusinessCard.xcscheme_^#shared#^_
orderHint
- 2
+ 1
BusinessCardClip.xcscheme_^#shared#^_
orderHint
- 0
+ 2
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 1
+ 3
diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift
index e63d5ed..ec07d5f 100644
--- a/BusinessCard/BusinessCardApp.swift
+++ b/BusinessCard/BusinessCardApp.swift
@@ -8,7 +8,7 @@ struct BusinessCardApp: App {
@State private var appState: AppState
init() {
- let schema = Schema([BusinessCard.self, Contact.self, ContactField.self])
+ let schema = Schema([BusinessCard.self, Contact.self, ContactField.self, AppSettings.self])
let cloudKitDatabase: ModelConfiguration.CloudKitDatabase =
AppIdentifiers.isCloudKitSyncEnabled
? .private(AppIdentifiers.cloudKitContainerIdentifier)
diff --git a/BusinessCard/Configuration/Base.xcconfig b/BusinessCard/Configuration/Base.xcconfig
index e56e3ea..6c68aff 100644
--- a/BusinessCard/Configuration/Base.xcconfig
+++ b/BusinessCard/Configuration/Base.xcconfig
@@ -9,7 +9,8 @@
// =============================================================================
COMPANY_IDENTIFIER = com.mbrucedogs
-APP_NAME = BusinessCard
+BUNDLE_ID_NAME = BusinessCard
+PRODUCT_NAME = Business Card
DEVELOPMENT_TEAM = 6R7KLBPBLZ
// =============================================================================
@@ -17,15 +18,15 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ
// =============================================================================
// Bundle identifiers
-APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)
+APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
-TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests
-UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
+TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)Tests
+UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)UITests
// Entitlement identifiers
-APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
-CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)
+APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
+CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
CLOUDKIT_SYNC_ENABLED = YES
// =============================================================================
diff --git a/BusinessCard/Models/AppSettings.swift b/BusinessCard/Models/AppSettings.swift
new file mode 100644
index 0000000..91f324a
--- /dev/null
+++ b/BusinessCard/Models/AppSettings.swift
@@ -0,0 +1,64 @@
+import Foundation
+import SwiftData
+
+enum PreferredShareAction: String, CaseIterable, Sendable {
+ case shareSheet
+ case textMessage
+ case email
+
+ var localizedTitle: String {
+ switch self {
+ case .shareSheet: "Share"
+ case .textMessage: "Text"
+ case .email: "Email"
+ }
+ }
+}
+
+enum DefaultFollowUpPreset: String, CaseIterable, Sendable {
+ case none
+ case oneWeek
+ case twoWeeks
+
+ var localizedTitle: String {
+ switch self {
+ case .none: "Off"
+ case .oneWeek: "1W"
+ case .twoWeeks: "2W"
+ }
+ }
+
+ func followUpDate(from referenceDate: Date) -> Date? {
+ switch self {
+ case .none:
+ nil
+ case .oneWeek:
+ Calendar.current.date(byAdding: .day, value: 7, to: referenceDate)
+ case .twoWeeks:
+ Calendar.current.date(byAdding: .day, value: 14, to: referenceDate)
+ }
+ }
+}
+
+@Model
+final class AppSettings {
+ var id: UUID
+ var preferredShareActionRawValue: String
+ var defaultFollowUpPresetRawValue: String
+ var createdAt: Date
+ var updatedAt: Date
+
+ init(
+ id: UUID = UUID(),
+ preferredShareActionRawValue: String = PreferredShareAction.shareSheet.rawValue,
+ defaultFollowUpPresetRawValue: String = DefaultFollowUpPreset.none.rawValue,
+ createdAt: Date = .now,
+ updatedAt: Date = .now
+ ) {
+ self.id = id
+ self.preferredShareActionRawValue = preferredShareActionRawValue
+ self.defaultFollowUpPresetRawValue = defaultFollowUpPresetRawValue
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+}
diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings
index e12a51f..f2d4db0 100644
--- a/BusinessCard/Resources/Localizable.xcstrings
+++ b/BusinessCard/Resources/Localizable.xcstrings
@@ -218,6 +218,10 @@
},
"Cover" : {
+ },
+ "Create a card first" : {
+ "comment" : "A message displayed in the \"Default Card\" section of the settings view when the user has not created any business cards yet.",
+ "isCommentAutoGenerated" : true
},
"Create multiple business cards" : {
"extractionState" : "stale",
@@ -275,6 +279,10 @@
}
}
},
+ "Default Card" : {
+ "comment" : "The title of the view.",
+ "isCommentAutoGenerated" : true
+ },
"Delete" : {
},
diff --git a/BusinessCard/State/AppPreferencesStore.swift b/BusinessCard/State/AppPreferencesStore.swift
index 675f990..fb7a230 100644
--- a/BusinessCard/State/AppPreferencesStore.swift
+++ b/BusinessCard/State/AppPreferencesStore.swift
@@ -8,6 +8,7 @@ final class AppPreferencesStore {
private enum Keys {
static let appearance = "appAppearance"
static let debugPremiumEnabled = "debugPremiumEnabled"
+ static let hasCompletedOnboarding = "hasCompletedOnboarding"
}
private let userDefaults: UserDefaults
@@ -18,6 +19,11 @@ final class AppPreferencesStore {
}
}
+ var hasCompletedOnboarding: Bool {
+ get { userDefaults.bool(forKey: Keys.hasCompletedOnboarding) }
+ set { userDefaults.set(newValue, forKey: Keys.hasCompletedOnboarding) }
+ }
+
#if DEBUG
var isDebugPremiumEnabled: Bool {
get { userDefaults.bool(forKey: Keys.debugPremiumEnabled) }
diff --git a/BusinessCard/State/AppSettingsStore.swift b/BusinessCard/State/AppSettingsStore.swift
new file mode 100644
index 0000000..15d63c5
--- /dev/null
+++ b/BusinessCard/State/AppSettingsStore.swift
@@ -0,0 +1,56 @@
+import Foundation
+import Observation
+import SwiftData
+
+@Observable
+@MainActor
+final class AppSettingsStore {
+ private let modelContext: ModelContext
+ private(set) var settings: AppSettings
+
+ init(modelContext: ModelContext) {
+ self.modelContext = modelContext
+
+ if let existing = Self.fetchSettings(in: modelContext).first {
+ self.settings = existing
+ } else {
+ let newSettings = AppSettings()
+ modelContext.insert(newSettings)
+ self.settings = newSettings
+ saveContext()
+ }
+ }
+
+ var preferredShareAction: PreferredShareAction {
+ get { PreferredShareAction(rawValue: settings.preferredShareActionRawValue) ?? .shareSheet }
+ set {
+ settings.preferredShareActionRawValue = newValue.rawValue
+ settings.updatedAt = .now
+ saveContext()
+ }
+ }
+
+ var defaultFollowUpPreset: DefaultFollowUpPreset {
+ get { DefaultFollowUpPreset(rawValue: settings.defaultFollowUpPresetRawValue) ?? .none }
+ set {
+ settings.defaultFollowUpPresetRawValue = newValue.rawValue
+ settings.updatedAt = .now
+ saveContext()
+ }
+ }
+
+ private static func fetchSettings(in modelContext: ModelContext) -> [AppSettings] {
+ let descriptor = FetchDescriptor(
+ sortBy: [SortDescriptor(\.createdAt, order: .forward)]
+ )
+ return (try? modelContext.fetch(descriptor)) ?? []
+ }
+
+ private func saveContext() {
+ do {
+ try modelContext.save()
+ } catch {
+ modelContext.rollback()
+ }
+ }
+}
diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift
index cd9fed4..bf26292 100644
--- a/BusinessCard/State/AppState.swift
+++ b/BusinessCard/State/AppState.swift
@@ -24,6 +24,7 @@ final class AppState {
var cardStore: CardStore
var contactsStore: ContactsStore
let preferences: AppPreferencesStore
+ let appSettings: AppSettingsStore
let shareLinkService: ShareLinkProviding
var preferredColorScheme: ColorScheme? {
@@ -39,6 +40,7 @@ final class AppState {
self.cardStore = CardStore(modelContext: modelContext)
self.contactsStore = ContactsStore(modelContext: modelContext)
self.preferences = AppPreferencesStore()
+ self.appSettings = AppSettingsStore(modelContext: modelContext)
self.shareLinkService = ShareLinkService()
// Clean up expired shared cards on launch (best-effort, non-blocking)
diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift
index 394fa48..8840c2c 100644
--- a/BusinessCard/Views/CardEditorView.swift
+++ b/BusinessCard/Views/CardEditorView.swift
@@ -1347,7 +1347,7 @@ private extension CardEditorView {
// MARK: - Preview
#Preview("New Card") {
- let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
+ let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
return CardEditorView(card: nil) { _ in }
.environment(AppState(modelContext: container.mainContext))
}
diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift
index d912e8b..32ca0bf 100644
--- a/BusinessCard/Views/CardsHomeView.swift
+++ b/BusinessCard/Views/CardsHomeView.swift
@@ -142,7 +142,7 @@ private struct EmptyCardsView: View {
}
#Preview {
- let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
+ let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
return CardsHomeView()
.environment(AppState(modelContext: container.mainContext))
}
diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift
index 0860a9e..ec83b37 100644
--- a/BusinessCard/Views/ContactDetailView.swift
+++ b/BusinessCard/Views/ContactDetailView.swift
@@ -641,6 +641,6 @@ private struct AddNoteSheet: View {
phone: "2145328862"
)
)
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
}
}
diff --git a/BusinessCard/Views/ContactsView.swift b/BusinessCard/Views/ContactsView.swift
index ef6d331..7643f92 100644
--- a/BusinessCard/Views/ContactsView.swift
+++ b/BusinessCard/Views/ContactsView.swift
@@ -229,5 +229,5 @@ private struct ContactAvatarView: View {
#Preview {
ContactsView()
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
}
diff --git a/BusinessCard/Views/OnboardingView.swift b/BusinessCard/Views/OnboardingView.swift
new file mode 100644
index 0000000..e26d348
--- /dev/null
+++ b/BusinessCard/Views/OnboardingView.swift
@@ -0,0 +1,376 @@
+import SwiftUI
+import Bedrock
+import AVFoundation
+import Photos
+import Contacts
+
+struct OnboardingView: View {
+ let onComplete: () -> Void
+
+ @State private var stepIndex = 0
+ @State private var cameraStatus: OnboardingPermissionStatus = .notRequested
+ @State private var photosStatus: OnboardingPermissionStatus = .notRequested
+ @State private var contactsStatus: OnboardingPermissionStatus = .notRequested
+
+ private let totalSteps = 3
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ Color.AppBackground.base
+ .ignoresSafeArea()
+
+ VStack(spacing: Design.Spacing.large) {
+ progressHeader
+
+ Group {
+ switch stepIndex {
+ case 0:
+ welcomeStep
+ case 1:
+ permissionsStep
+ default:
+ activationStep
+ }
+ }
+
+ Spacer(minLength: 0)
+
+ primaryAction
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.top, Design.Spacing.large)
+ .padding(.bottom, Design.Spacing.xLarge)
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if stepIndex < totalSteps - 1 {
+ Button(String.localized("Skip")) {
+ onComplete()
+ }
+ }
+ }
+ }
+ .onAppear {
+ refreshPermissionStatuses()
+ }
+ }
+ }
+
+ private var progressHeader: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(String.localized("Step %d of %d", stepIndex + 1, totalSteps))
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ ProgressView(value: Double(stepIndex + 1), total: Double(totalSteps))
+ .tint(Color.Accent.red)
+ }
+ }
+
+ private var welcomeStep: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ Image(systemName: "person.crop.rectangle.stack.fill")
+ .typography(.title)
+ .foregroundStyle(Color.Accent.red)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("Welcome to BusinessCard")
+ .typography(.title)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ Text("Create one polished card, then share it anywhere in seconds.")
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ }
+
+ onboardingFeature(
+ icon: "rectangle.stack.badge.plus",
+ title: "Create once",
+ subtitle: "Build a professional card with your branding and contact links."
+ )
+
+ onboardingFeature(
+ icon: "qrcode",
+ title: "Share instantly",
+ subtitle: "Use QR, text, email, or App Clip links so people can save your details fast."
+ )
+
+ onboardingFeature(
+ icon: "person.2",
+ title: "Track follow-ups",
+ subtitle: "Keep important contacts organized and set reminders from one place."
+ )
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var permissionsStep: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("Enable the essentials")
+ .typography(.title2)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ Text("We ask only when it directly improves card sharing and profile quality.")
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ }
+
+ permissionRow(
+ title: "Camera",
+ icon: "camera.fill",
+ reason: "Needed to scan QR cards and capture profile, cover, or logo photos.",
+ status: cameraStatus,
+ action: { requestCameraPermission() }
+ )
+
+ permissionRow(
+ title: "Photos",
+ icon: "photo.on.rectangle.angled",
+ reason: "Needed to pick profile, cover, and logo images from your library.",
+ status: photosStatus,
+ action: { requestPhotosPermission() }
+ )
+
+ permissionRow(
+ title: "Contacts",
+ icon: "person.crop.circle.badge.plus",
+ reason: "Needed when saving shared cards to your Apple Contacts.",
+ status: contactsStatus,
+ action: { requestContactsPermission() }
+ )
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var activationStep: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.large) {
+ Image(systemName: "checkmark.seal.fill")
+ .typography(.title)
+ .foregroundStyle(Color.Accent.mint)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text("You are ready to share")
+ .typography(.title2)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ Text("Next step: create your first card. Once it is saved, you can start sharing immediately.")
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ }
+
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ onboardingChecklistItem("Add your name, role, and company")
+ onboardingChecklistItem("Choose a photo or logo")
+ onboardingChecklistItem("Share your card with one tap")
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ @ViewBuilder
+ private var primaryAction: some View {
+ if stepIndex < totalSteps - 1 {
+ PrimaryActionButton(
+ title: String.localized("Continue"),
+ systemImage: "arrow.right"
+ ) {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ stepIndex += 1
+ }
+ }
+ } else {
+ PrimaryActionButton(
+ title: String.localized("Create My First Card"),
+ systemImage: "sparkles"
+ ) {
+ onComplete()
+ }
+ }
+ }
+
+ private func onboardingFeature(icon: String, title: String, subtitle: String) -> some View {
+ HStack(alignment: .top, spacing: Design.Spacing.medium) {
+ Image(systemName: icon)
+ .foregroundStyle(Color.Accent.red)
+ .frame(width: Design.IconSize.medium)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(title)
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.Text.primary)
+
+ Text(subtitle)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ .padding(Design.Spacing.medium)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ private func onboardingChecklistItem(_ text: String) -> some View {
+ HStack(spacing: Design.Spacing.small) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(Color.Accent.mint)
+ Text(text)
+ .typography(.body)
+ .foregroundStyle(Color.Text.primary)
+ }
+ }
+
+ private func permissionRow(
+ title: String,
+ icon: String,
+ reason: String,
+ status: OnboardingPermissionStatus,
+ action: @escaping () -> Void
+ ) -> some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ HStack(spacing: Design.Spacing.small) {
+ Image(systemName: icon)
+ .foregroundStyle(Color.Accent.red)
+ Text(title)
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.Text.primary)
+ Spacer()
+ permissionStatusPill(status)
+ }
+
+ Text(reason)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ if status != .allowed {
+ Button(status == .denied ? "Open Settings" : "Allow Now") {
+ if status == .denied {
+ openSettings()
+ } else {
+ action()
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(Color.Accent.red)
+ .controlSize(.small)
+ }
+ }
+ .padding(Design.Spacing.medium)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+
+ private func permissionStatusPill(_ status: OnboardingPermissionStatus) -> some View {
+ Text(status.title)
+ .typography(.caption2)
+ .foregroundStyle(status.tint)
+ .padding(.horizontal, Design.Spacing.small)
+ .padding(.vertical, Design.Spacing.xxSmall)
+ .background(status.tint.opacity(0.14))
+ .clipShape(.capsule)
+ }
+
+ private func openSettings() {
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
+ UIApplication.shared.open(url)
+ }
+
+ private func refreshPermissionStatuses() {
+ cameraStatus = mapCameraStatus(AVCaptureDevice.authorizationStatus(for: .video))
+ photosStatus = mapPhotosStatus(PHPhotoLibrary.authorizationStatus(for: .readWrite))
+ contactsStatus = mapContactsStatus(CNContactStore.authorizationStatus(for: .contacts))
+ }
+
+ private func requestCameraPermission() {
+ AVCaptureDevice.requestAccess(for: .video) { _ in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ }
+ }
+ }
+
+ private func requestPhotosPermission() {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ }
+ }
+ }
+
+ private func requestContactsPermission() {
+ CNContactStore().requestAccess(for: .contacts) { _, _ in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ }
+ }
+ }
+
+ private func mapCameraStatus(_ status: AVAuthorizationStatus) -> OnboardingPermissionStatus {
+ switch status {
+ case .authorized:
+ .allowed
+ case .notDetermined:
+ .notRequested
+ case .denied, .restricted:
+ .denied
+ @unknown default:
+ .notRequested
+ }
+ }
+
+ private func mapPhotosStatus(_ status: PHAuthorizationStatus) -> OnboardingPermissionStatus {
+ switch status {
+ case .authorized, .limited:
+ .allowed
+ case .notDetermined:
+ .notRequested
+ case .denied, .restricted:
+ .denied
+ @unknown default:
+ .notRequested
+ }
+ }
+
+ private func mapContactsStatus(_ status: CNAuthorizationStatus) -> OnboardingPermissionStatus {
+ switch status {
+ case .authorized:
+ .allowed
+ case .notDetermined:
+ .notRequested
+ case .denied, .restricted:
+ .denied
+ @unknown default:
+ .notRequested
+ }
+ }
+}
+
+private enum OnboardingPermissionStatus {
+ case notRequested
+ case allowed
+ case denied
+
+ var title: String {
+ switch self {
+ case .notRequested: "Not enabled"
+ case .allowed: "Enabled"
+ case .denied: "Blocked"
+ }
+ }
+
+ var tint: Color {
+ switch self {
+ case .notRequested: .orange
+ case .allowed: Color.Accent.mint
+ case .denied: Color.Accent.red
+ }
+ }
+}
+
+#Preview {
+ OnboardingView(onComplete: {})
+}
diff --git a/BusinessCard/Views/QRCodeView.swift b/BusinessCard/Views/QRCodeView.swift
index 212d3d4..a2d4153 100644
--- a/BusinessCard/Views/QRCodeView.swift
+++ b/BusinessCard/Views/QRCodeView.swift
@@ -13,7 +13,7 @@ struct QRCodeView: View {
}
#Preview {
- let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
+ let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
let context = container.mainContext
BusinessCard.createSamples(in: context)
let cards = try! context.fetch(FetchDescriptor())
diff --git a/BusinessCard/Views/RootTabView.swift b/BusinessCard/Views/RootTabView.swift
index 58685a2..f16f93a 100644
--- a/BusinessCard/Views/RootTabView.swift
+++ b/BusinessCard/Views/RootTabView.swift
@@ -5,6 +5,7 @@ import SwiftData
struct RootTabView: View {
@Environment(AppState.self) private var appState
@State private var showingShareSheet = false
+ @State private var showingOnboarding = false
var body: some View {
@Bindable var appState = appState
@@ -35,6 +36,17 @@ struct RootTabView: View {
.sheet(isPresented: $showingShareSheet) {
ShareCardView()
}
+ .fullScreenCover(isPresented: $showingOnboarding) {
+ OnboardingView {
+ appState.preferences.hasCompletedOnboarding = true
+ showingOnboarding = false
+ }
+ }
+ .onAppear {
+ if !appState.preferences.hasCompletedOnboarding {
+ showingOnboarding = true
+ }
+ }
}
}
@@ -68,5 +80,5 @@ private struct FloatingShareButton: View {
#Preview {
RootTabView()
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
}
diff --git a/BusinessCard/Views/Settings/DefaultCardSelectionView.swift b/BusinessCard/Views/Settings/DefaultCardSelectionView.swift
new file mode 100644
index 0000000..575d45f
--- /dev/null
+++ b/BusinessCard/Views/Settings/DefaultCardSelectionView.swift
@@ -0,0 +1,91 @@
+import SwiftUI
+import Bedrock
+import SwiftData
+
+struct DefaultCardSelectionView: View {
+ @Environment(AppState.self) private var appState
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ SettingsSectionHeader(
+ title: "Default Card",
+ systemImage: "checklist",
+ accentColor: AppThemeAccent.primary
+ )
+
+ SettingsCard(
+ backgroundColor: Color.AppBackground.elevated,
+ borderColor: AppBorder.standard
+ ) {
+ if appState.cardStore.cards.isEmpty {
+ SettingsCardRow {
+ Text("Create a card first")
+ .typography(.body)
+ .foregroundStyle(Color.AppText.secondary)
+ }
+ } else {
+ ForEach(appState.cardStore.cards) { card in
+ Button {
+ appState.cardStore.setDefaultCard(card)
+ } label: {
+ SettingsCardRow(verticalPadding: Design.Spacing.medium) {
+ HStack {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
+ Text(displayTitle(for: card))
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.AppText.primary)
+
+ if !card.label.isEmpty {
+ Text(card.label)
+ .typography(.caption)
+ .foregroundStyle(Color.AppText.secondary)
+ }
+ }
+
+ Spacer()
+
+ if card.isDefault {
+ Image(systemName: "checkmark")
+ .foregroundStyle(AppThemeAccent.primary)
+ .font(.body.bold())
+ }
+ }
+ }
+ .background(Color.clear)
+ }
+ .buttonStyle(.plain)
+
+ if card.id != appState.cardStore.cards.last?.id {
+ SettingsDivider(color: AppBorder.subtle)
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.top, Design.Spacing.medium)
+ .padding(.bottom, Design.Spacing.xxxLarge)
+ }
+ .background(Color.AppBackground.base)
+ .navigationTitle("Default Card")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private func displayTitle(for card: BusinessCard) -> String {
+ let trimmedName = card.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedName.isEmpty {
+ return trimmedName
+ }
+ let trimmedLabel = card.label.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedLabel.isEmpty {
+ return trimmedLabel
+ }
+ return "Untitled"
+ }
+}
+
+#Preview {
+ DefaultCardSelectionView()
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
+}
diff --git a/BusinessCard/Views/SettingsView.swift b/BusinessCard/Views/SettingsView.swift
index 1ba79a7..c2bb2bc 100644
--- a/BusinessCard/Views/SettingsView.swift
+++ b/BusinessCard/Views/SettingsView.swift
@@ -12,16 +12,21 @@ import Bedrock
struct SettingsView: View {
@Environment(AppState.self) private var appState
+ @Environment(\.openURL) private var openURL
@State private var settingsState = SettingsState()
+ @State private var showingResetOnboardingConfirmation = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.large) {
- // MARK: - About Section
+ // MARK: - Settings Sections
appearanceSection
- aboutSection
+ cardsSection
+ sharingSection
+ contactsSection
+ supportSection
// MARK: - Debug Section
@@ -35,8 +40,19 @@ struct SettingsView: View {
.padding(.top, Design.Spacing.medium)
}
.background(Color.AppBackground.base)
+ .safeAreaInset(edge: .bottom) {
+ versionFooter
+ }
.navigationTitle(String.localized("Settings"))
.navigationBarTitleDisplayMode(.large)
+ .alert("Reset Onboarding", isPresented: $showingResetOnboardingConfirmation) {
+ Button("Cancel", role: .cancel) { }
+ Button("Reset", role: .destructive) {
+ appState.preferences.hasCompletedOnboarding = false
+ }
+ } message: {
+ Text("Onboarding will be shown again the next time you open the app.")
+ }
}
}
@@ -72,75 +88,205 @@ struct SettingsView: View {
}
}
- // MARK: - About Section
-
- private var aboutSection: some View {
+ private var versionFooter: some View {
+ Text(settingsState.versionString)
+ .typography(.caption)
+ .fontDesign(.monospaced)
+ .foregroundStyle(Color.AppText.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Design.Spacing.small)
+ .background(Color.AppBackground.base)
+ }
+
+ // MARK: - Cards Section
+
+ private var cardsSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
SettingsSectionHeader(
- title: String.localized("About"),
- systemImage: "info.circle",
+ title: "Cards",
+ systemImage: "rectangle.stack",
accentColor: AppThemeAccent.primary
)
-
+
SettingsCard(
backgroundColor: Color.AppBackground.elevated,
borderColor: AppBorder.standard
) {
- SettingsCardRow {
- HStack {
- Text(settingsState.appName)
- .typography(.title3Bold)
- .foregroundStyle(Color.AppText.primary)
-
- Spacer()
+ if appState.cardStore.cards.isEmpty {
+ SettingsCardRow {
+ HStack {
+ Text("Default Card")
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.AppText.primary)
+
+ Spacer()
+
+ Text("Create a card first")
+ .typography(.body)
+ .foregroundStyle(Color.AppText.secondary)
+ }
}
- }
-
- SettingsDivider(color: AppBorder.subtle)
-
- SettingsCardRow {
- HStack {
- Text(String.localized("Version"))
- .typography(.bodyEmphasis)
- .foregroundStyle(Color.AppText.primary)
-
- Spacer()
-
- Text(settingsState.versionString)
- .typography(.body)
- .fontDesign(.monospaced)
- .foregroundStyle(Color.AppText.secondary)
+ } else {
+ SettingsNavigationRow(
+ title: "Default Card",
+ subtitle: defaultCardTitle,
+ backgroundColor: .clear
+ ) {
+ DefaultCardSelectionView()
}
}
-
- SettingsDivider(color: AppBorder.subtle)
-
- SettingsCardRow {
- HStack {
- Text(String.localized("Developer"))
- .typography(.bodyEmphasis)
- .foregroundStyle(Color.AppText.primary)
-
- Spacer()
-
- Text("Matt Bruce")
- .typography(.body)
- .foregroundStyle(Color.AppText.secondary)
- }
- }
-
- SettingsDivider(color: AppBorder.subtle)
-
- SettingsNavigationRow(
- title: String.localized("Widgets"),
- subtitle: "Phone and watch preview",
- backgroundColor: .clear
- ) {
- WidgetsView()
- }
}
}
}
+
+ // MARK: - Sharing Section
+
+ private var sharingSection: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ SettingsSectionHeader(
+ title: "Sharing",
+ systemImage: "square.and.arrow.up",
+ accentColor: AppThemeAccent.primary
+ )
+
+ SettingsCard(
+ backgroundColor: Color.AppBackground.elevated,
+ borderColor: AppBorder.standard
+ ) {
+ SettingsSegmentedPicker(
+ title: "Preferred Share Action",
+ subtitle: "Prioritize this option in Send Your Card",
+ options: PreferredShareAction.allCases.map { ($0.localizedTitle, $0) },
+ selection: Binding(
+ get: { appState.appSettings.preferredShareAction },
+ set: { appState.appSettings.preferredShareAction = $0 }
+ ),
+ accentColor: AppThemeAccent.primary
+ )
+ }
+ }
+ }
+
+ // MARK: - Contacts Section
+
+ private var contactsSection: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ SettingsSectionHeader(
+ title: "Contacts",
+ systemImage: "person.2",
+ accentColor: AppThemeAccent.primary
+ )
+
+ SettingsCard(
+ backgroundColor: Color.AppBackground.elevated,
+ borderColor: AppBorder.standard
+ ) {
+ SettingsSegmentedPicker(
+ title: "Default Follow-Up",
+ subtitle: "Apply to newly created contacts",
+ options: DefaultFollowUpPreset.allCases.map { ($0.localizedTitle, $0) },
+ selection: Binding(
+ get: { appState.appSettings.defaultFollowUpPreset },
+ set: { appState.appSettings.defaultFollowUpPreset = $0 }
+ ),
+ accentColor: AppThemeAccent.primary
+ )
+ }
+ }
+ }
+
+ // MARK: - Support Section
+
+ private var supportSection: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ SettingsSectionHeader(
+ title: "Support",
+ systemImage: "questionmark.circle",
+ accentColor: AppThemeAccent.primary
+ )
+
+ SettingsCard(
+ backgroundColor: Color.AppBackground.elevated,
+ borderColor: AppBorder.standard
+ ) {
+ supportRow(
+ title: "Send Feedback",
+ subtitle: "Email support"
+ ) {
+ guard let url = URL(string: "mailto:info@topdoglabs.com?subject=BusinessCard%20Support") else { return }
+ openURL(url)
+ }
+
+ SettingsDivider(color: AppBorder.subtle)
+
+ supportRow(
+ title: "Privacy Policy",
+ subtitle: "How we handle data"
+ ) {
+ guard let url = URL(string: "https://topdoglabs.com/privacy") else { return }
+ openURL(url)
+ }
+
+ SettingsDivider(color: AppBorder.subtle)
+
+ supportRow(
+ title: "Website",
+ subtitle: "topdoglabs.com"
+ ) {
+ guard let url = URL(string: "https://topdoglabs.com") else { return }
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ private var defaultCardTitle: String {
+ if let defaultCard = appState.cardStore.cards.first(where: { $0.isDefault }) {
+ return displayTitle(for: defaultCard)
+ }
+ if let selectedCard = appState.cardStore.selectedCard {
+ return displayTitle(for: selectedCard)
+ }
+ return "Not set"
+ }
+
+ private func displayTitle(for card: BusinessCard) -> String {
+ let trimmedName = card.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedName.isEmpty {
+ return trimmedName
+ }
+ let trimmedLabel = card.label.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedLabel.isEmpty {
+ return trimmedLabel
+ }
+ return "Untitled"
+ }
+
+ @ViewBuilder
+ private func supportRow(title: String, subtitle: String, action: @escaping () -> Void) -> some View {
+ SettingsCardRow {
+ Button(action: action) {
+ HStack {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
+ Text(title)
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.AppText.primary)
+
+ Text(subtitle)
+ .typography(.caption)
+ .foregroundStyle(Color.AppText.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "arrow.up.right.square")
+ .typography(.subheading)
+ .foregroundStyle(Color.AppText.secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
// MARK: - Debug Section
@@ -169,6 +315,33 @@ struct SettingsView: View {
SettingsDivider(color: AppBorder.subtle)
+ SettingsCardRow {
+ Button {
+ showingResetOnboardingConfirmation = true
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
+ Text("Reset Onboarding")
+ .typography(.bodyEmphasis)
+ .foregroundStyle(AppStatus.error)
+
+ Text("Show first-run onboarding again on next app launch")
+ .typography(.caption)
+ .foregroundStyle(Color.AppText.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "arrow.counterclockwise")
+ .typography(.subheading)
+ .foregroundStyle(AppStatus.error)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+
+ SettingsDivider(color: AppBorder.subtle)
+
SettingsNavigationRow(
title: "Icon Generator",
subtitle: "Generate and save app icon to Files",
diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift
index 4e2e026..38f92f5 100644
--- a/BusinessCard/Views/ShareCardView.swift
+++ b/BusinessCard/Views/ShareCardView.swift
@@ -41,6 +41,7 @@ struct ShareCardView: View {
// Share options
ShareOptionsSection(
card: card,
+ preferredAction: appState.appSettings.preferredShareAction,
vCardFileURL: $vCardFileURL,
messageComposeURL: $messageComposeURL,
mailComposeURL: $mailComposeURL
@@ -203,74 +204,108 @@ private struct AppClipSection: View {
// MARK: - Share Options Section
private struct ShareOptionsSection: View {
+ enum ShareOption: String, CaseIterable, Identifiable {
+ case shareSheet
+ case textMessage
+ case email
+
+ var id: String { rawValue }
+ }
+
let card: BusinessCard
+ let preferredAction: PreferredShareAction
@Binding var vCardFileURL: URL?
@Binding var messageComposeURL: IdentifiableURL?
@Binding var mailComposeURL: IdentifiableURL?
var body: some View {
VStack(spacing: 0) {
- // Share card (system share sheet - like Contacts app)
- if let url = vCardFileURL {
- ShareLink(item: url) {
- RowContent(
- title: String.localized("Share card"),
- systemImage: "square.and.arrow.up",
- iconColor: Color.ShareSheet.secondaryText
- )
- }
- .buttonStyle(.plain)
- } else {
- RowContent(
- title: String.localized("Share card"),
- systemImage: "square.and.arrow.up",
- iconColor: Color.ShareSheet.secondaryText.opacity(Design.Opacity.medium)
- )
- }
-
- Divider()
- .overlay(Color.ShareSheet.rowBackground)
-
- // Text your card (with .vcf attachment)
- if MFMessageComposeViewController.canSendText() {
- Button {
+ ForEach(Array(orderedOptions.enumerated()), id: \.element.id) { index, option in
+ switch option {
+ case .shareSheet:
if let url = vCardFileURL {
- messageComposeURL = IdentifiableURL(url: url)
+ ShareLink(item: url) {
+ RowContent(
+ title: String.localized("Share card"),
+ systemImage: "square.and.arrow.up",
+ iconColor: Color.ShareSheet.secondaryText
+ )
+ }
+ .buttonStyle(.plain)
+ } else {
+ RowContent(
+ title: String.localized("Share card"),
+ systemImage: "square.and.arrow.up",
+ iconColor: Color.ShareSheet.secondaryText.opacity(Design.Opacity.medium)
+ )
}
- } label: {
- RowContent(
- title: String.localized("Text your card"),
- systemImage: "message.fill",
- iconColor: Color.ShareSheet.secondaryText
- )
- }
- .buttonStyle(.plain)
- .disabled(vCardFileURL == nil)
-
- Divider()
- .overlay(Color.ShareSheet.rowBackground)
- }
-
- // Email your card (with .vcf attachment)
- if MFMailComposeViewController.canSendMail() {
- Button {
- if let url = vCardFileURL {
- mailComposeURL = IdentifiableURL(url: url)
+ case .textMessage:
+ Button {
+ if let url = vCardFileURL {
+ messageComposeURL = IdentifiableURL(url: url)
+ }
+ } label: {
+ RowContent(
+ title: String.localized("Text your card"),
+ systemImage: "message.fill",
+ iconColor: Color.ShareSheet.secondaryText
+ )
}
- } label: {
- RowContent(
- title: String.localized("Email your card"),
- systemImage: "envelope.fill",
- iconColor: Color.ShareSheet.secondaryText
- )
+ .buttonStyle(.plain)
+ .disabled(vCardFileURL == nil)
+ case .email:
+ Button {
+ if let url = vCardFileURL {
+ mailComposeURL = IdentifiableURL(url: url)
+ }
+ } label: {
+ RowContent(
+ title: String.localized("Email your card"),
+ systemImage: "envelope.fill",
+ iconColor: Color.ShareSheet.secondaryText
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(vCardFileURL == nil)
+ }
+
+ if index < orderedOptions.count - 1 {
+ Divider()
+ .overlay(Color.ShareSheet.rowBackground)
}
- .buttonStyle(.plain)
- .disabled(vCardFileURL == nil)
}
}
.background(Color.ShareSheet.cardBackground)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
+
+ private var orderedOptions: [ShareOption] {
+ let supportedOptions = availableOptions
+ guard let preferred = mapPreferredAction(preferredAction),
+ supportedOptions.contains(preferred) else {
+ return supportedOptions
+ }
+ return [preferred] + supportedOptions.filter { $0 != preferred }
+ }
+
+ private var availableOptions: [ShareOption] {
+ var options: [ShareOption] = [.shareSheet]
+ if MFMessageComposeViewController.canSendText() {
+ options.append(.textMessage)
+ }
+ if MFMailComposeViewController.canSendMail() {
+ options.append(.email)
+ }
+ return options
+ }
+
+ private func mapPreferredAction(_ action: PreferredShareAction) -> ShareOption? {
+ switch action {
+ case .shareSheet: .shareSheet
+ case .textMessage: .textMessage
+ case .email: .email
+ }
+ }
}
// MARK: - Empty State
@@ -400,5 +435,5 @@ private struct MailComposeView: UIViewControllerRepresentable {
#Preview {
ShareCardView()
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
}
diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Sheets/AddContactSheet.swift
index 58bf7c7..b205367 100644
--- a/BusinessCard/Views/Sheets/AddContactSheet.swift
+++ b/BusinessCard/Views/Sheets/AddContactSheet.swift
@@ -314,6 +314,7 @@ struct AddContactSheet: View {
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
+ followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
contactFields: contactFields,
photoData: photoData
)
@@ -445,5 +446,5 @@ private struct LabeledFieldRow: View {
#Preview {
AddContactSheet()
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
}
diff --git a/BusinessCardClip/Configuration/ClipIdentifiers.swift b/BusinessCardClip/Configuration/ClipIdentifiers.swift
index 6296ce7..5f53b84 100644
--- a/BusinessCardClip/Configuration/ClipIdentifiers.swift
+++ b/BusinessCardClip/Configuration/ClipIdentifiers.swift
@@ -11,7 +11,7 @@ enum ClipIdentifiers {
/// Must match the main app's container to access shared cards.
static let cloudKitContainerIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
- ?? "iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)"
+ ?? "iCloud.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)"
}()
/// App Clip domain for URL handling.
diff --git a/BusinessCardClip/Info.plist b/BusinessCardClip/Info.plist
index 91c1abb..0a89a87 100644
--- a/BusinessCardClip/Info.plist
+++ b/BusinessCardClip/Info.plist
@@ -7,7 +7,7 @@
AppClipDomain
$(APPCLIP_DOMAIN)
NSContactsUsageDescription
- Save this contact to your address book.
+ BusinessCard Clip saves this scanned card to your Apple Contacts when you tap Add to Contacts.
UILaunchScreen
UIColorName
diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift
index 6a2d167..160a5fd 100644
--- a/BusinessCardTests/BusinessCardTests.swift
+++ b/BusinessCardTests/BusinessCardTests.swift
@@ -8,7 +8,7 @@ struct BusinessCardTests {
private func makeTestContainer() throws -> ModelContainer {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
- return try ModelContainer(for: BusinessCard.self, Contact.self, configurations: config)
+ return try ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self, configurations: config)
}
@Test func vCardPayloadIncludesFields() async throws {