Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2bd3722c1c
commit
5fa0a2e4eb
@ -626,9 +626,9 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusinessCard/Info.plist;
|
INFOPLIST_FILE = BusinessCard/Info.plist;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses your camera to scan QR cards and take profile, cover, or logo photos for your card.";
|
||||||
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
|
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 to add a profile photo to your business card.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
@ -663,9 +663,9 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = BusinessCard/Info.plist;
|
INFOPLIST_FILE = BusinessCard/Info.plist;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses the camera to scan QR codes on other people's business cards.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "BusinessCard uses your camera to scan QR cards and take profile, cover, or logo photos for your card.";
|
||||||
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
|
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 to add a profile photo to your business card.";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
|
||||||
|
|||||||
@ -7,17 +7,17 @@
|
|||||||
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>2</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>2</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct BusinessCardApp: App {
|
|||||||
@State private var appState: AppState
|
@State private var appState: AppState
|
||||||
|
|
||||||
init() {
|
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 =
|
let cloudKitDatabase: ModelConfiguration.CloudKitDatabase =
|
||||||
AppIdentifiers.isCloudKitSyncEnabled
|
AppIdentifiers.isCloudKitSyncEnabled
|
||||||
? .private(AppIdentifiers.cloudKitContainerIdentifier)
|
? .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
COMPANY_IDENTIFIER = com.mbrucedogs
|
COMPANY_IDENTIFIER = com.mbrucedogs
|
||||||
APP_NAME = BusinessCard
|
BUNDLE_ID_NAME = BusinessCard
|
||||||
|
PRODUCT_NAME = Business Card
|
||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -17,15 +18,15 @@ DEVELOPMENT_TEAM = 6R7KLBPBLZ
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Bundle identifiers
|
// Bundle identifiers
|
||||||
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)
|
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
|
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
|
||||||
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
|
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
|
||||||
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests
|
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)Tests
|
||||||
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
|
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)UITests
|
||||||
|
|
||||||
// Entitlement identifiers
|
// Entitlement identifiers
|
||||||
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)
|
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
CLOUDKIT_SYNC_ENABLED = YES
|
CLOUDKIT_SYNC_ENABLED = YES
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
64
BusinessCard/Models/AppSettings.swift
Normal file
64
BusinessCard/Models/AppSettings.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -218,6 +218,10 @@
|
|||||||
},
|
},
|
||||||
"Cover" : {
|
"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" : {
|
"Create multiple business cards" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -275,6 +279,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Default Card" : {
|
||||||
|
"comment" : "The title of the view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Delete" : {
|
"Delete" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,7 @@ final class AppPreferencesStore {
|
|||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let appearance = "appAppearance"
|
static let appearance = "appAppearance"
|
||||||
static let debugPremiumEnabled = "debugPremiumEnabled"
|
static let debugPremiumEnabled = "debugPremiumEnabled"
|
||||||
|
static let hasCompletedOnboarding = "hasCompletedOnboarding"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let userDefaults: UserDefaults
|
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
|
#if DEBUG
|
||||||
var isDebugPremiumEnabled: Bool {
|
var isDebugPremiumEnabled: Bool {
|
||||||
get { userDefaults.bool(forKey: Keys.debugPremiumEnabled) }
|
get { userDefaults.bool(forKey: Keys.debugPremiumEnabled) }
|
||||||
|
|||||||
56
BusinessCard/State/AppSettingsStore.swift
Normal file
56
BusinessCard/State/AppSettingsStore.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ final class AppState {
|
|||||||
var cardStore: CardStore
|
var cardStore: CardStore
|
||||||
var contactsStore: ContactsStore
|
var contactsStore: ContactsStore
|
||||||
let preferences: AppPreferencesStore
|
let preferences: AppPreferencesStore
|
||||||
|
let appSettings: AppSettingsStore
|
||||||
let shareLinkService: ShareLinkProviding
|
let shareLinkService: ShareLinkProviding
|
||||||
|
|
||||||
var preferredColorScheme: ColorScheme? {
|
var preferredColorScheme: ColorScheme? {
|
||||||
@ -39,6 +40,7 @@ final class AppState {
|
|||||||
self.cardStore = CardStore(modelContext: modelContext)
|
self.cardStore = CardStore(modelContext: modelContext)
|
||||||
self.contactsStore = ContactsStore(modelContext: modelContext)
|
self.contactsStore = ContactsStore(modelContext: modelContext)
|
||||||
self.preferences = AppPreferencesStore()
|
self.preferences = AppPreferencesStore()
|
||||||
|
self.appSettings = AppSettingsStore(modelContext: modelContext)
|
||||||
self.shareLinkService = ShareLinkService()
|
self.shareLinkService = ShareLinkService()
|
||||||
|
|
||||||
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
// Clean up expired shared cards on launch (best-effort, non-blocking)
|
||||||
|
|||||||
@ -1347,7 +1347,7 @@ private extension CardEditorView {
|
|||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview("New Card") {
|
#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 }
|
return CardEditorView(card: nil) { _ in }
|
||||||
.environment(AppState(modelContext: container.mainContext))
|
.environment(AppState(modelContext: container.mainContext))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,7 +142,7 @@ private struct EmptyCardsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self)
|
||||||
return CardsHomeView()
|
return CardsHomeView()
|
||||||
.environment(AppState(modelContext: container.mainContext))
|
.environment(AppState(modelContext: container.mainContext))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -641,6 +641,6 @@ private struct AddNoteSheet: View {
|
|||||||
phone: "2145328862"
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -229,5 +229,5 @@ private struct ContactAvatarView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ContactsView()
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
376
BusinessCard/Views/OnboardingView.swift
Normal file
376
BusinessCard/Views/OnboardingView.swift
Normal 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: {})
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ struct QRCodeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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
|
let context = container.mainContext
|
||||||
BusinessCard.createSamples(in: context)
|
BusinessCard.createSamples(in: context)
|
||||||
let cards = try! context.fetch(FetchDescriptor<BusinessCard>())
|
let cards = try! context.fetch(FetchDescriptor<BusinessCard>())
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import SwiftData
|
|||||||
struct RootTabView: View {
|
struct RootTabView: View {
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@State private var showingShareSheet = false
|
@State private var showingShareSheet = false
|
||||||
|
@State private var showingOnboarding = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var appState = appState
|
@Bindable var appState = appState
|
||||||
@ -35,6 +36,17 @@ struct RootTabView: View {
|
|||||||
.sheet(isPresented: $showingShareSheet) {
|
.sheet(isPresented: $showingShareSheet) {
|
||||||
ShareCardView()
|
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 {
|
#Preview {
|
||||||
RootTabView()
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
91
BusinessCard/Views/Settings/DefaultCardSelectionView.swift
Normal file
91
BusinessCard/Views/Settings/DefaultCardSelectionView.swift
Normal 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))
|
||||||
|
}
|
||||||
@ -12,16 +12,21 @@ import Bedrock
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var settingsState = SettingsState()
|
@State private var settingsState = SettingsState()
|
||||||
|
@State private var showingResetOnboardingConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Design.Spacing.large) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
|
||||||
// MARK: - About Section
|
// MARK: - Settings Sections
|
||||||
appearanceSection
|
appearanceSection
|
||||||
aboutSection
|
cardsSection
|
||||||
|
sharingSection
|
||||||
|
contactsSection
|
||||||
|
supportSection
|
||||||
|
|
||||||
// MARK: - Debug Section
|
// MARK: - Debug Section
|
||||||
|
|
||||||
@ -35,8 +40,19 @@ struct SettingsView: View {
|
|||||||
.padding(.top, Design.Spacing.medium)
|
.padding(.top, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.base)
|
.background(Color.AppBackground.base)
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
versionFooter
|
||||||
|
}
|
||||||
.navigationTitle(String.localized("Settings"))
|
.navigationTitle(String.localized("Settings"))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.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 versionFooter: some View {
|
||||||
|
Text(settingsState.versionString)
|
||||||
private var aboutSection: some View {
|
.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) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
SettingsSectionHeader(
|
SettingsSectionHeader(
|
||||||
title: String.localized("About"),
|
title: "Cards",
|
||||||
systemImage: "info.circle",
|
systemImage: "rectangle.stack",
|
||||||
accentColor: AppThemeAccent.primary
|
accentColor: AppThemeAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
backgroundColor: Color.AppBackground.elevated,
|
backgroundColor: Color.AppBackground.elevated,
|
||||||
borderColor: AppBorder.standard
|
borderColor: AppBorder.standard
|
||||||
) {
|
) {
|
||||||
SettingsCardRow {
|
if appState.cardStore.cards.isEmpty {
|
||||||
HStack {
|
SettingsCardRow {
|
||||||
Text(settingsState.appName)
|
HStack {
|
||||||
.typography(.title3Bold)
|
Text("Default Card")
|
||||||
.foregroundStyle(Color.AppText.primary)
|
.typography(.bodyEmphasis)
|
||||||
|
.foregroundStyle(Color.AppText.primary)
|
||||||
Spacer()
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("Create a card first")
|
||||||
|
.typography(.body)
|
||||||
|
.foregroundStyle(Color.AppText.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
SettingsNavigationRow(
|
||||||
SettingsDivider(color: AppBorder.subtle)
|
title: "Default Card",
|
||||||
|
subtitle: defaultCardTitle,
|
||||||
SettingsCardRow {
|
backgroundColor: .clear
|
||||||
HStack {
|
) {
|
||||||
Text(String.localized("Version"))
|
DefaultCardSelectionView()
|
||||||
.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")
|
|
||||||
.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
|
// MARK: - Debug Section
|
||||||
|
|
||||||
@ -169,6 +315,33 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
SettingsDivider(color: AppBorder.subtle)
|
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(
|
SettingsNavigationRow(
|
||||||
title: "Icon Generator",
|
title: "Icon Generator",
|
||||||
subtitle: "Generate and save app icon to Files",
|
subtitle: "Generate and save app icon to Files",
|
||||||
|
|||||||
@ -41,6 +41,7 @@ struct ShareCardView: View {
|
|||||||
// Share options
|
// Share options
|
||||||
ShareOptionsSection(
|
ShareOptionsSection(
|
||||||
card: card,
|
card: card,
|
||||||
|
preferredAction: appState.appSettings.preferredShareAction,
|
||||||
vCardFileURL: $vCardFileURL,
|
vCardFileURL: $vCardFileURL,
|
||||||
messageComposeURL: $messageComposeURL,
|
messageComposeURL: $messageComposeURL,
|
||||||
mailComposeURL: $mailComposeURL
|
mailComposeURL: $mailComposeURL
|
||||||
@ -203,74 +204,108 @@ private struct AppClipSection: View {
|
|||||||
// MARK: - Share Options Section
|
// MARK: - Share Options Section
|
||||||
|
|
||||||
private struct ShareOptionsSection: View {
|
private struct ShareOptionsSection: View {
|
||||||
|
enum ShareOption: String, CaseIterable, Identifiable {
|
||||||
|
case shareSheet
|
||||||
|
case textMessage
|
||||||
|
case email
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
let card: BusinessCard
|
let card: BusinessCard
|
||||||
|
let preferredAction: PreferredShareAction
|
||||||
@Binding var vCardFileURL: URL?
|
@Binding var vCardFileURL: URL?
|
||||||
@Binding var messageComposeURL: IdentifiableURL?
|
@Binding var messageComposeURL: IdentifiableURL?
|
||||||
@Binding var mailComposeURL: IdentifiableURL?
|
@Binding var mailComposeURL: IdentifiableURL?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Share card (system share sheet - like Contacts app)
|
ForEach(Array(orderedOptions.enumerated()), id: \.element.id) { index, option in
|
||||||
if let url = vCardFileURL {
|
switch option {
|
||||||
ShareLink(item: url) {
|
case .shareSheet:
|
||||||
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 {
|
|
||||||
if let url = vCardFileURL {
|
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: {
|
case .textMessage:
|
||||||
RowContent(
|
Button {
|
||||||
title: String.localized("Text your card"),
|
if let url = vCardFileURL {
|
||||||
systemImage: "message.fill",
|
messageComposeURL = IdentifiableURL(url: url)
|
||||||
iconColor: Color.ShareSheet.secondaryText
|
}
|
||||||
)
|
} label: {
|
||||||
}
|
RowContent(
|
||||||
.buttonStyle(.plain)
|
title: String.localized("Text your card"),
|
||||||
.disabled(vCardFileURL == nil)
|
systemImage: "message.fill",
|
||||||
|
iconColor: Color.ShareSheet.secondaryText
|
||||||
Divider()
|
)
|
||||||
.overlay(Color.ShareSheet.rowBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email your card (with .vcf attachment)
|
|
||||||
if MFMailComposeViewController.canSendMail() {
|
|
||||||
Button {
|
|
||||||
if let url = vCardFileURL {
|
|
||||||
mailComposeURL = IdentifiableURL(url: url)
|
|
||||||
}
|
}
|
||||||
} label: {
|
.buttonStyle(.plain)
|
||||||
RowContent(
|
.disabled(vCardFileURL == nil)
|
||||||
title: String.localized("Email your card"),
|
case .email:
|
||||||
systemImage: "envelope.fill",
|
Button {
|
||||||
iconColor: Color.ShareSheet.secondaryText
|
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)
|
.background(Color.ShareSheet.cardBackground)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.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
|
// MARK: - Empty State
|
||||||
@ -400,5 +435,5 @@ private struct MailComposeView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ShareCardView()
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -314,6 +314,7 @@ struct AddContactSheet: View {
|
|||||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
|
||||||
contactFields: contactFields,
|
contactFields: contactFields,
|
||||||
photoData: photoData
|
photoData: photoData
|
||||||
)
|
)
|
||||||
@ -445,5 +446,5 @@ private struct LabeledFieldRow: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddContactSheet()
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ enum ClipIdentifiers {
|
|||||||
/// Must match the main app's container to access shared cards.
|
/// Must match the main app's container to access shared cards.
|
||||||
static let cloudKitContainerIdentifier: String = {
|
static let cloudKitContainerIdentifier: String = {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? 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.
|
/// App Clip domain for URL handling.
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
<key>AppClipDomain</key>
|
<key>AppClipDomain</key>
|
||||||
<string>$(APPCLIP_DOMAIN)</string>
|
<string>$(APPCLIP_DOMAIN)</string>
|
||||||
<key>NSContactsUsageDescription</key>
|
<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>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ struct BusinessCardTests {
|
|||||||
|
|
||||||
private func makeTestContainer() throws -> ModelContainer {
|
private func makeTestContainer() throws -> ModelContainer {
|
||||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
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 {
|
@Test func vCardPayloadIncludesFields() async throws {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user