From 5fa0a2e4ebb208f5794d1f945444d635db65d334 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 10 Feb 2026 22:01:50 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard.xcodeproj/project.pbxproj | 12 +- .../xcschemes/xcschememanagement.plist | 6 +- BusinessCard/BusinessCardApp.swift | 2 +- BusinessCard/Configuration/Base.xcconfig | 13 +- BusinessCard/Models/AppSettings.swift | 64 +++ BusinessCard/Resources/Localizable.xcstrings | 8 + BusinessCard/State/AppPreferencesStore.swift | 6 + BusinessCard/State/AppSettingsStore.swift | 56 +++ BusinessCard/State/AppState.swift | 2 + BusinessCard/Views/CardEditorView.swift | 2 +- BusinessCard/Views/CardsHomeView.swift | 2 +- BusinessCard/Views/ContactDetailView.swift | 2 +- BusinessCard/Views/ContactsView.swift | 2 +- BusinessCard/Views/OnboardingView.swift | 376 ++++++++++++++++++ BusinessCard/Views/QRCodeView.swift | 2 +- BusinessCard/Views/RootTabView.swift | 14 +- .../Settings/DefaultCardSelectionView.swift | 91 +++++ BusinessCard/Views/SettingsView.swift | 287 ++++++++++--- BusinessCard/Views/ShareCardView.swift | 141 ++++--- .../Views/Sheets/AddContactSheet.swift | 3 +- .../Configuration/ClipIdentifiers.swift | 2 +- BusinessCardClip/Info.plist | 2 +- BusinessCardTests/BusinessCardTests.swift | 2 +- 23 files changed, 961 insertions(+), 136 deletions(-) create mode 100644 BusinessCard/Models/AppSettings.swift create mode 100644 BusinessCard/State/AppSettingsStore.swift create mode 100644 BusinessCard/Views/OnboardingView.swift create mode 100644 BusinessCard/Views/Settings/DefaultCardSelectionView.swift 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 {