Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2bd3722c1c
commit
5fa0a2e4eb
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
// =============================================================================
|
||||
|
||||
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" : {
|
||||
|
||||
},
|
||||
"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" : {
|
||||
|
||||
},
|
||||
|
||||
@ -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) }
|
||||
|
||||
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 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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
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 {
|
||||
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>())
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
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 {
|
||||
@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",
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user