Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-10 22:01:50 -06:00
parent 2bd3722c1c
commit 5fa0a2e4eb
23 changed files with 961 additions and 136 deletions

View File

@ -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;

View File

@ -7,17 +7,17 @@
<key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>2</integer>
</dict>
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>3</integer>
</dict>
</dict>
</dict>

View File

@ -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)

View File

@ -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
// =============================================================================

View File

@ -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
}
}

View File

@ -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" : {
},

View File

@ -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) }

View File

@ -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<AppSettings>(
sortBy: [SortDescriptor(\.createdAt, order: .forward)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
private func saveContext() {
do {
try modelContext.save()
} catch {
modelContext.rollback()
}
}
}

View File

@ -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)

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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))
}
}

View File

@ -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))
}

View File

@ -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: {})
}

View File

@ -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<BusinessCard>())

View File

@ -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))
}

View File

@ -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))
}

View File

@ -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,13 +88,23 @@ struct SettingsView: View {
}
}
// MARK: - About Section
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)
}
private var aboutSection: some View {
// 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
)
@ -86,61 +112,181 @@ struct SettingsView: View {
backgroundColor: Color.AppBackground.elevated,
borderColor: AppBorder.standard
) {
if appState.cardStore.cards.isEmpty {
SettingsCardRow {
HStack {
Text(settingsState.appName)
.typography(.title3Bold)
.foregroundStyle(Color.AppText.primary)
Spacer()
}
}
SettingsDivider(color: AppBorder.subtle)
SettingsCardRow {
HStack {
Text(String.localized("Version"))
Text("Default Card")
.typography(.bodyEmphasis)
.foregroundStyle(Color.AppText.primary)
Spacer()
Text(settingsState.versionString)
.typography(.body)
.fontDesign(.monospaced)
.foregroundStyle(Color.AppText.secondary)
}
}
SettingsDivider(color: AppBorder.subtle)
SettingsCardRow {
HStack {
Text(String.localized("Developer"))
.typography(.bodyEmphasis)
.foregroundStyle(Color.AppText.primary)
Spacer()
Text("Matt Bruce")
Text("Create a card first")
.typography(.body)
.foregroundStyle(Color.AppText.secondary)
}
}
SettingsDivider(color: AppBorder.subtle)
} else {
SettingsNavigationRow(
title: String.localized("Widgets"),
subtitle: "Phone and watch preview",
title: "Default Card",
subtitle: defaultCardTitle,
backgroundColor: .clear
) {
WidgetsView()
DefaultCardSelectionView()
}
}
}
}
}
// 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",

View File

@ -41,6 +41,7 @@ struct ShareCardView: View {
// Share options
ShareOptionsSection(
card: card,
preferredAction: appState.appSettings.preferredShareAction,
vCardFileURL: $vCardFileURL,
messageComposeURL: $messageComposeURL,
mailComposeURL: $mailComposeURL
@ -203,14 +204,25 @@ 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)
ForEach(Array(orderedOptions.enumerated()), id: \.element.id) { index, option in
switch option {
case .shareSheet:
if let url = vCardFileURL {
ShareLink(item: url) {
RowContent(
@ -227,12 +239,7 @@ private struct ShareOptionsSection: View {
iconColor: Color.ShareSheet.secondaryText.opacity(Design.Opacity.medium)
)
}
Divider()
.overlay(Color.ShareSheet.rowBackground)
// Text your card (with .vcf attachment)
if MFMessageComposeViewController.canSendText() {
case .textMessage:
Button {
if let url = vCardFileURL {
messageComposeURL = IdentifiableURL(url: url)
@ -246,13 +253,7 @@ private struct ShareOptionsSection: View {
}
.buttonStyle(.plain)
.disabled(vCardFileURL == nil)
Divider()
.overlay(Color.ShareSheet.rowBackground)
}
// Email your card (with .vcf attachment)
if MFMailComposeViewController.canSendMail() {
case .email:
Button {
if let url = vCardFileURL {
mailComposeURL = IdentifiableURL(url: url)
@ -267,10 +268,44 @@ private struct ShareOptionsSection: View {
.buttonStyle(.plain)
.disabled(vCardFileURL == nil)
}
if index < orderedOptions.count - 1 {
Divider()
.overlay(Color.ShareSheet.rowBackground)
}
}
}
.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))
}

View File

@ -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))
}

View File

@ -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.

View File

@ -7,7 +7,7 @@
<key>AppClipDomain</key>
<string>$(APPCLIP_DOMAIN)</string>
<key>NSContactsUsageDescription</key>
<string>Save this contact to your address book.</string>
<string>BusinessCard Clip saves this scanned card to your Apple Contacts when you tap Add to Contacts.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>

View File

@ -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 {