diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj
index 7251f64..895dca1 100644
--- a/BusinessCard.xcodeproj/project.pbxproj
+++ b/BusinessCard.xcodeproj/project.pbxproj
@@ -70,7 +70,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
- EA8379232F105F2600077F87 /* BusinessCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ EA8379232F105F2600077F87 /* Business Card.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Business Card.app"; sourceTree = BUILT_PRODUCTS_DIR; };
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -188,7 +188,7 @@
EA8379242F105F2600077F87 /* Products */ = {
isa = PBXGroup;
children = (
- EA8379232F105F2600077F87 /* BusinessCard.app */,
+ EA8379232F105F2600077F87 /* Business Card.app */,
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
@@ -241,7 +241,7 @@
EA69DC812F3C199C00592220 /* Bedrock */,
);
productName = BusinessCard;
- productReference = EA8379232F105F2600077F87 /* BusinessCard.app */;
+ productReference = EA8379232F105F2600077F87 /* Business Card.app */;
productType = "com.apple.product-type.application";
};
EA83792F2F105F2800077F87 /* BusinessCardTests */ = {
@@ -641,7 +641,6 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
- PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@@ -679,7 +678,6 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
- PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
index c68cbe9..0771ad4 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -3,4 +3,22 @@
uuid = "6FB169DC-E619-40A8-968F-910EF3CF4FA4"
type = "1"
version = "2.0">
+
+
+
+
+
+
diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
index 43dc255..2643284 100644
--- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,12 +12,12 @@
BusinessCardClip.xcscheme_^#shared#^_
orderHint
- 3
+ 1
BusinessCardWatch Watch App.xcscheme_^#shared#^_
orderHint
- 1
+ 3
diff --git a/BusinessCard/Configuration/AppIdentifiers.swift b/BusinessCard/Configuration/AppIdentifiers.swift
index 6e7b042..ad6ebe3 100644
--- a/BusinessCard/Configuration/AppIdentifiers.swift
+++ b/BusinessCard/Configuration/AppIdentifiers.swift
@@ -13,6 +13,13 @@ enum AppIdentifiers {
// MARK: - Runtime Identifiers (read from Info.plist)
+ /// Public app name for user-facing UI text.
+ static let publicAppName: String = {
+ (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
+ ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String)
+ ?? "App"
+ }()
+
/// App Group identifier for sharing data between app and extensions.
static let appGroupIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
diff --git a/BusinessCard/Design/BusinessCardAccentColors.swift b/BusinessCard/Design/BusinessCardAccentColors.swift
new file mode 100644
index 0000000..bbf29f4
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardAccentColors.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardAccentColors: AccentColorProvider {
+ public static let primary = Color(red: 0.95, green: 0.33, blue: 0.28)
+ public static let light = Color(red: 0.98, green: 0.50, blue: 0.45)
+ public static let dark = Color(red: 0.75, green: 0.25, blue: 0.22)
+ public static let secondary = Color(red: 0.12, green: 0.12, blue: 0.14)
+}
diff --git a/BusinessCard/Design/BusinessCardBorderColors.swift b/BusinessCard/Design/BusinessCardBorderColors.swift
new file mode 100644
index 0000000..ad12bfa
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardBorderColors.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardBorderColors: BorderColorProvider {
+ public static let subtle = Color.AppText.tertiary.opacity(Design.Opacity.subtle)
+ public static let standard = Color.AppText.tertiary.opacity(Design.Opacity.hint)
+ public static let emphasized = Color.AppText.secondary.opacity(Design.Opacity.light)
+ public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.medium)
+}
diff --git a/BusinessCard/Design/BusinessCardButtonColors.swift b/BusinessCard/Design/BusinessCardButtonColors.swift
new file mode 100644
index 0000000..c7a06b6
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardButtonColors.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardButtonColors: ButtonColorProvider {
+ public static let primaryLight = Color(red: 0.98, green: 0.45, blue: 0.40)
+ public static let primaryDark = Color(red: 0.85, green: 0.28, blue: 0.24)
+ public static let secondary = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
+ public static let destructive = Color.red.opacity(Design.Opacity.heavy)
+ public static let cancelText = Color(red: 0.32, green: 0.34, blue: 0.40)
+}
diff --git a/BusinessCard/Design/BusinessCardInteractiveColors.swift b/BusinessCard/Design/BusinessCardInteractiveColors.swift
new file mode 100644
index 0000000..2fb03cd
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardInteractiveColors.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardInteractiveColors: InteractiveColorProvider {
+ public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.selection)
+ public static let hover = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
+ public static let pressed = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.hint)
+ public static let focus = BusinessCardAccentColors.light
+}
diff --git a/BusinessCard/Design/BusinessCardStatusColors.swift b/BusinessCard/Design/BusinessCardStatusColors.swift
new file mode 100644
index 0000000..0b6ac69
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardStatusColors.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardStatusColors: StatusColorProvider {
+ public static let success = Color(red: 0.2, green: 0.75, blue: 0.4)
+ public static let warning = Color(red: 0.95, green: 0.75, blue: 0.25)
+ public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
+ public static let info = Color(red: 0.3, green: 0.6, blue: 0.9)
+}
diff --git a/BusinessCard/Design/BusinessCardSurfaceColors.swift b/BusinessCard/Design/BusinessCardSurfaceColors.swift
new file mode 100644
index 0000000..8d5fdc2
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardSurfaceColors.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardSurfaceColors: SurfaceColorProvider {
+ public static let primary = Color.AppBackground.base
+ public static let secondary = Color.AppBackground.secondary
+ public static let tertiary = Color.AppBackground.elevated
+ public static let overlay = Color.AppBackground.base
+ public static let card = Color.AppBackground.card
+ public static let groupedFill = Color.AppBackground.accent
+ public static let sectionFill = Color.AppBackground.secondary
+}
diff --git a/BusinessCard/Design/BusinessCardTextColors.swift b/BusinessCard/Design/BusinessCardTextColors.swift
new file mode 100644
index 0000000..4f3f8ee
--- /dev/null
+++ b/BusinessCard/Design/BusinessCardTextColors.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+import Bedrock
+
+public enum BusinessCardTextColors: TextColorProvider {
+ public static let primary = Color.AppText.primary
+ public static let secondary = Color.AppText.secondary
+ public static let tertiary = Color.AppText.tertiary
+ public static let disabled = Color.AppText.tertiary.opacity(Design.Opacity.strong)
+ public static let placeholder = Color.AppText.tertiary
+ public static let inverse = Color.AppText.inverted
+}
diff --git a/BusinessCard/Design/BusinessCardTheme.swift b/BusinessCard/Design/BusinessCardTheme.swift
index 400ef41..125ddf9 100644
--- a/BusinessCard/Design/BusinessCardTheme.swift
+++ b/BusinessCard/Design/BusinessCardTheme.swift
@@ -1,112 +1,7 @@
-//
-// BusinessCardTheme.swift
-// BusinessCard
-//
-// App-specific adaptive theme conforming to Bedrock's color protocols.
-// Uses warm light colors and deep slate dark colors.
-//
-
import SwiftUI
import Bedrock
-// MARK: - Surface Colors
-
-/// Surface colors with warm off-white light tones and deep slate dark tones.
-public enum BusinessCardSurfaceColors: SurfaceColorProvider {
- /// Primary background
- public static let primary = Color.AppBackground.base
-
- /// Secondary/elevated surface
- public static let secondary = Color.AppBackground.secondary
-
- /// Tertiary/card surface - most elevated
- public static let tertiary = Color.AppBackground.elevated
-
- /// Overlay background (for sheets/modals)
- public static let overlay = Color.AppBackground.base
-
- /// Card/grouped element background
- public static let card = Color.AppBackground.card
-
- /// Subtle fill for grouped content sections
- public static let groupedFill = Color.AppBackground.accent
-
- /// Section fill for list sections
- public static let sectionFill = Color.AppBackground.secondary
-}
-
-// MARK: - Text Colors
-
-public enum BusinessCardTextColors: TextColorProvider {
- public static let primary = Color.AppText.primary
- public static let secondary = Color.AppText.secondary
- public static let tertiary = Color.AppText.tertiary
- public static let disabled = Color.AppText.tertiary.opacity(Design.Opacity.strong)
- public static let placeholder = Color.AppText.tertiary
- public static let inverse = Color.AppText.inverted
-}
-
-// MARK: - Accent Colors
-
-public enum BusinessCardAccentColors: AccentColorProvider {
- /// Primary accent - warm red
- public static let primary = Color(red: 0.95, green: 0.33, blue: 0.28)
-
- /// Light variant
- public static let light = Color(red: 0.98, green: 0.50, blue: 0.45)
-
- /// Dark variant
- public static let dark = Color(red: 0.75, green: 0.25, blue: 0.22)
-
- /// Secondary accent - ink/dark
- public static let secondary = Color(red: 0.12, green: 0.12, blue: 0.14)
-}
-
-// MARK: - Button Colors
-
-public enum BusinessCardButtonColors: ButtonColorProvider {
- public static let primaryLight = Color(red: 0.98, green: 0.45, blue: 0.40)
- public static let primaryDark = Color(red: 0.85, green: 0.28, blue: 0.24)
- public static let secondary = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
- public static let destructive = Color.red.opacity(Design.Opacity.heavy)
- public static let cancelText = Color(red: 0.32, green: 0.34, blue: 0.40)
-}
-
-// MARK: - Status Colors
-
-public enum BusinessCardStatusColors: StatusColorProvider {
- public static let success = Color(red: 0.2, green: 0.75, blue: 0.4)
- public static let warning = Color(red: 0.95, green: 0.75, blue: 0.25)
- public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
- public static let info = Color(red: 0.3, green: 0.6, blue: 0.9)
-}
-
-// MARK: - Border Colors
-
-public enum BusinessCardBorderColors: BorderColorProvider {
- public static let subtle = Color.AppText.tertiary.opacity(Design.Opacity.subtle)
- public static let standard = Color.AppText.tertiary.opacity(Design.Opacity.hint)
- public static let emphasized = Color.AppText.secondary.opacity(Design.Opacity.light)
- public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.medium)
-}
-
-// MARK: - Interactive Colors
-
-public enum BusinessCardInteractiveColors: InteractiveColorProvider {
- public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.selection)
- public static let hover = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
- public static let pressed = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.hint)
- public static let focus = BusinessCardAccentColors.light
-}
-
-// MARK: - Combined Theme
-
-/// BusinessCard's complete color theme.
-/// Note: We use the individual color provider typealiases (AppSurface, AppThemeAccent, etc.)
-/// directly in views rather than going through the theme type.
-///
-/// This enum is not used directly but documents the theme structure.
-/// The AppColorTheme conformance is omitted to avoid MainActor isolation conflicts.
+/// BusinessCard's complete color theme wrapper.
public enum BusinessCardTheme {
public typealias Surface = BusinessCardSurfaceColors
public typealias Text = BusinessCardTextColors
@@ -117,16 +12,7 @@ public enum BusinessCardTheme {
public typealias Interactive = BusinessCardInteractiveColors
}
-// MARK: - Convenience Typealiases
-
/// Short typealiases for cleaner usage throughout the app.
-/// These avoid conflicts with Bedrock's default typealiases by using unique names.
-///
-/// Usage:
-/// ```swift
-/// .background(AppSurface.primary)
-/// .foregroundStyle(AppThemeAccent.primary)
-/// ```
typealias AppSurface = BusinessCardSurfaceColors
typealias AppThemeText = BusinessCardTextColors
typealias AppThemeAccent = BusinessCardAccentColors
diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift
index 82cd3e9..d23e878 100644
--- a/BusinessCard/Design/DesignConstants.swift
+++ b/BusinessCard/Design/DesignConstants.swift
@@ -197,17 +197,6 @@ extension Color {
typealias Text = AppText
}
-// MARK: - Keyboard Dismiss Helpers
-
-private struct KeyboardDismissModifier: ViewModifier {
- func body(content: Content) -> some View {
- content
- .autocorrectionDisabled(true)
- .textInputAutocapitalization(.sentences)
- .scrollDismissesKeyboard(.interactively)
- }
-}
-
extension View {
/// Adds standard iOS keyboard dismissal behavior:
/// - interactive scroll-to-dismiss
diff --git a/BusinessCard/Design/KeyboardDismissModifier.swift b/BusinessCard/Design/KeyboardDismissModifier.swift
new file mode 100644
index 0000000..469a26c
--- /dev/null
+++ b/BusinessCard/Design/KeyboardDismissModifier.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+struct KeyboardDismissModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .autocorrectionDisabled(true)
+ .textInputAutocapitalization(.sentences)
+ .scrollDismissesKeyboard(.interactively)
+ }
+}
diff --git a/BusinessCard/Models/AppSettings.swift b/BusinessCard/Models/AppSettings.swift
index 91f324a..a27f5c5 100644
--- a/BusinessCard/Models/AppSettings.swift
+++ b/BusinessCard/Models/AppSettings.swift
@@ -1,45 +1,6 @@
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
diff --git a/BusinessCard/Models/BannerContentType.swift b/BusinessCard/Models/BannerContentType.swift
new file mode 100644
index 0000000..3081da7
--- /dev/null
+++ b/BusinessCard/Models/BannerContentType.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// What fills the banner area of the card header.
+enum BannerContentType: Sendable {
+ case profile
+ case logo
+ case cover
+}
diff --git a/BusinessCard/Models/CardHeaderLayout.swift b/BusinessCard/Models/CardHeaderLayout.swift
index 185f648..0496976 100644
--- a/BusinessCard/Models/CardHeaderLayout.swift
+++ b/BusinessCard/Models/CardHeaderLayout.swift
@@ -1,31 +1,5 @@
import Foundation
-// MARK: - Banner Content Type
-
-/// What fills the banner area of the card header.
-enum BannerContentType: Sendable {
- /// Profile photo fills the entire banner
- case profile
- /// Logo (3:2 landscape) fills the banner
- case logo
- /// Cover photo fills the banner
- case cover
-}
-
-// MARK: - Content Overlay Type
-
-/// What overlaps from the banner into the content area.
-enum ContentOverlayType: Sendable {
- /// No overlay - content starts immediately below banner
- case none
- /// Circular avatar overlapping from banner
- case avatar
- /// Logo rectangle (3:2) overlapping from banner
- case logoRectangle
- /// Both avatar and logo displayed side-by-side overlapping
- case avatarAndLogo
-}
-
// MARK: - Card Header Layout
/// Defines how the business card header arranges profile, cover, and logo images.
diff --git a/BusinessCard/Models/ContactFieldCategory.swift b/BusinessCard/Models/ContactFieldCategory.swift
new file mode 100644
index 0000000..52b7c6e
--- /dev/null
+++ b/BusinessCard/Models/ContactFieldCategory.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// Category for grouping contact field types in the picker
+enum ContactFieldCategory: String, CaseIterable, Sendable {
+ case contact
+ case social
+ case developer
+ case messaging
+ case payment
+ case creator
+ case scheduling
+ case other
+
+ var displayName: String {
+ switch self {
+ case .contact: return String(localized: "Contact")
+ case .social: return String(localized: "Social Media")
+ case .developer: return String(localized: "Developer")
+ case .messaging: return String(localized: "Messaging")
+ case .payment: return String(localized: "Payment")
+ case .creator: return String(localized: "Support & Funding")
+ case .scheduling: return String(localized: "Scheduling")
+ case .other: return String(localized: "Other")
+ }
+ }
+}
diff --git a/BusinessCard/Models/ContactFieldType.swift b/BusinessCard/Models/ContactFieldType.swift
index 120779a..feeff91 100644
--- a/BusinessCard/Models/ContactFieldType.swift
+++ b/BusinessCard/Models/ContactFieldType.swift
@@ -1,30 +1,5 @@
import SwiftUI
-/// Category for grouping contact field types in the picker
-enum ContactFieldCategory: String, CaseIterable, Sendable {
- case contact
- case social
- case developer
- case messaging
- case payment
- case creator
- case scheduling
- case other
-
- var displayName: String {
- switch self {
- case .contact: return String(localized: "Contact")
- case .social: return String(localized: "Social Media")
- case .developer: return String(localized: "Developer")
- case .messaging: return String(localized: "Messaging")
- case .payment: return String(localized: "Payment")
- case .creator: return String(localized: "Support & Funding")
- case .scheduling: return String(localized: "Scheduling")
- case .other: return String(localized: "Other")
- }
- }
-}
-
/// Defines a contact field type with all its configuration
struct ContactFieldType: Identifiable, Hashable, Sendable {
let id: String
@@ -715,132 +690,3 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
}
// MARK: - Phone Text Utilities
-
-enum PhoneNumberText {
- nonisolated static func normalizedForStorage(_ value: String) -> String {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- let hasLeadingPlus = trimmed.hasPrefix("+")
- let digits = trimmed.filter(\.isNumber)
-
- guard !digits.isEmpty else { return "" }
- if hasLeadingPlus {
- return "+\(digits)"
- }
- return digits
- }
-
- nonisolated static func isValid(_ value: String) -> Bool {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return false }
-
- let plusCount = trimmed.filter { $0 == "+" }.count
- if plusCount > 1 { return false }
- if plusCount == 1 && !trimmed.hasPrefix("+") { return false }
-
- let digits = trimmed.filter(\.isNumber)
- return (7...15).contains(digits.count)
- }
-
- nonisolated static func formatted(_ raw: String) -> String {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return "" }
-
- let hasLeadingPlus = trimmed.hasPrefix("+")
- let digits = trimmed.filter(\.isNumber)
- guard !digits.isEmpty else { return hasLeadingPlus ? "+" : "" }
-
- if hasLeadingPlus {
- if digits.count == 11, digits.first == "1" {
- return "+1 \(formatUSDigits(String(digits.dropFirst())))"
- }
- return "+" + formatInternationalDigits(digits)
- }
-
- if digits.count == 11, digits.first == "1" {
- return "1 \(formatUSDigits(String(digits.dropFirst())))"
- }
- if digits.count <= 10 {
- return formatUSDigits(digits)
- }
- return formatInternationalDigits(digits)
- }
-
- nonisolated private static func formatUSDigits(_ digits: String) -> String {
- if digits.isEmpty { return "" }
-
- if digits.count <= 3 {
- return digits
- }
-
- let area = String(digits.prefix(3))
- let remaining = String(digits.dropFirst(3))
-
- if remaining.count <= 3 {
- return "(\(area)) \(remaining)"
- }
-
- let prefix = String(remaining.prefix(3))
- let line = String(remaining.dropFirst(3).prefix(4))
- return "(\(area)) \(prefix)-\(line)"
- }
-
- nonisolated private static func formatInternationalDigits(_ digits: String) -> String {
- guard !digits.isEmpty else { return "" }
-
- if digits.count <= 3 {
- return digits
- }
-
- var groups: [String] = []
- var index = digits.startIndex
-
- while index < digits.endIndex {
- let next = digits.index(index, offsetBy: 3, limitedBy: digits.endIndex) ?? digits.endIndex
- groups.append(String(digits[index.. String {
- value
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- }
-
- nonisolated static func isValid(_ value: String) -> Bool {
- let trimmed = normalizedForStorage(value)
- guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
-
- let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
- guard parts.count == 2 else { return false }
-
- let local = String(parts[0])
- let domain = String(parts[1])
-
- guard !local.isEmpty, !domain.isEmpty else { return false }
- guard domain.contains("."), !domain.hasPrefix("."), !domain.hasSuffix(".") else { return false }
- return true
- }
-}
-
-enum WebLinkText {
- nonisolated static func normalizedForStorage(_ value: String) -> String {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return "" }
- if let url = buildWebURL(trimmed) {
- return url.absoluteString
- }
- return trimmed
- }
-
- nonisolated static func isValid(_ value: String) -> Bool {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
- guard let url = buildWebURL(trimmed) else { return false }
- return url.host != nil
- }
-}
diff --git a/BusinessCard/Models/ContentOverlayType.swift b/BusinessCard/Models/ContentOverlayType.swift
new file mode 100644
index 0000000..2c321b2
--- /dev/null
+++ b/BusinessCard/Models/ContentOverlayType.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+/// What overlaps from the banner into the content area.
+enum ContentOverlayType: Sendable {
+ case none
+ case avatar
+ case logoRectangle
+ case avatarAndLogo
+}
diff --git a/BusinessCard/Models/DefaultFollowUpPreset.swift b/BusinessCard/Models/DefaultFollowUpPreset.swift
new file mode 100644
index 0000000..5337cf8
--- /dev/null
+++ b/BusinessCard/Models/DefaultFollowUpPreset.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+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)
+ }
+ }
+}
diff --git a/BusinessCard/Models/EmailText.swift b/BusinessCard/Models/EmailText.swift
new file mode 100644
index 0000000..fa1e370
--- /dev/null
+++ b/BusinessCard/Models/EmailText.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum EmailText {
+ nonisolated static func normalizedForStorage(_ value: String) -> String {
+ value
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = normalizedForStorage(value)
+ guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
+
+ let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
+ guard parts.count == 2 else { return false }
+
+ let local = String(parts[0])
+ let domain = String(parts[1])
+
+ guard !local.isEmpty, !domain.isEmpty else { return false }
+ guard domain.contains("."), !domain.hasPrefix("."), !domain.hasSuffix(".") else { return false }
+ return true
+ }
+}
diff --git a/BusinessCard/Models/PhoneNumberText.swift b/BusinessCard/Models/PhoneNumberText.swift
new file mode 100644
index 0000000..ad92ac6
--- /dev/null
+++ b/BusinessCard/Models/PhoneNumberText.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+enum PhoneNumberText {
+ nonisolated static func normalizedForStorage(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let hasLeadingPlus = trimmed.hasPrefix("+")
+ let digits = trimmed.filter(\.isNumber)
+
+ guard !digits.isEmpty else { return "" }
+ if hasLeadingPlus {
+ return "+\(digits)"
+ }
+ return digits
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return false }
+
+ let plusCount = trimmed.filter { $0 == "+" }.count
+ if plusCount > 1 { return false }
+ if plusCount == 1 && !trimmed.hasPrefix("+") { return false }
+
+ let digits = trimmed.filter(\.isNumber)
+ return (7...15).contains(digits.count)
+ }
+
+ nonisolated static func formatted(_ raw: String) -> String {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+
+ let hasLeadingPlus = trimmed.hasPrefix("+")
+ let digits = trimmed.filter(\.isNumber)
+ guard !digits.isEmpty else { return hasLeadingPlus ? "+" : "" }
+
+ if hasLeadingPlus {
+ if digits.count == 11, digits.first == "1" {
+ return "+1 \(formatUSDigits(String(digits.dropFirst())))"
+ }
+ return "+" + formatInternationalDigits(digits)
+ }
+
+ if digits.count == 11, digits.first == "1" {
+ return "1 \(formatUSDigits(String(digits.dropFirst())))"
+ }
+ if digits.count <= 10 {
+ return formatUSDigits(digits)
+ }
+ return formatInternationalDigits(digits)
+ }
+
+ nonisolated private static func formatUSDigits(_ digits: String) -> String {
+ if digits.isEmpty { return "" }
+
+ if digits.count <= 3 {
+ return digits
+ }
+
+ let area = String(digits.prefix(3))
+ let remaining = String(digits.dropFirst(3))
+
+ if remaining.count <= 3 {
+ return "(\(area)) \(remaining)"
+ }
+
+ let prefix = String(remaining.prefix(3))
+ let line = String(remaining.dropFirst(3).prefix(4))
+ return "(\(area)) \(prefix)-\(line)"
+ }
+
+ nonisolated private static func formatInternationalDigits(_ digits: String) -> String {
+ guard !digits.isEmpty else { return "" }
+
+ if digits.count <= 3 {
+ return digits
+ }
+
+ var groups: [String] = []
+ var index = digits.startIndex
+
+ while index < digits.endIndex {
+ let next = digits.index(index, offsetBy: 3, limitedBy: digits.endIndex) ?? digits.endIndex
+ groups.append(String(digits[index.. String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+ if let url = buildURL(trimmed) {
+ return url.absoluteString
+ }
+ return trimmed
+ }
+
+ nonisolated static func isValid(_ value: String) -> Bool {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !trimmed.contains(" ") else { return false }
+ guard let url = buildURL(trimmed) else { return false }
+ return url.host != nil
+ }
+
+ nonisolated private static func buildURL(_ value: String) -> URL? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
+ return URL(string: trimmed)
+ }
+ return URL(string: "https://\(trimmed)")
+ }
+}
diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings
index f2d4db0..2da1523 100644
--- a/BusinessCard/Resources/Localizable.xcstrings
+++ b/BusinessCard/Resources/Localizable.xcstrings
@@ -246,6 +246,10 @@
}
}
},
+ "Create one polished card, then share it anywhere in seconds." : {
+ "comment" : "A subtitle for the first onboarding feature.",
+ "isCommentAutoGenerated" : true
+ },
"Create your first card" : {
},
@@ -416,10 +420,6 @@
},
"Maiden Name" : {
- },
- "Matt Bruce" : {
- "comment" : "The name of the developer of the app.",
- "isCommentAutoGenerated" : true
},
"Messaging" : {
@@ -429,6 +429,10 @@
},
"More..." : {
+ },
+ "Next step: create your first card. Once it is saved, you can start sharing immediately." : {
+ "comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
+ "isCommentAutoGenerated" : true
},
"No card selected" : {
@@ -438,6 +442,10 @@
},
"Notes" : {
+ },
+ "Onboarding will be shown again the next time you open the app." : {
+ "comment" : "An alert message that appears when the user confirms resetting the onboarding state.",
+ "isCommentAutoGenerated" : true
},
"Open on Apple Watch" : {
"localizations" : {
@@ -592,6 +600,13 @@
},
"Removes this field" : {
+ },
+ "Reset" : {
+ "comment" : "The text on a button that resets onboarding for a user.",
+ "isCommentAutoGenerated" : true
+ },
+ "Reset Onboarding" : {
+
},
"Save" : {
@@ -754,6 +769,10 @@
}
}
},
+ "Show first-run onboarding again on next app launch" : {
+ "comment" : "A description of the reset onboarding feature.",
+ "isCommentAutoGenerated" : true
+ },
"Social Media" : {
},
@@ -933,6 +952,10 @@
},
"Website URL" : {
+ },
+ "Welcome to %@" : {
+ "comment" : "A title and description for the welcome step in the onboarding flow. The argument is the name of the app.",
+ "isCommentAutoGenerated" : true
},
"Widgets on iPhone and Watch" : {
"comment" : "A title for a view that showcases widgets for iPhone and watch faces.",
@@ -943,6 +966,10 @@
},
"Write down a memorable reminder about your contact" : {
+ },
+ "You are ready to share" : {
+ "comment" : "A heading displayed in the \"Activation\" step of the onboarding flow.",
+ "isCommentAutoGenerated" : true
},
"Your Contact Fields" : {
diff --git a/BusinessCard/Services/BundleAppMetadataProvider.swift b/BusinessCard/Services/BundleAppMetadataProvider.swift
index 7ae486c..aac179e 100644
--- a/BusinessCard/Services/BundleAppMetadataProvider.swift
+++ b/BusinessCard/Services/BundleAppMetadataProvider.swift
@@ -8,9 +8,7 @@ struct BundleAppMetadataProvider: AppMetadataProviding {
}
var appName: String {
- bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
- ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
- ?? "App"
+ AppIdentifiers.publicAppName
}
var appVersion: String {
diff --git a/BusinessCard/Services/SharedCardCloudKitService.swift b/BusinessCard/Services/SharedCardCloudKitService.swift
index 3d613e2..6717955 100644
--- a/BusinessCard/Services/SharedCardCloudKitService.swift
+++ b/BusinessCard/Services/SharedCardCloudKitService.swift
@@ -91,29 +91,3 @@ struct SharedCardCloudKitService: SharedCardProviding {
return fileURL
}
}
-
-// MARK: - Error Types
-
-/// Errors that can occur during shared card operations.
-enum SharedCardError: Error, LocalizedError {
- case invalidURL
- case uploadFailed(Error)
- case fetchFailed(Error)
- case recordNotFound
- case recordExpired
-
- var errorDescription: String? {
- switch self {
- case .invalidURL:
- return String(localized: "Failed to create share URL")
- case .uploadFailed(let error):
- return String(localized: "Upload failed: \(error.localizedDescription)")
- case .fetchFailed(let error):
- return String(localized: "Could not load card: \(error.localizedDescription)")
- case .recordNotFound:
- return String(localized: "Card not found")
- case .recordExpired:
- return String(localized: "This card has expired")
- }
- }
-}
diff --git a/BusinessCard/Services/SharedCardError.swift b/BusinessCard/Services/SharedCardError.swift
new file mode 100644
index 0000000..c73805d
--- /dev/null
+++ b/BusinessCard/Services/SharedCardError.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum SharedCardError: Error, LocalizedError {
+ case invalidURL
+ case uploadFailed(Error)
+ case fetchFailed(Error)
+ case recordNotFound
+ case recordExpired
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return String(localized: "Failed to create share URL")
+ case .uploadFailed(let error):
+ return String(localized: "Upload failed: \(error.localizedDescription)")
+ case .fetchFailed(let error):
+ return String(localized: "Could not load card: \(error.localizedDescription)")
+ case .recordNotFound:
+ return String(localized: "Card not found")
+ case .recordExpired:
+ return String(localized: "This card has expired")
+ }
+ }
+}
diff --git a/BusinessCard/Services/WatchConnectivityService.swift b/BusinessCard/Services/WatchConnectivityService.swift
index cbff843..11ec411 100644
--- a/BusinessCard/Services/WatchConnectivityService.swift
+++ b/BusinessCard/Services/WatchConnectivityService.swift
@@ -269,23 +269,3 @@ extension WatchConnectivityService: WCSessionDelegate {
}
}
}
-
-/// A simplified card structure that can be shared between iOS and watchOS
-struct SyncableCard: Codable, Identifiable {
- let id: UUID
- var fullName: String
- var role: String
- var company: String
- var email: String
- var phone: String
- var website: String
- var location: String
- var isDefault: Bool
- var pronouns: String
- var bio: String
- var linkedIn: String
- var twitter: String
- var instagram: String
- /// Pre-generated QR code PNG data (CoreImage not available on watchOS)
- var qrCodeImageData: Data?
-}
diff --git a/BusinessCard/State/AppAppearance.swift b/BusinessCard/State/AppAppearance.swift
new file mode 100644
index 0000000..34b7878
--- /dev/null
+++ b/BusinessCard/State/AppAppearance.swift
@@ -0,0 +1,15 @@
+import SwiftUI
+
+enum AppAppearance: String, CaseIterable, Sendable {
+ case system
+ case light
+ case dark
+
+ var preferredColorScheme: ColorScheme? {
+ switch self {
+ case .system: nil
+ case .light: .light
+ case .dark: .dark
+ }
+ }
+}
diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift
index bf26292..a528ed6 100644
--- a/BusinessCard/State/AppState.swift
+++ b/BusinessCard/State/AppState.swift
@@ -3,24 +3,11 @@ import Observation
import SwiftData
import SwiftUI
-enum AppAppearance: String, CaseIterable, Sendable {
- case system
- case light
- case dark
-
- var preferredColorScheme: ColorScheme? {
- switch self {
- case .system: nil
- case .light: .light
- case .dark: .dark
- }
- }
-}
-
@Observable
@MainActor
final class AppState {
var selectedTab: AppTab = .cards
+ var shouldPresentCreateCardFlow = false
var cardStore: CardStore
var contactsStore: ContactsStore
let preferences: AppPreferencesStore
diff --git a/BusinessCard/Views/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Components/AddedContactFieldsView.swift
deleted file mode 100644
index e4d81a8..0000000
--- a/BusinessCard/Views/Components/AddedContactFieldsView.swift
+++ /dev/null
@@ -1,209 +0,0 @@
-import SwiftUI
-import Bedrock
-
-/// Represents a contact field that has been added
-struct AddedContactField: Identifiable, Equatable {
- let id: UUID
- let fieldType: ContactFieldType
- var value: String
- var title: String
-
- init(id: UUID = UUID(), fieldType: ContactFieldType, value: String = "", title: String = "") {
- self.id = id
- self.fieldType = fieldType
- self.value = value
- self.title = title
- }
-
- static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
- lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
- }
-
- /// Returns the display value for this field (formatted for addresses, raw for others)
- var displayValue: String {
- fieldType.formattedDisplayValue(value)
- }
-
- /// Returns a short display value suitable for single-line display in lists
- var shortDisplayValue: String {
- if fieldType.id == "address" {
- // For addresses, show single-line format in the list
- if let address = PostalAddress.decode(from: value), address.hasValue {
- return address.singleLineString
- }
- }
- return value
- }
-}
-
-/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
-struct AddedContactFieldsView: View {
- @Binding var fields: [AddedContactField]
- var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
- let onEdit: (AddedContactField) -> Void
-
- @State private var draggingField: AddedContactField?
-
- var body: some View {
- if fields.isEmpty {
- EmptyView()
- } else {
- VStack(spacing: 0) {
- ForEach(fields) { field in
- FieldRow(
- field: field,
- themeColor: themeColor,
- onTap: { onEdit(field) },
- onDelete: { deleteField(field) }
- )
- .draggable(field.id.uuidString) {
- // Drag preview
- FieldRowPreview(field: field, themeColor: themeColor)
- }
- .dropDestination(for: String.self) { items, _ in
- guard let droppedId = items.first,
- let droppedUUID = UUID(uuidString: droppedId),
- let fromIndex = fields.firstIndex(where: { $0.id == droppedUUID }),
- let toIndex = fields.firstIndex(where: { $0.id == field.id }),
- fromIndex != toIndex else {
- return false
- }
- withAnimation(.spring(duration: Design.Animation.quick)) {
- let movedField = fields.remove(at: fromIndex)
- fields.insert(movedField, at: toIndex)
- }
- return true
- }
-
- if field.id != fields.last?.id {
- Divider()
- .padding(.leading, Design.CardSize.avatarSize + Design.Spacing.large + Design.Spacing.medium)
- }
- }
- }
- .background(Color.AppBackground.elevated)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
- }
- }
-
- private func deleteField(_ field: AddedContactField) {
- withAnimation {
- fields.removeAll { $0.id == field.id }
- }
- }
-}
-
-/// Preview shown while dragging a field
-private struct FieldRowPreview: View {
- let field: AddedContactField
- let themeColor: Color
-
- var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- Circle()
- .fill(themeColor)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .overlay(
- field.fieldType.iconImage()
- .typography(.title3)
- .foregroundStyle(.white)
- )
-
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- Text(field.value.isEmpty ? field.fieldType.displayName : field.shortDisplayValue)
- .typography(.subheading)
- .foregroundStyle(Color.Text.primary)
- .lineLimit(1)
-
- if !field.title.isEmpty {
- Text(field.title)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
- .lineLimit(1)
- }
- }
- }
- .padding(Design.Spacing.medium)
- .background(Color.AppBackground.elevated)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- .shadow(radius: Design.Shadow.radiusMedium)
- }
-}
-
-/// A display row for a contact field - tap to edit, hold to drag
-private struct FieldRow: View {
- let field: AddedContactField
- let themeColor: Color
- let onTap: () -> Void
- let onDelete: () -> Void
-
- var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- // Drag handle
- Image(systemName: "line.3.horizontal")
- .typography(.caption)
- .foregroundStyle(Color.Text.tertiary)
- .frame(width: Design.Spacing.large)
-
- // Icon
- Circle()
- .fill(themeColor)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .overlay(
- field.fieldType.iconImage()
- .typography(.title3)
- .foregroundStyle(.white)
- )
-
- // Content - tap to edit
- Button(action: onTap) {
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.shortDisplayValue)
- .typography(.subheading)
- .foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
- .lineLimit(1)
-
- Text(field.title.isEmpty ? field.fieldType.displayName : field.title)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
- .lineLimit(1)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .buttonStyle(.plain)
-
- // Delete button
- Button(action: onDelete) {
- Image(systemName: "xmark.circle.fill")
- .typography(.title3)
- .foregroundStyle(Color.Text.secondary)
- }
- .buttonStyle(.plain)
- .accessibilityLabel(String(localized: "Delete"))
- .accessibilityHint(String(localized: "Removes this field"))
- }
- .padding(Design.Spacing.medium)
- .contentShape(.rect)
- }
-}
-
-#Preview {
- @Previewable @State var fields: [AddedContactField] = {
- let address = PostalAddress(street: "6565 Headquarters Dr", city: "Plano", state: "TX", postalCode: "75024")
- return [
- AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
- AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
- AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
- AddedContactField(fieldType: .address, value: address.encode(), title: "Work"),
- AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
- ]
- }()
-
- ScrollView {
- AddedContactFieldsView(fields: $fields) { field in
- Design.debugLog("Edit: \(field.fieldType.displayName)")
- }
- .padding()
- }
- .background(Color.AppBackground.base)
-}
diff --git a/BusinessCard/Views/Components/ImageEditorFlow.swift b/BusinessCard/Views/Components/ImageEditorFlow.swift
deleted file mode 100644
index 511774d..0000000
--- a/BusinessCard/Views/Components/ImageEditorFlow.swift
+++ /dev/null
@@ -1,350 +0,0 @@
-import SwiftUI
-import PhotosUI
-import Bedrock
-
-/// A self-contained image editor flow.
-/// Shows source picker first, then presents photo picker or camera as a full-screen cover.
-/// The aspect ratio is determined by the imageType.
-/// For logos, an additional LogoEditorSheet is shown after cropping.
-struct ImageEditorFlow: View {
- @Environment(\.dismiss) private var dismiss
-
- let imageType: CardEditorView.ImageType
- let hasExistingImage: Bool
- let onComplete: (Data?) -> Void // nil = cancelled/no change, Data = final cropped image
-
- private enum NextAction {
- case library
- case camera
- }
-
- @State private var nextAction: NextAction?
- @State private var showingFullScreenPicker = false
- @State private var showingFullScreenCamera = false
-
- private var aspectRatio: CropAspectRatio {
- imageType.cropAspectRatio
- }
-
- /// Only allow aspect ratio selection for logos
- private var allowAspectRatioSelection: Bool {
- imageType == .logo
- }
-
- /// Whether this is a logo image (needs extra editing step)
- private var isLogoImage: Bool {
- imageType == .logo
- }
-
- var body: some View {
- // Source picker is the base content of this sheet
- sourcePickerView
- .fullScreenCover(isPresented: $showingFullScreenPicker) {
- PhotoPickerFlow(
- aspectRatio: aspectRatio,
- allowAspectRatioSelection: allowAspectRatioSelection,
- isLogoImage: isLogoImage,
- onComplete: { imageData in
- showingFullScreenPicker = false
- if let imageData {
- onComplete(imageData)
- }
- // If nil, we stay on source picker
- }
- )
- }
- .fullScreenCover(isPresented: $showingFullScreenCamera) {
- CameraFlow(
- aspectRatio: aspectRatio,
- allowAspectRatioSelection: allowAspectRatioSelection,
- isLogoImage: isLogoImage,
- onComplete: { imageData in
- showingFullScreenCamera = false
- if let imageData {
- onComplete(imageData)
- }
- // If nil, we stay on source picker
- }
- )
- }
- }
-
- // MARK: - Source Picker
-
- private var sourcePickerView: some View {
- NavigationStack {
- VStack(spacing: 0) {
- VStack(spacing: 0) {
- OptionRow(
- icon: "photo.on.rectangle",
- title: String.localized("Select from photo library")
- ) {
- showingFullScreenPicker = true
- }
-
- Divider()
- .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
-
- OptionRow(
- icon: "camera",
- title: String.localized("Take photo")
- ) {
- showingFullScreenCamera = true
- }
-
- if hasExistingImage {
- Divider()
- .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
-
- OptionRow(
- icon: "trash",
- title: String.localized("Remove photo"),
- isDestructive: true
- ) {
- onComplete(Data())
- }
- }
- }
- .background(Color.AppBackground.card)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
- .padding(.horizontal, Design.Spacing.large)
- .padding(.top, Design.Spacing.medium)
-
- Spacer()
- }
- .background(Color.AppBackground.secondary)
- .navigationTitle(imageType.title)
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button {
- onComplete(nil)
- } label: {
- Image(systemName: "xmark")
- .typography(.bodyEmphasis)
- .foregroundStyle(Color.Text.primary)
- }
- }
- }
- }
- .presentationDetents([.height(CGFloat((hasExistingImage ? 3 : 2) * 56 + 100))])
- .presentationDragIndicator(.visible)
- }
-}
-
-// MARK: - Photo Picker Flow (full screen)
-
-private struct PhotoPickerFlow: View {
- let aspectRatio: CropAspectRatio
- let allowAspectRatioSelection: Bool
- let isLogoImage: Bool
- let onComplete: (Data?) -> Void
-
- @State private var selectedPhotoItem: PhotosPickerItem?
- @State private var imageData: Data?
- @State private var showingCropper = false
- @State private var showingLogoEditor = false
- @State private var croppedLogoImage: UIImage?
- @State private var pickerID = UUID()
-
- var body: some View {
- NavigationStack {
- PhotosPicker(
- selection: $selectedPhotoItem,
- matching: .images,
- photoLibrary: .shared()
- ) {
- EmptyView()
- }
- .photosPickerStyle(.inline)
- .photosPickerDisabledCapabilities([.selectionActions])
- .ignoresSafeArea()
- .id(pickerID)
- .onChange(of: selectedPhotoItem) { _, newValue in
- guard let newValue else { return }
- Task { @MainActor in
- if let data = try? await newValue.loadTransferable(type: Data.self) {
- imageData = data
- showingCropper = true
- }
- }
- }
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button(String.localized("Cancel")) {
- onComplete(nil)
- }
- }
- }
- }
- .overlay {
- if showingCropper, let imageData {
- PhotoCropperSheet(
- imageData: imageData,
- aspectRatio: aspectRatio,
- allowAspectRatioSelection: allowAspectRatioSelection,
- shouldDismissOnComplete: false
- ) { croppedData in
- if let croppedData {
- // For logos, show the logo editor next
- if isLogoImage, let uiImage = UIImage(data: croppedData) {
- croppedLogoImage = uiImage
- showingCropper = false
- showingLogoEditor = true
- } else {
- onComplete(croppedData)
- }
- } else {
- // Go back to picker
- showingCropper = false
- self.imageData = nil
- self.selectedPhotoItem = nil
- pickerID = UUID()
- }
- }
- .transition(.move(edge: .trailing))
- }
-
- // Logo editor overlay
- if showingLogoEditor, let logoImage = croppedLogoImage {
- LogoEditorSheet(logoImage: logoImage) { finalData in
- if let finalData {
- onComplete(finalData)
- } else {
- // User cancelled logo editor, go back to picker
- showingLogoEditor = false
- croppedLogoImage = nil
- self.imageData = nil
- self.selectedPhotoItem = nil
- pickerID = UUID()
- }
- }
- .transition(.move(edge: .trailing))
- }
- }
- .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
- .animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
- }
-}
-
-// MARK: - Camera Flow (full screen)
-
-private struct CameraFlow: View {
- let aspectRatio: CropAspectRatio
- let allowAspectRatioSelection: Bool
- let isLogoImage: Bool
- let onComplete: (Data?) -> Void
-
- @State private var capturedImageData: Data?
- @State private var showingCropper = false
- @State private var showingLogoEditor = false
- @State private var croppedLogoImage: UIImage?
- @State private var cameraID = UUID() // For resetting camera after cancel
-
- var body: some View {
- ZStack {
- // Camera - only show when not cropping or editing
- if !showingCropper && !showingLogoEditor {
- CameraCaptureView(shouldDismissOnCapture: false) { imageData in
- if let imageData {
- capturedImageData = imageData
- showingCropper = true
- } else {
- // User cancelled camera itself
- onComplete(nil)
- }
- }
- .id(cameraID)
- .ignoresSafeArea()
- .transition(.opacity)
- }
-
- // Cropper overlay
- if showingCropper, let imageData = capturedImageData {
- PhotoCropperSheet(
- imageData: imageData,
- aspectRatio: aspectRatio,
- allowAspectRatioSelection: allowAspectRatioSelection,
- shouldDismissOnComplete: false
- ) { croppedData in
- if let croppedData {
- // For logos, show the logo editor next
- if isLogoImage, let uiImage = UIImage(data: croppedData) {
- croppedLogoImage = uiImage
- showingCropper = false
- showingLogoEditor = true
- } else {
- onComplete(croppedData)
- }
- } else {
- // User cancelled cropper - go back to camera for retake
- showingCropper = false
- capturedImageData = nil
- cameraID = UUID() // Reset camera
- }
- }
- .transition(.move(edge: .trailing))
- }
-
- // Logo editor overlay
- if showingLogoEditor, let logoImage = croppedLogoImage {
- LogoEditorSheet(logoImage: logoImage) { finalData in
- if let finalData {
- onComplete(finalData)
- } else {
- // User cancelled logo editor, go back to camera
- showingLogoEditor = false
- croppedLogoImage = nil
- capturedImageData = nil
- cameraID = UUID()
- }
- }
- .transition(.move(edge: .trailing))
- }
- }
- .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
- .animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
- }
-}
-
-// MARK: - Option Row
-
-private struct OptionRow: View {
- let icon: String
- let title: String
- var isDestructive: Bool = false
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: Design.Spacing.medium) {
- Image(systemName: icon)
- .typography(.body)
- .foregroundStyle(isDestructive ? Color.red : Color.Text.secondary)
- .frame(width: Design.CardSize.socialIconSize)
-
- Text(title)
- .typography(.body)
- .foregroundStyle(isDestructive ? Color.red : Color.Text.primary)
-
- Spacer()
- }
- .padding(.horizontal, Design.Spacing.large)
- .padding(.vertical, Design.Spacing.medium)
- .contentShape(.rect)
- }
- .buttonStyle(.plain)
- }
-}
-
-#Preview {
- Text("Tap to edit")
- .sheet(isPresented: .constant(true)) {
- ImageEditorFlow(
- imageType: .profile,
- hasExistingImage: false
- ) { data in
- Design.debugLog(data != nil ? "Got image" : "Cancelled")
- }
- }
-}
diff --git a/BusinessCard/Views/ContactsView.swift b/BusinessCard/Views/ContactsView.swift
deleted file mode 100644
index 7643f92..0000000
--- a/BusinessCard/Views/ContactsView.swift
+++ /dev/null
@@ -1,233 +0,0 @@
-import SwiftUI
-import Bedrock
-import SwiftData
-
-struct ContactsView: View {
- @Environment(AppState.self) private var appState
- @State private var showingScanner = false
- @State private var showingAddContact = false
-
- var body: some View {
- @Bindable var contactsStore = appState.contactsStore
- NavigationStack {
- Group {
- if contactsStore.contacts.isEmpty {
- EmptyContactsView()
- } else {
- ContactsListView(contactsStore: contactsStore)
- }
- }
- .searchable(text: $contactsStore.searchQuery, prompt: String.localized("Search contacts"))
- .navigationTitle(String.localized("Contacts"))
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- Button(String.localized("Scan Card"), systemImage: "qrcode.viewfinder") {
- showingScanner = true
- }
- .accessibilityHint(String.localized("Scan someone else's QR code to save their card"))
- }
-
- ToolbarItem(placement: .primaryAction) {
- Button(String.localized("Add Contact"), systemImage: "plus") {
- showingAddContact = true
- }
- .accessibilityHint(String.localized("Manually add a new contact"))
- }
- }
- .sheet(isPresented: $showingScanner) {
- QRScannerView { scannedData in
- if !scannedData.isEmpty {
- appState.contactsStore.addReceivedCard(vCardData: scannedData)
- }
- showingScanner = false
- }
- }
- .sheet(isPresented: $showingAddContact) {
- AddContactSheet()
- }
- }
- }
-}
-
-private struct EmptyContactsView: View {
- var body: some View {
- VStack(spacing: Design.Spacing.large) {
- Image(systemName: "person.2.slash")
- .typography(.title2)
- .foregroundStyle(Color.Text.secondary)
-
- Text("No contacts yet")
- .typography(.heading)
- .foregroundStyle(Color.Text.primary)
-
- Text("Tap + to add a contact, scan a QR code, or track who you share your card with.")
- .typography(.subheading)
- .foregroundStyle(Color.Text.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, Design.Spacing.xLarge)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.AppBackground.base)
- }
-}
-
-private struct ContactsListView: View {
- @Bindable var contactsStore: ContactsStore
-
- var body: some View {
- List {
- // Follow-up reminders section
- let overdueContacts = contactsStore.visibleContacts.filter { $0.isFollowUpOverdue }
- if !overdueContacts.isEmpty {
- Section {
- ForEach(overdueContacts) { contact in
- NavigationLink(value: contact) {
- ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
- }
- }
- } header: {
- Label(String.localized("Follow-up Overdue"), systemImage: "exclamationmark.circle")
- .foregroundStyle(Color.Accent.red)
- }
- }
-
- // Received cards section
- let receivedCards = contactsStore.visibleContacts.filter { $0.isReceivedCard && !$0.isFollowUpOverdue }
- if !receivedCards.isEmpty {
- Section {
- ForEach(receivedCards) { contact in
- NavigationLink(value: contact) {
- ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
- }
- }
- .onDelete { indexSet in
- for index in indexSet {
- contactsStore.deleteContact(receivedCards[index])
- }
- }
- } header: {
- Label(String.localized("Received Cards"), systemImage: "tray.and.arrow.down")
- }
- }
-
- // Shared with section
- let sharedContacts = contactsStore.visibleContacts.filter { !$0.isReceivedCard && !$0.isFollowUpOverdue }
- if !sharedContacts.isEmpty {
- Section {
- ForEach(sharedContacts) { contact in
- NavigationLink(value: contact) {
- ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
- }
- }
- .onDelete { indexSet in
- for index in indexSet {
- contactsStore.deleteContact(sharedContacts[index])
- }
- }
- } header: {
- Text("Shared With")
- .typography(.heading)
- .bold()
- }
- }
- }
- .listStyle(.plain)
- .navigationDestination(for: Contact.self) { contact in
- ContactDetailView(contact: contact)
- }
- }
-}
-
-private struct ContactRowView: View {
- let contact: Contact
- let relativeDate: String
-
- var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- ContactAvatarView(contact: contact)
-
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- HStack(spacing: Design.Spacing.xSmall) {
- Text(contact.name)
- .typography(.heading)
- .foregroundStyle(Color.Text.primary)
-
- if contact.isReceivedCard {
- Image(systemName: "arrow.down.circle.fill")
- .typography(.caption)
- .foregroundStyle(Color.Accent.mint)
- }
-
- if contact.hasFollowUp {
- Image(systemName: contact.isFollowUpOverdue ? "exclamationmark.circle.fill" : "clock.fill")
- .typography(.caption)
- .foregroundStyle(contact.isFollowUpOverdue ? Color.Accent.red : Color.Accent.gold)
- }
- }
-
- if !contact.role.isEmpty || !contact.company.isEmpty {
- Text("\(contact.role)\(contact.role.isEmpty || contact.company.isEmpty ? "" : " · ")\(contact.company)")
- .typography(.subheading)
- .foregroundStyle(Color.Text.secondary)
- .lineLimit(1)
- }
-
- if !contact.tagList.isEmpty {
- HStack(spacing: Design.Spacing.xSmall) {
- ForEach(contact.tagList.prefix(2), id: \.self) { tag in
- Text(tag)
- .typography(.caption2)
- .padding(.horizontal, Design.Spacing.xSmall)
- .padding(.vertical, Design.Spacing.xxSmall)
- .background(Color.AppBackground.accent)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.small))
- }
- }
- }
- }
-
- Spacer()
-
- VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) {
- Text(relativeDate)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
- Text(String.localized(contact.cardLabel))
- .typography(.caption)
- .padding(.horizontal, Design.Spacing.small)
- .padding(.vertical, Design.Spacing.xxSmall)
- .background(Color.AppBackground.base)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- }
- }
- .accessibilityElement(children: .ignore)
- .accessibilityLabel(contact.name)
- .accessibilityValue("\(contact.role), \(contact.company)")
- }
-}
-
-private struct ContactAvatarView: View {
- let contact: Contact
-
- var body: some View {
- if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
- Image(uiImage: uiImage)
- .resizable()
- .scaledToFill()
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- } else {
- Image(systemName: contact.avatarSystemName)
- .typography(.title2)
- .foregroundStyle(Color.Accent.red)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .background(Color.AppBackground.accent)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- }
- }
-}
-
-#Preview {
- ContactsView()
- .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
-}
diff --git a/BusinessCard/Views/Features/AppShell/FloatingShareButton.swift b/BusinessCard/Views/Features/AppShell/FloatingShareButton.swift
new file mode 100644
index 0000000..fc6b5ed
--- /dev/null
+++ b/BusinessCard/Views/Features/AppShell/FloatingShareButton.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+import Bedrock
+
+struct FloatingShareButton: View {
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: "qrcode")
+ .typography(.title2)
+ .fontWeight(.semibold)
+ .foregroundStyle(.white)
+ .frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize)
+ .background(
+ Circle()
+ .fill(Color.Accent.red)
+ .shadow(
+ color: Color.Accent.red.opacity(Design.Opacity.medium),
+ radius: Design.Shadow.radiusMedium,
+ x: Design.Shadow.offsetNone,
+ y: Design.Shadow.offsetSmall
+ )
+ )
+ }
+ .accessibilityLabel(String.localized("Share"))
+ .accessibilityHint(String.localized("Opens the share sheet to send your card"))
+ }
+}
diff --git a/BusinessCard/Views/RootTabView.swift b/BusinessCard/Views/Features/AppShell/RootTabView.swift
similarity index 62%
rename from BusinessCard/Views/RootTabView.swift
rename to BusinessCard/Views/Features/AppShell/RootTabView.swift
index f16f93a..33f3431 100644
--- a/BusinessCard/Views/RootTabView.swift
+++ b/BusinessCard/Views/Features/AppShell/RootTabView.swift
@@ -4,6 +4,7 @@ import SwiftData
struct RootTabView: View {
@Environment(AppState.self) private var appState
+ @Environment(\.scenePhase) private var scenePhase
@State private var showingShareSheet = false
@State private var showingOnboarding = false
@@ -43,38 +44,24 @@ struct RootTabView: View {
}
}
.onAppear {
- if !appState.preferences.hasCompletedOnboarding {
- showingOnboarding = true
+ updateOnboardingPresentation()
+ }
+ .onChange(of: appState.preferences.hasCompletedOnboarding) { _, _ in
+ updateOnboardingPresentation()
+ }
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active {
+ updateOnboardingPresentation()
}
}
}
-}
-// MARK: - Floating Share Button
-
-private struct FloatingShareButton: View {
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- Image(systemName: "qrcode")
- .typography(.title2)
- .fontWeight(.semibold)
- .foregroundStyle(.white)
- .frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize)
- .background(
- Circle()
- .fill(Color.Accent.red)
- .shadow(
- color: Color.Accent.red.opacity(Design.Opacity.medium),
- radius: Design.Shadow.radiusMedium,
- x: Design.Shadow.offsetNone,
- y: Design.Shadow.offsetSmall
- )
- )
+ private func updateOnboardingPresentation() {
+ if appState.preferences.hasCompletedOnboarding {
+ showingOnboarding = false
+ } else if !showingOnboarding {
+ showingOnboarding = true
}
- .accessibilityLabel(String.localized("Share"))
- .accessibilityHint(String.localized("Opens the share sheet to send your card"))
}
}
diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/Features/Cards/CardsHomeView.swift
similarity index 69%
rename from BusinessCard/Views/CardsHomeView.swift
rename to BusinessCard/Views/Features/Cards/CardsHomeView.swift
index 32ca0bf..3782140 100644
--- a/BusinessCard/Views/CardsHomeView.swift
+++ b/BusinessCard/Views/Features/Cards/CardsHomeView.swift
@@ -80,64 +80,19 @@ struct CardsHomeView: View {
} message: {
Text("Are you sure you want to delete this card? This action cannot be undone.")
}
+ .onAppear {
+ presentPendingCreateCardFlowIfNeeded()
+ }
+ .onChange(of: appState.shouldPresentCreateCardFlow) { _, _ in
+ presentPendingCreateCardFlowIfNeeded()
+ }
}
}
-}
-// MARK: - Card Page View
-
-private struct CardPageView: View {
- let card: BusinessCard
-
- var body: some View {
- ScrollView {
- VStack(spacing: Design.Spacing.large) {
- BusinessCardView(card: card)
- .frame(maxWidth: Design.CardSize.maxCardWidth)
- }
- .frame(maxWidth: .infinity)
- .padding(.horizontal, Design.Spacing.large)
- .padding(.vertical, Design.Spacing.xLarge)
- }
- .scrollIndicators(.hidden)
- }
-}
-
-// MARK: - Empty State
-
-private struct EmptyCardsView: View {
- let onCreateCard: () -> Void
-
- var body: some View {
- VStack(spacing: Design.Spacing.xLarge) {
- Spacer()
-
- Image(systemName: "rectangle.stack.badge.plus")
- .typography(.title2)
- .foregroundStyle(Color.Text.secondary)
-
- VStack(spacing: Design.Spacing.small) {
- Text("Create your first card")
- .typography(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- Text("Design and share polished digital business cards for every context.")
- .typography(.subheading)
- .foregroundStyle(Color.Text.secondary)
- .multilineTextAlignment(.center)
- }
- .padding(.horizontal, Design.Spacing.xLarge)
-
- PrimaryActionButton(
- title: String.localized("Create Card"),
- systemImage: "plus"
- ) {
- onCreateCard()
- }
-
- Spacer()
- }
+ private func presentPendingCreateCardFlowIfNeeded() {
+ guard appState.shouldPresentCreateCardFlow else { return }
+ appState.shouldPresentCreateCardFlow = false
+ showingCreateCard = true
}
}
diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift
similarity index 100%
rename from BusinessCard/Views/BusinessCardView.swift
rename to BusinessCard/Views/Features/Cards/Components/BusinessCardView.swift
diff --git a/BusinessCard/Views/Features/Cards/Components/CardPageView.swift b/BusinessCard/Views/Features/Cards/Components/CardPageView.swift
new file mode 100644
index 0000000..8957425
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Components/CardPageView.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+import Bedrock
+
+struct CardPageView: View {
+ let card: BusinessCard
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: Design.Spacing.large) {
+ BusinessCardView(card: card)
+ .frame(maxWidth: Design.CardSize.maxCardWidth)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.xLarge)
+ }
+ .scrollIndicators(.hidden)
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Components/EmptyCardsView.swift b/BusinessCard/Views/Features/Cards/Components/EmptyCardsView.swift
new file mode 100644
index 0000000..5a67564
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Components/EmptyCardsView.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+import Bedrock
+
+struct EmptyCardsView: View {
+ let onCreateCard: () -> Void
+
+ var body: some View {
+ VStack(spacing: Design.Spacing.xLarge) {
+ Spacer()
+
+ Image(systemName: "rectangle.stack.badge.plus")
+ .typography(.title2)
+ .foregroundStyle(Color.Text.secondary)
+
+ VStack(spacing: Design.Spacing.small) {
+ Text("Create your first card")
+ .typography(.title2)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ Text("Design and share polished digital business cards for every context.")
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.horizontal, Design.Spacing.xLarge)
+
+ PrimaryActionButton(
+ title: String.localized("Create Card"),
+ systemImage: "plus"
+ ) {
+ onCreateCard()
+ }
+
+ Spacer()
+ }
+ }
+}
diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/Features/Cards/Editor/CardEditorView.swift
similarity index 100%
rename from BusinessCard/Views/CardEditorView.swift
rename to BusinessCard/Views/Features/Cards/Editor/CardEditorView.swift
diff --git a/BusinessCard/Views/Features/Cards/Sheets/ColorSwatchButton.swift b/BusinessCard/Views/Features/Cards/Sheets/ColorSwatchButton.swift
new file mode 100644
index 0000000..050e8a4
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/ColorSwatchButton.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+import Bedrock
+
+struct ColorSwatchButton: View {
+ let color: Color
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Circle()
+ .fill(color)
+ .frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
+ .overlay(
+ Circle()
+ .stroke(isSelected ? Color.accentColor : Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin)
+ )
+ .overlay(
+ Circle()
+ .stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
+ .padding(Design.LineWidth.thin)
+ .opacity(isSelected ? 1 : 0)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Color swatch")
+ .accessibilityAddTraits(isSelected ? [.isSelected] : [])
+ }
+}
diff --git a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift
similarity index 78%
rename from BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
rename to BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift
index 537dfc7..b6d1c57 100644
--- a/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/ContactFieldEditorSheet.swift
@@ -246,102 +246,6 @@ struct ContactFieldEditorSheet: View {
}
}
-// MARK: - Field Header
-
-private struct FieldHeaderView: View {
- let fieldType: ContactFieldType
- let themeColor: Color
-
- var body: some View {
- HStack(spacing: Design.Spacing.medium) {
- Circle()
- .fill(themeColor)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .overlay(
- fieldType.iconImage()
- .typography(.title3)
- .foregroundStyle(.white)
- )
-
- Text(fieldType.displayName)
- .typography(.heading)
- .foregroundStyle(Color.Text.primary)
-
- Spacer()
- }
- .padding(Design.Spacing.large)
- .background(Color.AppBackground.elevated)
- }
-}
-
-// MARK: - Suggestion Chip
-
-private struct SuggestionChip: View {
- let text: String
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- Text(text)
- .typography(.subheading)
- .padding(.horizontal, Design.Spacing.medium)
- .padding(.vertical, Design.Spacing.small)
- .background(Color.AppBackground.elevated)
- .clipShape(.capsule)
- .overlay(
- Capsule()
- .stroke(Color.Text.secondary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
- )
- }
- .buttonStyle(.plain)
- }
-}
-
-// MARK: - Flow Layout
-
-private struct FlowLayout: Layout {
- var spacing: CGFloat = 8
-
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
- let result = layout(subviews: subviews, proposal: proposal)
- return result.size
- }
-
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
- let result = layout(subviews: subviews, proposal: proposal)
-
- for (index, position) in result.positions.enumerated() {
- subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
- }
- }
-
- private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (size: CGSize, positions: [CGPoint]) {
- let maxWidth = proposal.width ?? .infinity
- var positions: [CGPoint] = []
- var currentX: CGFloat = 0
- var currentY: CGFloat = 0
- var lineHeight: CGFloat = 0
- var maxX: CGFloat = 0
-
- for subview in subviews {
- let size = subview.sizeThatFits(.unspecified)
-
- if currentX + size.width > maxWidth && currentX > 0 {
- currentX = 0
- currentY += lineHeight + spacing
- lineHeight = 0
- }
-
- positions.append(CGPoint(x: currentX, y: currentY))
- lineHeight = max(lineHeight, size.height)
- currentX += size.width + spacing
- maxX = max(maxX, currentX)
- }
-
- return (CGSize(width: maxX, height: currentY + lineHeight), positions)
- }
-}
-
#Preview("Add Email") {
ContactFieldEditorSheet(fieldType: .email) { value, title in
Design.debugLog("Saved: \(value), \(title)")
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CropAspectRatio.swift b/BusinessCard/Views/Features/Cards/Sheets/CropAspectRatio.swift
new file mode 100644
index 0000000..f701e0d
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/CropAspectRatio.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+
+/// Aspect ratio options for the photo cropper
+enum CropAspectRatio: Identifiable, CaseIterable, Equatable {
+ case original // Use image's original aspect ratio
+ case square // 1:1 for profile photos
+ case threeToTwo // 3:2
+ case fiveToThree // 5:3
+ case fourToThree // 4:3
+ case fiveToFour // 5:4
+ case sevenToFive // 7:5
+ case sixteenToNine // 16:9
+ case banner // Wide ratio for cover/banner photos (roughly 2.3:1)
+
+ var id: String { displayName }
+
+ static var allCases: [CropAspectRatio] {
+ [.original, .square, .threeToTwo, .fiveToThree, .fourToThree, .fiveToFour, .sevenToFive, .sixteenToNine]
+ }
+
+ var displayName: String {
+ switch self {
+ case .original: return String.localized("Original")
+ case .square: return String.localized("Square")
+ case .threeToTwo: return "3:2"
+ case .fiveToThree: return "5:3"
+ case .fourToThree: return "4:3"
+ case .fiveToFour: return "5:4"
+ case .sevenToFive: return "7:5"
+ case .sixteenToNine: return "16:9"
+ case .banner: return String.localized("Banner")
+ }
+ }
+
+ /// Returns the ratio (width / height). For .original, pass the image size.
+ func ratio(for imageSize: CGSize? = nil) -> CGFloat {
+ switch self {
+ case .original:
+ guard let size = imageSize, size.height > 0 else { return 1.0 }
+ return size.width / size.height
+ case .square:
+ return 1.0
+ case .threeToTwo:
+ return 3.0 / 2.0
+ case .fiveToThree:
+ return 5.0 / 3.0
+ case .fourToThree:
+ return 4.0 / 3.0
+ case .fiveToFour:
+ return 5.0 / 4.0
+ case .sevenToFive:
+ return 7.0 / 5.0
+ case .sixteenToNine:
+ return 16.0 / 9.0
+ case .banner:
+ return 2.3
+ }
+ }
+
+ /// Simple ratio for backwards compatibility (doesn't handle .original properly)
+ var ratio: CGFloat {
+ ratio(for: nil)
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift b/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
new file mode 100644
index 0000000..4e7f648
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+import Bedrock
+
+struct CropGridLines: View {
+ let cropSize: CGSize
+
+ var body: some View {
+ ZStack {
+ HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
+ ForEach(0..<2, id: \.self) { _ in
+ Rectangle()
+ .fill(Color.white.opacity(Design.Opacity.light))
+ .frame(width: Design.LineWidth.thin, height: cropSize.height)
+ }
+ }
+
+ VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
+ ForEach(0..<2, id: \.self) { _ in
+ Rectangle()
+ .fill(Color.white.opacity(Design.Opacity.light))
+ .frame(width: cropSize.width, height: Design.LineWidth.thin)
+ }
+ }
+ }
+ .frame(width: cropSize.width, height: cropSize.height)
+ .allowsHitTesting(false)
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift b/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
new file mode 100644
index 0000000..0d55fad
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+import Bedrock
+
+struct CropOverlay: View {
+ let cropSize: CGSize
+ let containerSize: CGSize
+
+ var body: some View {
+ ZStack {
+ Rectangle()
+ .fill(Color.black.opacity(Design.Opacity.accent))
+
+ Rectangle()
+ .fill(Color.clear)
+ .frame(width: cropSize.width, height: cropSize.height)
+ .blendMode(.destinationOut)
+ }
+ .compositingGroup()
+ .allowsHitTesting(false)
+
+ Rectangle()
+ .stroke(Color.white, lineWidth: Design.LineWidth.thin)
+ .frame(width: cropSize.width, height: cropSize.height)
+ .allowsHitTesting(false)
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/CustomColorPickerSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/CustomColorPickerSheet.swift
new file mode 100644
index 0000000..0376302
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/CustomColorPickerSheet.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import Bedrock
+
+struct LogoCustomColorPickerSheet: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let initialColor: Color
+ let onSelect: (Color) -> Void
+
+ @State private var selectedColor: Color
+
+ init(initialColor: Color, onSelect: @escaping (Color) -> Void) {
+ self.initialColor = initialColor
+ self.onSelect = onSelect
+ self._selectedColor = State(initialValue: initialColor)
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: Design.Spacing.xLarge) {
+ ColorPicker("Select a color", selection: $selectedColor, supportsOpacity: false)
+ .labelsHidden()
+ .scaleEffect(2.0)
+ .frame(height: 100)
+
+ RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
+ .fill(selectedColor)
+ .frame(height: 100)
+ .overlay(
+ RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
+ .stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
+ )
+
+ Spacer()
+ }
+ .padding(Design.Spacing.xLarge)
+ .navigationTitle("Custom color")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ onSelect(selectedColor)
+ dismiss()
+ }
+ .bold()
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/FieldHeaderView.swift b/BusinessCard/Views/Features/Cards/Sheets/FieldHeaderView.swift
new file mode 100644
index 0000000..28ff78d
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/FieldHeaderView.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+import Bedrock
+
+struct FieldHeaderView: View {
+ let fieldType: ContactFieldType
+ let themeColor: Color
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Circle()
+ .fill(themeColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ fieldType.iconImage()
+ .typography(.title3)
+ .foregroundStyle(.white)
+ )
+
+ Text(fieldType.displayName)
+ .typography(.heading)
+ .foregroundStyle(Color.Text.primary)
+
+ Spacer()
+ }
+ .padding(Design.Spacing.large)
+ .background(Color.AppBackground.elevated)
+ }
+}
diff --git a/BusinessCard/Views/Features/Cards/Sheets/FlowLayout.swift b/BusinessCard/Views/Features/Cards/Sheets/FlowLayout.swift
new file mode 100644
index 0000000..a68be72
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/FlowLayout.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layout(subviews: subviews, proposal: proposal)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layout(subviews: subviews, proposal: proposal)
+
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
+ }
+ }
+
+ private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (size: CGSize, positions: [CGPoint]) {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var maxX: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+
+ if currentX + size.width > maxWidth && currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ maxX = max(maxX, currentX)
+ }
+
+ return (CGSize(width: maxX, height: currentY + lineHeight), positions)
+ }
+}
diff --git a/BusinessCard/Views/Sheets/LogoEditorSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/LogoEditorSheet.swift
similarity index 73%
rename from BusinessCard/Views/Sheets/LogoEditorSheet.swift
rename to BusinessCard/Views/Features/Cards/Sheets/LogoEditorSheet.swift
index 79f31a6..b4aa9b9 100644
--- a/BusinessCard/Views/Sheets/LogoEditorSheet.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/LogoEditorSheet.swift
@@ -64,7 +64,7 @@ struct LogoEditorSheet: View {
extractSuggestedColors()
}
.sheet(isPresented: $showingColorPicker) {
- CustomColorPickerSheet(initialColor: customColor) { selectedColor in
+ LogoCustomColorPickerSheet(initialColor: customColor) { selectedColor in
customColor = selectedColor
backgroundColor = selectedColor
}
@@ -241,93 +241,6 @@ struct LogoEditorSheet: View {
}
}
-// MARK: - Color Swatch Button
-
-private struct ColorSwatchButton: View {
- let color: Color
- let isSelected: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- Circle()
- .fill(color)
- .frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
- .overlay(
- Circle()
- .stroke(isSelected ? Color.accentColor : Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin)
- )
- .overlay(
- Circle()
- .stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
- .padding(Design.LineWidth.thin)
- .opacity(isSelected ? 1 : 0)
- )
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Color swatch")
- .accessibilityAddTraits(isSelected ? [.isSelected] : [])
- }
-}
-
-// MARK: - Custom Color Picker Sheet
-
-private struct CustomColorPickerSheet: View {
- @Environment(\.dismiss) private var dismiss
-
- let initialColor: Color
- let onSelect: (Color) -> Void
-
- @State private var selectedColor: Color
-
- init(initialColor: Color, onSelect: @escaping (Color) -> Void) {
- self.initialColor = initialColor
- self.onSelect = onSelect
- self._selectedColor = State(initialValue: initialColor)
- }
-
- var body: some View {
- NavigationStack {
- VStack(spacing: Design.Spacing.xLarge) {
- ColorPicker("Select a color", selection: $selectedColor, supportsOpacity: false)
- .labelsHidden()
- .scaleEffect(2.0)
- .frame(height: 100)
-
- // Preview
- RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
- .fill(selectedColor)
- .frame(height: 100)
- .overlay(
- RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
- .stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
- )
-
- Spacer()
- }
- .padding(Design.Spacing.xLarge)
- .navigationTitle("Custom color")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- dismiss()
- }
- }
-
- ToolbarItem(placement: .confirmationAction) {
- Button("Done") {
- onSelect(selectedColor)
- dismiss()
- }
- .bold()
- }
- }
- }
- .presentationDetents([.medium])
- }
-}
-
// MARK: - Preview
#Preview {
diff --git a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift b/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
similarity index 81%
rename from BusinessCard/Views/Sheets/PhotoCropperSheet.swift
rename to BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
index 6018c8c..7a94f70 100644
--- a/BusinessCard/Views/Sheets/PhotoCropperSheet.swift
+++ b/BusinessCard/Views/Features/Cards/Sheets/PhotoCropperSheet.swift
@@ -1,69 +1,6 @@
import SwiftUI
import Bedrock
-/// Aspect ratio options for the photo cropper
-enum CropAspectRatio: Identifiable, CaseIterable, Equatable {
- case original // Use image's original aspect ratio
- case square // 1:1 for profile photos
- case threeToTwo // 3:2
- case fiveToThree // 5:3
- case fourToThree // 4:3
- case fiveToFour // 5:4
- case sevenToFive // 7:5
- case sixteenToNine // 16:9
- case banner // Wide ratio for cover/banner photos (roughly 2.3:1)
-
- var id: String { displayName }
-
- static var allCases: [CropAspectRatio] {
- [.original, .square, .threeToTwo, .fiveToThree, .fourToThree, .fiveToFour, .sevenToFive, .sixteenToNine]
- }
-
- var displayName: String {
- switch self {
- case .original: return String.localized("Original")
- case .square: return String.localized("Square")
- case .threeToTwo: return "3:2"
- case .fiveToThree: return "5:3"
- case .fourToThree: return "4:3"
- case .fiveToFour: return "5:4"
- case .sevenToFive: return "7:5"
- case .sixteenToNine: return "16:9"
- case .banner: return String.localized("Banner")
- }
- }
-
- /// Returns the ratio (width / height). For .original, pass the image size.
- func ratio(for imageSize: CGSize? = nil) -> CGFloat {
- switch self {
- case .original:
- guard let size = imageSize, size.height > 0 else { return 1.0 }
- return size.width / size.height
- case .square:
- return 1.0
- case .threeToTwo:
- return 3.0 / 2.0
- case .fiveToThree:
- return 5.0 / 3.0
- case .fourToThree:
- return 4.0 / 3.0
- case .fiveToFour:
- return 5.0 / 4.0
- case .sevenToFive:
- return 7.0 / 5.0
- case .sixteenToNine:
- return 16.0 / 9.0
- case .banner:
- return 2.3 // Width is 2.3x height
- }
- }
-
- /// Simple ratio for backwards compatibility (doesn't handle .original properly)
- var ratio: CGFloat {
- ratio(for: nil)
- }
-}
-
/// A sheet that allows the user to crop an image.
/// Supports pinch-to-zoom and drag gestures for positioning.
/// Use `aspectRatio` to specify square, banner, or custom crop shapes.
@@ -515,65 +452,6 @@ struct PhotoCropperSheet: View {
}
}
-// MARK: - Crop Overlay
-
-private struct CropOverlay: View {
- let cropSize: CGSize
- let containerSize: CGSize
-
- var body: some View {
- ZStack {
- // Semi-transparent overlay
- Rectangle()
- .fill(Color.black.opacity(Design.Opacity.accent))
-
- // Clear rectangle in center (can be square or banner)
- Rectangle()
- .fill(Color.clear)
- .frame(width: cropSize.width, height: cropSize.height)
- .blendMode(.destinationOut)
- }
- .compositingGroup()
- .allowsHitTesting(false)
-
- // Border around crop area
- Rectangle()
- .stroke(Color.white, lineWidth: Design.LineWidth.thin)
- .frame(width: cropSize.width, height: cropSize.height)
- .allowsHitTesting(false)
- }
-}
-
-// MARK: - Crop Grid Lines
-
-private struct CropGridLines: View {
- let cropSize: CGSize
-
- var body: some View {
- ZStack {
- // Vertical lines (rule of thirds)
- HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
- ForEach(0..<2, id: \.self) { _ in
- Rectangle()
- .fill(Color.white.opacity(Design.Opacity.light))
- .frame(width: Design.LineWidth.thin, height: cropSize.height)
- }
- }
-
- // Horizontal lines (rule of thirds)
- VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
- ForEach(0..<2, id: \.self) { _ in
- Rectangle()
- .fill(Color.white.opacity(Design.Opacity.light))
- .frame(width: cropSize.width, height: Design.LineWidth.thin)
- }
- }
- }
- .frame(width: cropSize.width, height: cropSize.height)
- .allowsHitTesting(false)
- }
-}
-
// MARK: - Preview
#Preview {
diff --git a/BusinessCard/Views/Features/Cards/Sheets/SuggestionChip.swift b/BusinessCard/Views/Features/Cards/Sheets/SuggestionChip.swift
new file mode 100644
index 0000000..e3e7d42
--- /dev/null
+++ b/BusinessCard/Views/Features/Cards/Sheets/SuggestionChip.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+import Bedrock
+
+struct SuggestionChip: View {
+ let text: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(text)
+ .typography(.subheading)
+ .padding(.horizontal, Design.Spacing.medium)
+ .padding(.vertical, Design.Spacing.small)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.capsule)
+ .overlay(
+ Capsule()
+ .stroke(Color.Text.secondary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/Components/ContactAvatarView.swift b/BusinessCard/Views/Features/Contacts/Components/ContactAvatarView.swift
new file mode 100644
index 0000000..b8d42f8
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Components/ContactAvatarView.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+import Bedrock
+
+struct ContactAvatarView: View {
+ let contact: Contact
+
+ var body: some View {
+ if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ } else {
+ Image(systemName: contact.avatarSystemName)
+ .typography(.title2)
+ .foregroundStyle(Color.Accent.red)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .background(Color.AppBackground.accent)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ }
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift b/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift
new file mode 100644
index 0000000..710c15b
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+import Bedrock
+
+struct ContactRowView: View {
+ let contact: Contact
+ let relativeDate: String
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ ContactAvatarView(contact: contact)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ HStack(spacing: Design.Spacing.xSmall) {
+ Text(contact.name)
+ .typography(.heading)
+ .foregroundStyle(Color.Text.primary)
+
+ if contact.isReceivedCard {
+ Image(systemName: "arrow.down.circle.fill")
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.mint)
+ }
+
+ if contact.hasFollowUp {
+ Image(systemName: contact.isFollowUpOverdue ? "exclamationmark.circle.fill" : "clock.fill")
+ .typography(.caption)
+ .foregroundStyle(contact.isFollowUpOverdue ? Color.Accent.red : Color.Accent.gold)
+ }
+ }
+
+ if !contact.role.isEmpty || !contact.company.isEmpty {
+ Text("\(contact.role)\(contact.role.isEmpty || contact.company.isEmpty ? "" : " · ")\(contact.company)")
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.secondary)
+ .lineLimit(1)
+ }
+
+ if !contact.tagList.isEmpty {
+ HStack(spacing: Design.Spacing.xSmall) {
+ ForEach(contact.tagList.prefix(2), id: \.self) { tag in
+ Text(tag)
+ .typography(.caption2)
+ .padding(.horizontal, Design.Spacing.xSmall)
+ .padding(.vertical, Design.Spacing.xxSmall)
+ .background(Color.AppBackground.accent)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.small))
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) {
+ Text(relativeDate)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ Text(String.localized(contact.cardLabel))
+ .typography(.caption)
+ .padding(.horizontal, Design.Spacing.small)
+ .padding(.vertical, Design.Spacing.xxSmall)
+ .background(Color.AppBackground.base)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ }
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(contact.name)
+ .accessibilityValue("\(contact.role), \(contact.company)")
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift b/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift
new file mode 100644
index 0000000..5b9df96
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift
@@ -0,0 +1,66 @@
+import SwiftUI
+import Bedrock
+
+struct ContactsListView: View {
+ @Bindable var contactsStore: ContactsStore
+
+ var body: some View {
+ List {
+ let overdueContacts = contactsStore.visibleContacts.filter { $0.isFollowUpOverdue }
+ if !overdueContacts.isEmpty {
+ Section {
+ ForEach(overdueContacts) { contact in
+ NavigationLink(value: contact) {
+ ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
+ }
+ }
+ } header: {
+ Label(String.localized("Follow-up Overdue"), systemImage: "exclamationmark.circle")
+ .foregroundStyle(Color.Accent.red)
+ }
+ }
+
+ let receivedCards = contactsStore.visibleContacts.filter { $0.isReceivedCard && !$0.isFollowUpOverdue }
+ if !receivedCards.isEmpty {
+ Section {
+ ForEach(receivedCards) { contact in
+ NavigationLink(value: contact) {
+ ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
+ }
+ }
+ .onDelete { indexSet in
+ for index in indexSet {
+ contactsStore.deleteContact(receivedCards[index])
+ }
+ }
+ } header: {
+ Label(String.localized("Received Cards"), systemImage: "tray.and.arrow.down")
+ }
+ }
+
+ let sharedContacts = contactsStore.visibleContacts.filter { !$0.isReceivedCard && !$0.isFollowUpOverdue }
+ if !sharedContacts.isEmpty {
+ Section {
+ ForEach(sharedContacts) { contact in
+ NavigationLink(value: contact) {
+ ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
+ }
+ }
+ .onDelete { indexSet in
+ for index in indexSet {
+ contactsStore.deleteContact(sharedContacts[index])
+ }
+ }
+ } header: {
+ Text("Shared With")
+ .typography(.heading)
+ .bold()
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationDestination(for: Contact.self) { contact in
+ ContactDetailView(contact: contact)
+ }
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/Components/EmptyContactsView.swift b/BusinessCard/Views/Features/Contacts/Components/EmptyContactsView.swift
new file mode 100644
index 0000000..6bff1f1
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Components/EmptyContactsView.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+import Bedrock
+
+struct EmptyContactsView: View {
+ var body: some View {
+ VStack(spacing: Design.Spacing.large) {
+ Image(systemName: "person.2.slash")
+ .typography(.title2)
+ .foregroundStyle(Color.Text.secondary)
+
+ Text("No contacts yet")
+ .typography(.heading)
+ .foregroundStyle(Color.Text.primary)
+
+ Text("Tap + to add a contact, scan a QR code, or track who you share your card with.")
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, Design.Spacing.xLarge)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.AppBackground.base)
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/ContactsView.swift b/BusinessCard/Views/Features/Contacts/ContactsView.swift
new file mode 100644
index 0000000..620ca73
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/ContactsView.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+import Bedrock
+import SwiftData
+
+struct ContactsView: View {
+ @Environment(AppState.self) private var appState
+ @State private var showingScanner = false
+ @State private var showingAddContact = false
+
+ var body: some View {
+ @Bindable var contactsStore = appState.contactsStore
+ NavigationStack {
+ Group {
+ if contactsStore.contacts.isEmpty {
+ EmptyContactsView()
+ } else {
+ ContactsListView(contactsStore: contactsStore)
+ }
+ }
+ .searchable(text: $contactsStore.searchQuery, prompt: String.localized("Search contacts"))
+ .navigationTitle(String.localized("Contacts"))
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button(String.localized("Scan Card"), systemImage: "qrcode.viewfinder") {
+ showingScanner = true
+ }
+ .accessibilityHint(String.localized("Scan someone else's QR code to save their card"))
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button(String.localized("Add Contact"), systemImage: "plus") {
+ showingAddContact = true
+ }
+ .accessibilityHint(String.localized("Manually add a new contact"))
+ }
+ }
+ .sheet(isPresented: $showingScanner) {
+ QRScannerView { scannedData in
+ if !scannedData.isEmpty {
+ appState.contactsStore.addReceivedCard(vCardData: scannedData)
+ }
+ showingScanner = false
+ }
+ }
+ .sheet(isPresented: $showingAddContact) {
+ AddContactSheet()
+ }
+ }
+ }
+}
+
+#Preview {
+ ContactsView()
+ .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
+}
diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift
similarity index 100%
rename from BusinessCard/Views/ContactDetailView.swift
rename to BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift
diff --git a/BusinessCard/Views/Sheets/AddContactSheet.swift b/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift
similarity index 73%
rename from BusinessCard/Views/Sheets/AddContactSheet.swift
rename to BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift
index b205367..1371c91 100644
--- a/BusinessCard/Views/Sheets/AddContactSheet.swift
+++ b/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift
@@ -322,128 +322,6 @@ struct AddContactSheet: View {
}
}
-// MARK: - Contact Photo Row
-
-private struct ContactPhotoRow: View {
- @Binding var photoData: Data?
- let onTap: () -> Void
-
- var body: some View {
- Button(action: onTap) {
- HStack(spacing: Design.Spacing.medium) {
- // Photo preview
- Group {
- if let photoData, let uiImage = UIImage(data: photoData) {
- Image(uiImage: uiImage)
- .resizable()
- .scaledToFill()
- } else {
- Image(systemName: "person.crop.circle.fill")
- .typography(.title2)
- .foregroundStyle(Color.Text.tertiary)
- }
- }
- .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
- .clipShape(.circle)
- .overlay(Circle().stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin))
-
- VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
- Text("Profile Photo")
- .typography(.subheading)
- .foregroundStyle(Color.Text.primary)
-
- Text(photoData == nil ? String.localized("Add a photo") : String.localized("Tap to change"))
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
- }
-
- Spacer()
-
- Image(systemName: "chevron.right")
- .typography(.caption)
- .foregroundStyle(Color.Text.tertiary)
- }
- .padding(.vertical, Design.Spacing.xSmall)
- .contentShape(.rect)
- }
- .buttonStyle(.plain)
- }
-}
-
-// MARK: - Labeled Entry Model
-
-private struct LabeledEntry: Identifiable {
- let id = UUID()
- var label: String
- var value: String
-}
-
-// MARK: - Labeled Field Row
-
-private struct LabeledFieldRow: View {
- @Binding var entry: LabeledEntry
- let valuePlaceholder: String
- let labelSuggestions: [String]
- var keyboardType: UIKeyboardType = .default
- var autocapitalization: TextInputAutocapitalization = .sentences
- var formatValue: ((String) -> String)?
- var isValueValid: ((String) -> Bool)?
- var validationMessage: String?
-
- private var trimmedValue: String {
- entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
- private var showsValidationError: Bool {
- guard let isValueValid else { return false }
- guard !trimmedValue.isEmpty else { return false }
- return !isValueValid(trimmedValue)
- }
-
- var body: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
- HStack(spacing: Design.Spacing.medium) {
- // Label picker
- Menu {
- ForEach(labelSuggestions, id: \.self) { suggestion in
- Button(suggestion) {
- entry.label = suggestion
- }
- }
- } label: {
- HStack(spacing: Design.Spacing.xSmall) {
- Text(entry.label)
- .foregroundStyle(Color.accentColor)
- Image(systemName: "chevron.up.chevron.down")
- .typography(.caption2)
- .foregroundStyle(Color.secondary)
- }
- }
- .frame(width: 80, alignment: .leading)
-
- // Value field
- TextField(valuePlaceholder, text: $entry.value)
- .keyboardType(keyboardType)
- .textInputAutocapitalization(autocapitalization)
- .onChange(of: entry.value) { _, newValue in
- guard let formatValue else { return }
- let formatted = formatValue(newValue)
- if formatted != newValue {
- entry.value = formatted
- }
- }
- }
-
- if showsValidationError, let validationMessage {
- Text(validationMessage)
- .typography(.caption)
- .foregroundStyle(Color.Accent.red)
- .padding(.leading, 80 + Design.Spacing.medium)
- }
- }
- }
-}
-
#Preview {
AddContactSheet()
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
diff --git a/BusinessCard/Views/Features/Contacts/Sheets/ContactPhotoRow.swift b/BusinessCard/Views/Features/Contacts/Sheets/ContactPhotoRow.swift
new file mode 100644
index 0000000..d97457e
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Sheets/ContactPhotoRow.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+import Bedrock
+
+struct ContactPhotoRow: View {
+ @Binding var photoData: Data?
+ let onTap: () -> Void
+
+ var body: some View {
+ Button(action: onTap) {
+ HStack(spacing: Design.Spacing.medium) {
+ Group {
+ if let photoData, let uiImage = UIImage(data: photoData) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ } else {
+ Image(systemName: "person.crop.circle.fill")
+ .typography(.title2)
+ .foregroundStyle(Color.Text.tertiary)
+ }
+ }
+ .frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
+ .clipShape(.circle)
+ .overlay(Circle().stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin))
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ Text("Profile Photo")
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.primary)
+
+ Text(photoData == nil ? String.localized("Add a photo") : String.localized("Tap to change"))
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .typography(.caption)
+ .foregroundStyle(Color.Text.tertiary)
+ }
+ .padding(.vertical, Design.Spacing.xSmall)
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/BusinessCard/Views/Features/Contacts/Sheets/LabeledEntry.swift b/BusinessCard/Views/Features/Contacts/Sheets/LabeledEntry.swift
new file mode 100644
index 0000000..a31997d
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Sheets/LabeledEntry.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+struct LabeledEntry: Identifiable {
+ let id = UUID()
+ var label: String
+ var value: String
+}
diff --git a/BusinessCard/Views/Features/Contacts/Sheets/LabeledFieldRow.swift b/BusinessCard/Views/Features/Contacts/Sheets/LabeledFieldRow.swift
new file mode 100644
index 0000000..ad87219
--- /dev/null
+++ b/BusinessCard/Views/Features/Contacts/Sheets/LabeledFieldRow.swift
@@ -0,0 +1,64 @@
+import SwiftUI
+import Bedrock
+
+struct LabeledFieldRow: View {
+ @Binding var entry: LabeledEntry
+ let valuePlaceholder: String
+ let labelSuggestions: [String]
+ var keyboardType: UIKeyboardType = .default
+ var autocapitalization: TextInputAutocapitalization = .sentences
+ var formatValue: ((String) -> String)?
+ var isValueValid: ((String) -> Bool)?
+ var validationMessage: String?
+
+ private var trimmedValue: String {
+ entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var showsValidationError: Bool {
+ guard let isValueValid else { return false }
+ guard !trimmedValue.isEmpty else { return false }
+ return !isValueValid(trimmedValue)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ HStack(spacing: Design.Spacing.medium) {
+ Menu {
+ ForEach(labelSuggestions, id: \.self) { suggestion in
+ Button(suggestion) {
+ entry.label = suggestion
+ }
+ }
+ } label: {
+ HStack(spacing: Design.Spacing.xSmall) {
+ Text(entry.label)
+ .foregroundStyle(Color.accentColor)
+ Image(systemName: "chevron.up.chevron.down")
+ .typography(.caption2)
+ .foregroundStyle(Color.secondary)
+ }
+ }
+ .frame(width: 80, alignment: .leading)
+
+ TextField(valuePlaceholder, text: $entry.value)
+ .keyboardType(keyboardType)
+ .textInputAutocapitalization(autocapitalization)
+ .onChange(of: entry.value) { _, newValue in
+ guard let formatValue else { return }
+ let formatted = formatValue(newValue)
+ if formatted != newValue {
+ entry.value = formatted
+ }
+ }
+ }
+
+ if showsValidationError, let validationMessage {
+ Text(validationMessage)
+ .typography(.caption)
+ .foregroundStyle(Color.Accent.red)
+ .padding(.leading, 80 + Design.Spacing.medium)
+ }
+ }
+ }
+}
diff --git a/BusinessCard/Views/Sheets/RecordContactSheet.swift b/BusinessCard/Views/Features/Contacts/Sheets/RecordContactSheet.swift
similarity index 100%
rename from BusinessCard/Views/Sheets/RecordContactSheet.swift
rename to BusinessCard/Views/Features/Contacts/Sheets/RecordContactSheet.swift
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingActivationStepView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingActivationStepView.swift
new file mode 100644
index 0000000..7760ccb
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingActivationStepView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingActivationStepView: View {
+ var body: 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) {
+ OnboardingChecklistRowView(text: "Add your name, role, and company")
+ OnboardingChecklistRowView(text: "Choose a photo or logo")
+ OnboardingChecklistRowView(text: "Share your card with one tap")
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingChecklistRowView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingChecklistRowView.swift
new file mode 100644
index 0000000..3c62791
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingChecklistRowView.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingChecklistRowView: View {
+ let text: String
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.small) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(Color.Accent.mint)
+ Text(text)
+ .typography(.body)
+ .foregroundStyle(Color.Text.primary)
+ }
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingFeatureRowView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingFeatureRowView.swift
new file mode 100644
index 0000000..e808047
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingFeatureRowView.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingFeatureRowView: View {
+ let icon: String
+ let title: String
+ let subtitle: String
+
+ var body: some View {
+ HStack(alignment: .top, spacing: Design.Spacing.medium) {
+ Image(systemName: icon)
+ .foregroundStyle(Color.Accent.red)
+ .frame(width: 28, height: 28, alignment: .topLeading)
+ .padding(.top, Design.Spacing.xxxSmall)
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(title)
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.Text.primary)
+
+ Text(subtitle)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ .frame(maxWidth: .infinity, minHeight: 100, alignment: .leading)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStatus.swift b/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStatus.swift
new file mode 100644
index 0000000..64b566e
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStatus.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+enum OnboardingPermissionStatus {
+ case notRequested
+ case allowed
+ case denied
+
+ var description: String {
+ switch self {
+ case .notRequested: "Not enabled yet"
+ case .allowed: "Enabled"
+ case .denied: "Blocked in Settings"
+ }
+ }
+
+ var tint: Color {
+ switch self {
+ case .notRequested: .orange
+ case .allowed: Color.Accent.mint
+ case .denied: Color.Accent.red
+ }
+ }
+
+ var isAllowed: Bool {
+ if case .allowed = self { return true }
+ return false
+ }
+
+ var isDenied: Bool {
+ if case .denied = self { return true }
+ return false
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStepView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStepView.swift
new file mode 100644
index 0000000..2a6b2e7
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingPermissionStepView.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingPermissionStepView: View {
+ let title: String
+ let icon: String
+ let reason: String
+ let status: OnboardingPermissionStatus
+
+ var body: some View {
+ ViewThatFits(in: .vertical) {
+ centeredLayout
+ scrollLayout
+ }
+ }
+
+ private var centeredLayout: some View {
+ VStack(spacing: Design.Spacing.large) {
+ Spacer(minLength: Design.Spacing.xxxLarge)
+
+ SymbolIcon(icon,
+ size: .hero,
+ color: AppThemeAccent.primary)
+
+ VStack(spacing: Design.Spacing.small) {
+ Text(title)
+ .typography(.title2Bold)
+ .foregroundStyle(Color.Text.primary)
+ .multilineTextAlignment(.center)
+
+ Text(reason)
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, Design.Spacing.large)
+
+ permissionStatusLabel
+ }
+
+ Spacer(minLength: Design.Spacing.xxxLarge)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private var scrollLayout: some View {
+ ScrollView(.vertical) {
+ VStack(spacing: Design.Spacing.large) {
+
+ SymbolIcon(icon,
+ size: .hero,
+ color: AppThemeAccent.primary)
+
+ Text(title)
+ .typography(.title2Bold)
+ .foregroundStyle(Color.Text.primary)
+ .multilineTextAlignment(.center)
+
+ Text(reason)
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, Design.Spacing.medium)
+
+ permissionStatusLabel
+ }
+ .padding(.top, Design.Spacing.large)
+ .padding(.bottom, Design.Spacing.medium)
+ .frame(maxWidth: .infinity)
+ }
+ .scrollIndicators(.hidden)
+ }
+
+ private var permissionStatusLabel: some View {
+ Text(status.description)
+ .typography(.calloutEmphasis)
+ .foregroundStyle(status.tint)
+ .padding(.horizontal, Design.Spacing.small)
+ .padding(.vertical, Design.Spacing.xxSmall)
+ .background(status.tint.opacity(0.14))
+ .clipShape(.capsule)
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingProgressHeaderView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingProgressHeaderView.swift
new file mode 100644
index 0000000..73698a8
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingProgressHeaderView.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingProgressHeaderView: View {
+ let step: OnboardingStep
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.small) {
+ Text(String.localized("Step %d of %d", step.index, step.total))
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ ProgressView(value: Double(step.index), total: Double(step.total))
+ .tint(Color.Accent.red)
+ }
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingStep.swift b/BusinessCard/Views/Features/Onboarding/OnboardingStep.swift
new file mode 100644
index 0000000..1d760ee
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingStep.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+enum OnboardingStep: Int, CaseIterable {
+ case welcome
+ case camera
+ case photos
+ case contacts
+ case activation
+
+ var index: Int { rawValue + 1 }
+
+ var total: Int { Self.allCases.count }
+
+ var isPermissionStep: Bool {
+ switch self {
+ case .camera, .photos, .contacts:
+ true
+ default:
+ false
+ }
+ }
+
+ var next: OnboardingStep? {
+ guard let nextStep = Self(rawValue: rawValue + 1) else { return nil }
+ return nextStep
+ }
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000..c99d596
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingView.swift
@@ -0,0 +1,235 @@
+import SwiftUI
+import Bedrock
+import AVFoundation
+import Photos
+import Contacts
+
+struct OnboardingView: View {
+ @Environment(AppState.self) private var appState
+ let onComplete: () -> Void
+
+ @State private var step: OnboardingStep = .welcome
+ @State private var cameraStatus: OnboardingPermissionStatus = .notRequested
+ @State private var photosStatus: OnboardingPermissionStatus = .notRequested
+ @State private var contactsStatus: OnboardingPermissionStatus = .notRequested
+
+ private let appName = AppIdentifiers.publicAppName
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ Color.AppBackground.base
+ .ignoresSafeArea()
+
+ VStack(spacing: Design.Spacing.large) {
+ OnboardingProgressHeaderView(step: step)
+
+ currentStepContent
+
+ Spacer(minLength: 0)
+
+ primaryAction
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.top, Design.Spacing.large)
+ .padding(.bottom, Design.Spacing.xLarge)
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if step != .activation {
+ Button(String.localized("Skip")) {
+ onComplete()
+ }
+ }
+ }
+ }
+ .onAppear {
+ refreshPermissionStatuses()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var currentStepContent: some View {
+ switch step {
+ case .welcome:
+ ScrollView(.vertical) {
+ OnboardingWelcomeStepView(appName: appName)
+ .padding(.top, Design.Spacing.xSmall)
+ }
+ .scrollIndicators(.hidden)
+ case .camera:
+ OnboardingPermissionStepView(
+ title: "Camera Access",
+ icon: "camera.fill",
+ reason: "Needed to scan QR cards and capture profile, cover, or logo photos.",
+ status: cameraStatus
+ )
+ case .photos:
+ OnboardingPermissionStepView(
+ title: "Photo Library",
+ icon: "photo.on.rectangle.angled",
+ reason: "Needed to pick profile, cover, and logo images from your library.",
+ status: photosStatus
+ )
+ case .contacts:
+ OnboardingPermissionStepView(
+ title: "Contacts Access",
+ icon: "person.crop.circle.badge.plus",
+ reason: "Needed when saving shared cards to your Apple Contacts.",
+ status: contactsStatus
+ )
+ case .activation:
+ OnboardingActivationStepView()
+ }
+ }
+
+ @ViewBuilder
+ private var primaryAction: some View {
+ switch step {
+ case .camera:
+ permissionPrimaryButton(status: cameraStatus, permissionName: "Camera", requestAction: requestCameraPermission)
+ case .photos:
+ permissionPrimaryButton(status: photosStatus, permissionName: "Photos", requestAction: requestPhotosPermission)
+ case .contacts:
+ permissionPrimaryButton(status: contactsStatus, permissionName: "Contacts", requestAction: requestContactsPermission)
+ case .activation:
+ PrimaryActionButton(
+ title: String.localized("Create My First Card"),
+ systemImage: "sparkles"
+ ) {
+ appState.selectedTab = .cards
+ appState.shouldPresentCreateCardFlow = true
+ onComplete()
+ }
+ case .welcome:
+ PrimaryActionButton(
+ title: String.localized("Continue"),
+ systemImage: "arrow.right"
+ ) {
+ advanceStep()
+ }
+ }
+ }
+
+ private func permissionPrimaryButton(
+ status: OnboardingPermissionStatus,
+ permissionName: String,
+ requestAction: @escaping () -> Void
+ ) -> some View {
+ let title: String
+ let systemImage: String
+ let action: () -> Void
+
+ switch status {
+ case .allowed:
+ title = "Continue"
+ systemImage = "arrow.right"
+ action = advanceStep
+ case .denied:
+ title = "Open Settings"
+ systemImage = "gear"
+ action = openSettings
+ case .notRequested:
+ title = "Enable \(permissionName)"
+ systemImage = "checkmark.shield"
+ action = requestAction
+ }
+
+ return PrimaryActionButton(title: title, systemImage: systemImage, action: action)
+ }
+
+ private func advanceStep() {
+ guard let next = step.next else { return }
+ withAnimation(.easeInOut(duration: 0.2)) {
+ step = next
+ }
+ }
+
+ 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) { granted in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ if granted, step == .camera {
+ advanceStep()
+ }
+ }
+ }
+ }
+
+ private func requestPhotosPermission() {
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ if (status == .authorized || status == .limited), step == .photos {
+ advanceStep()
+ }
+ }
+ }
+ }
+
+ private func requestContactsPermission() {
+ CNContactStore().requestAccess(for: .contacts) { granted, _ in
+ Task { @MainActor in
+ refreshPermissionStatuses()
+ if granted, step == .contacts {
+ advanceStep()
+ }
+ }
+ }
+ }
+
+ 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
+ }
+ }
+}
+
+#Preview {
+ OnboardingView(onComplete: {})
+}
diff --git a/BusinessCard/Views/Features/Onboarding/OnboardingWelcomeStepView.swift b/BusinessCard/Views/Features/Onboarding/OnboardingWelcomeStepView.swift
new file mode 100644
index 0000000..065fa72
--- /dev/null
+++ b/BusinessCard/Views/Features/Onboarding/OnboardingWelcomeStepView.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+import Bedrock
+
+struct OnboardingWelcomeStepView: View {
+ let appName: String
+
+ var body: 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 \(appName)")
+ .typography(.title)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ Text("Create one polished card, then share it anywhere in seconds.")
+ .typography(.body)
+ .foregroundStyle(Color.Text.secondary)
+ }
+
+ OnboardingFeatureRowView(
+ icon: "rectangle.stack.badge.plus",
+ title: "Create once",
+ subtitle: "Build a professional card with your branding and contact links."
+ )
+
+ OnboardingFeatureRowView(
+ icon: "qrcode",
+ title: "Share instantly",
+ subtitle: "Use QR, text, email, or App Clip links so people can save your details fast."
+ )
+
+ OnboardingFeatureRowView(
+ icon: "person.2",
+ title: "Track follow-ups",
+ subtitle: "Keep important contacts organized and set reminders from one place."
+ )
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/BusinessCard/Views/Settings/DefaultCardSelectionView.swift b/BusinessCard/Views/Features/Settings/Components/DefaultCardSelectionView.swift
similarity index 100%
rename from BusinessCard/Views/Settings/DefaultCardSelectionView.swift
rename to BusinessCard/Views/Features/Settings/Components/DefaultCardSelectionView.swift
diff --git a/BusinessCard/Views/SettingsView.swift b/BusinessCard/Views/Features/Settings/SettingsView.swift
similarity index 99%
rename from BusinessCard/Views/SettingsView.swift
rename to BusinessCard/Views/Features/Settings/SettingsView.swift
index 484a225..c4943b8 100644
--- a/BusinessCard/Views/SettingsView.swift
+++ b/BusinessCard/Views/Features/Settings/SettingsView.swift
@@ -15,7 +15,7 @@ struct SettingsView: View {
@Environment(\.openURL) private var openURL
@State private var settingsState = SettingsState()
@State private var showingResetOnboardingConfirmation = false
- private let appName = BundleAppMetadataProvider().appName
+ private let appName = AppIdentifiers.publicAppName
var body: some View {
NavigationStack {
diff --git a/BusinessCard/Views/QRCodeView.swift b/BusinessCard/Views/Features/Share/QRCodeView.swift
similarity index 100%
rename from BusinessCard/Views/QRCodeView.swift
rename to BusinessCard/Views/Features/Share/QRCodeView.swift
diff --git a/BusinessCard/Views/Features/Share/QRScannerRepresentable.swift b/BusinessCard/Views/Features/Share/QRScannerRepresentable.swift
new file mode 100644
index 0000000..07ffb3e
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/QRScannerRepresentable.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct QRScannerRepresentable: UIViewControllerRepresentable {
+ @Binding var scannedCode: String?
+
+ func makeUIViewController(context: Context) -> QRScannerViewController {
+ let controller = QRScannerViewController()
+ controller.delegate = context.coordinator
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(scannedCode: $scannedCode)
+ }
+
+ final class Coordinator: NSObject, QRScannerViewControllerDelegate {
+ @Binding var scannedCode: String?
+
+ init(scannedCode: Binding) {
+ _scannedCode = scannedCode
+ }
+
+ func didScanCode(_ code: String) {
+ Task { @MainActor in
+ scannedCode = code
+ }
+ }
+ }
+}
diff --git a/BusinessCard/Views/Features/Share/QRScannerView.swift b/BusinessCard/Views/Features/Share/QRScannerView.swift
new file mode 100644
index 0000000..b8ff7d9
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/QRScannerView.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+import Bedrock
+import AVFoundation
+
+struct QRScannerView: View {
+ @Environment(\.dismiss) private var dismiss
+ let onScan: (String) -> Void
+
+ @State private var scannedCode: String?
+ @State private var isScanning = true
+ @State private var showingPermissionDenied = false
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ if isScanning {
+ QRScannerRepresentable(scannedCode: $scannedCode)
+ .ignoresSafeArea()
+
+ // Overlay with scanning frame
+ ScannerOverlayView()
+ }
+
+ if let scannedCode {
+ ScannedResultView(code: scannedCode) {
+ onScan(scannedCode)
+ }
+ }
+ }
+ .navigationTitle(String.localized("Scan Card"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(String.localized("Cancel")) {
+ dismiss()
+ }
+ }
+ }
+ .onChange(of: scannedCode) { _, newValue in
+ if newValue != nil {
+ isScanning = false
+ }
+ }
+ .onAppear {
+ checkCameraPermission()
+ }
+ .alert(String.localized("Camera Access Required"), isPresented: $showingPermissionDenied) {
+ Button(String.localized("Open Settings")) {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ }
+ Button(String.localized("Cancel"), role: .cancel) {
+ dismiss()
+ }
+ } message: {
+ Text("Please allow camera access in Settings to scan QR codes.")
+ }
+ }
+ }
+
+ private func checkCameraPermission() {
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ break
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ if !granted {
+ Task { @MainActor in
+ showingPermissionDenied = true
+ }
+ }
+ }
+ case .denied, .restricted:
+ showingPermissionDenied = true
+ @unknown default:
+ break
+ }
+ }
+}
+
+#Preview {
+ QRScannerView { code in
+ Design.debugLog("Scanned: \(code)")
+ }
+}
diff --git a/BusinessCard/Views/Features/Share/QRScannerViewController.swift b/BusinessCard/Views/Features/Share/QRScannerViewController.swift
new file mode 100644
index 0000000..93a246c
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/QRScannerViewController.swift
@@ -0,0 +1,86 @@
+import UIKit
+import AVFoundation
+
+final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
+ weak var delegate: QRScannerViewControllerDelegate?
+
+ private var captureSession: AVCaptureSession?
+ private var previewLayer: AVCaptureVideoPreviewLayer?
+ private var hasScanned = false
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupCamera()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ previewLayer?.frame = view.bounds
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ startScanning()
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopScanning()
+ }
+
+ private func setupCamera() {
+ let session = AVCaptureSession()
+
+ guard let device = AVCaptureDevice.default(for: .video),
+ let input = try? AVCaptureDeviceInput(device: device) else {
+ return
+ }
+
+ if session.canAddInput(input) {
+ session.addInput(input)
+ }
+
+ let output = AVCaptureMetadataOutput()
+ if session.canAddOutput(output) {
+ session.addOutput(output)
+ output.setMetadataObjectsDelegate(self, queue: .main)
+ output.metadataObjectTypes = [.qr]
+ }
+
+ let previewLayer = AVCaptureVideoPreviewLayer(session: session)
+ previewLayer.videoGravity = .resizeAspectFill
+ previewLayer.frame = view.bounds
+ view.layer.addSublayer(previewLayer)
+
+ self.captureSession = session
+ self.previewLayer = previewLayer
+ }
+
+ private func startScanning() {
+ guard let session = captureSession, !session.isRunning else { return }
+ let capturedSession = session
+ DispatchQueue.global(qos: .userInitiated).async {
+ capturedSession.startRunning()
+ }
+ }
+
+ private func stopScanning() {
+ guard let session = captureSession, session.isRunning else { return }
+ let capturedSession = session
+ DispatchQueue.global(qos: .userInitiated).async {
+ capturedSession.stopRunning()
+ }
+ }
+
+ func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
+ guard !hasScanned,
+ let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
+ let code = metadataObject.stringValue else {
+ return
+ }
+
+ hasScanned = true
+ AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
+ delegate?.didScanCode(code)
+ }
+}
diff --git a/BusinessCard/Views/Features/Share/QRScannerViewControllerDelegate.swift b/BusinessCard/Views/Features/Share/QRScannerViewControllerDelegate.swift
new file mode 100644
index 0000000..9b9eece
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/QRScannerViewControllerDelegate.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+protocol QRScannerViewControllerDelegate: AnyObject {
+ func didScanCode(_ code: String)
+}
diff --git a/BusinessCard/Views/Features/Share/ScannedResultView.swift b/BusinessCard/Views/Features/Share/ScannedResultView.swift
new file mode 100644
index 0000000..69baa02
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/ScannedResultView.swift
@@ -0,0 +1,67 @@
+import SwiftUI
+import Bedrock
+
+struct ScannedResultView: View {
+ let code: String
+ let onConfirm: () -> Void
+
+ private var isVCard: Bool {
+ code.contains("BEGIN:VCARD")
+ }
+
+ private var parsedName: String? {
+ guard isVCard else { return nil }
+ let lines = code.components(separatedBy: "\n")
+ for line in lines {
+ if line.hasPrefix("FN:") {
+ return String(line.dropFirst(3))
+ }
+ }
+ return nil
+ }
+
+ var body: some View {
+ VStack(spacing: Design.Spacing.xLarge) {
+ Image(systemName: isVCard ? "person.crop.circle.badge.checkmark" : "qrcode")
+ .font(.system(size: Design.IconSize.xxxLarge))
+ .foregroundStyle(Color.Accent.red)
+
+ if isVCard {
+ VStack(spacing: Design.Spacing.small) {
+ Text("Card Found!")
+ .typography(.title2)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+
+ if let name = parsedName {
+ Text(name)
+ .typography(.heading)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ }
+ } else {
+ Text("QR Code Scanned")
+ .typography(.title2)
+ .bold()
+ .foregroundStyle(Color.Text.primary)
+ }
+
+ if isVCard {
+ Button(String.localized("Save Contact"), systemImage: "person.badge.plus") {
+ onConfirm()
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(Color.Accent.red)
+ .controlSize(.large)
+ } else {
+ Text("This doesn't appear to be a business card QR code.")
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, Design.Spacing.xLarge)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.AppBackground.base)
+ }
+}
diff --git a/BusinessCard/Views/Features/Share/ScannerOverlayView.swift b/BusinessCard/Views/Features/Share/ScannerOverlayView.swift
new file mode 100644
index 0000000..9fa1c8d
--- /dev/null
+++ b/BusinessCard/Views/Features/Share/ScannerOverlayView.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+import Bedrock
+
+struct ScannerOverlayView: View {
+ var body: some View {
+ GeometryReader { geometry in
+ let size = min(geometry.size.width, geometry.size.height) * 0.7
+
+ ZStack {
+ Color.black.opacity(Design.Opacity.medium)
+
+ RoundedRectangle(cornerRadius: Design.CornerRadius.large)
+ .frame(width: size, height: size)
+ .blendMode(.destinationOut)
+ }
+ .compositingGroup()
+
+ RoundedRectangle(cornerRadius: Design.CornerRadius.large)
+ .stroke(Color.Accent.red, lineWidth: Design.LineWidth.thick)
+ .frame(width: size, height: size)
+ .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
+
+ VStack {
+ Spacer()
+
+ Text("Point at a QR code")
+ .typography(.heading)
+ .foregroundStyle(Color.Text.inverted)
+ .padding(Design.Spacing.medium)
+ .background(Color.black.opacity(Design.Opacity.medium))
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ .padding(.bottom, Design.Spacing.xxxLarge)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ }
+}
diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/Features/Share/ShareCardView.swift
similarity index 100%
rename from BusinessCard/Views/ShareCardView.swift
rename to BusinessCard/Views/Features/Share/ShareCardView.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetPhonePreviewCard.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetPhonePreviewCard.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetPhonePreviewCard.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetPreviewCardView.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetPreviewCardView.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetPreviewCardView.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetSurfaceCard.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetSurfaceCard.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetSurfaceCard.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetWatchPreviewCard.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetWatchPreviewCard.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetWatchPreviewCard.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetsEmptyStateCardView.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetsEmptyStateCardView.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetsEmptyStateCardView.swift
diff --git a/BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift b/BusinessCard/Views/Features/Widgets/Views/Components/WidgetsHeroCardView.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/Components/WidgetsHeroCardView.swift
rename to BusinessCard/Views/Features/Widgets/Views/Components/WidgetsHeroCardView.swift
diff --git a/BusinessCard/Views/Widgets/WidgetsView.swift b/BusinessCard/Views/Features/Widgets/Views/WidgetsView.swift
similarity index 100%
rename from BusinessCard/Views/Widgets/WidgetsView.swift
rename to BusinessCard/Views/Features/Widgets/Views/WidgetsView.swift
diff --git a/BusinessCard/Views/OnboardingView.swift b/BusinessCard/Views/OnboardingView.swift
deleted file mode 100644
index f29794c..0000000
--- a/BusinessCard/Views/OnboardingView.swift
+++ /dev/null
@@ -1,377 +0,0 @@
-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
- private let appName = BundleAppMetadataProvider().appName
-
- 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 \(appName)")
- .typography(.title)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- Text("Create one polished card, then share it anywhere in seconds.")
- .typography(.body)
- .foregroundStyle(Color.Text.secondary)
- }
-
- onboardingFeature(
- icon: "rectangle.stack.badge.plus",
- title: "Create once",
- subtitle: "Build a professional card with your branding and contact links."
- )
-
- onboardingFeature(
- icon: "qrcode",
- title: "Share instantly",
- subtitle: "Use QR, text, email, or App Clip links so people can save your details fast."
- )
-
- onboardingFeature(
- icon: "person.2",
- title: "Track follow-ups",
- subtitle: "Keep important contacts organized and set reminders from one place."
- )
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private var permissionsStep: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.large) {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- Text("Enable the essentials")
- .typography(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- Text("We ask only when it directly improves card sharing and profile quality.")
- .typography(.body)
- .foregroundStyle(Color.Text.secondary)
- }
-
- permissionRow(
- title: "Camera",
- icon: "camera.fill",
- reason: "Needed to scan QR cards and capture profile, cover, or logo photos.",
- status: cameraStatus,
- action: { requestCameraPermission() }
- )
-
- permissionRow(
- title: "Photos",
- icon: "photo.on.rectangle.angled",
- reason: "Needed to pick profile, cover, and logo images from your library.",
- status: photosStatus,
- action: { requestPhotosPermission() }
- )
-
- permissionRow(
- title: "Contacts",
- icon: "person.crop.circle.badge.plus",
- reason: "Needed when saving shared cards to your Apple Contacts.",
- status: contactsStatus,
- action: { requestContactsPermission() }
- )
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- private var activationStep: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.large) {
- Image(systemName: "checkmark.seal.fill")
- .typography(.title)
- .foregroundStyle(Color.Accent.mint)
-
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- Text("You are ready to share")
- .typography(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- Text("Next step: create your first card. Once it is saved, you can start sharing immediately.")
- .typography(.body)
- .foregroundStyle(Color.Text.secondary)
- }
-
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- onboardingChecklistItem("Add your name, role, and company")
- onboardingChecklistItem("Choose a photo or logo")
- onboardingChecklistItem("Share your card with one tap")
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-
- @ViewBuilder
- private var primaryAction: some View {
- if stepIndex < totalSteps - 1 {
- PrimaryActionButton(
- title: String.localized("Continue"),
- systemImage: "arrow.right"
- ) {
- withAnimation(.easeInOut(duration: 0.2)) {
- stepIndex += 1
- }
- }
- } else {
- PrimaryActionButton(
- title: String.localized("Create My First Card"),
- systemImage: "sparkles"
- ) {
- onComplete()
- }
- }
- }
-
- private func onboardingFeature(icon: String, title: String, subtitle: String) -> some View {
- HStack(alignment: .top, spacing: Design.Spacing.medium) {
- Image(systemName: icon)
- .foregroundStyle(Color.Accent.red)
- .frame(width: Design.IconSize.medium)
-
- VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
- Text(title)
- .typography(.bodyEmphasis)
- .foregroundStyle(Color.Text.primary)
-
- Text(subtitle)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
- }
- }
- .padding(Design.Spacing.medium)
- .background(Color.AppBackground.elevated)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
- }
-
- private func onboardingChecklistItem(_ text: String) -> some View {
- HStack(spacing: Design.Spacing.small) {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(Color.Accent.mint)
- Text(text)
- .typography(.body)
- .foregroundStyle(Color.Text.primary)
- }
- }
-
- private func permissionRow(
- title: String,
- icon: String,
- reason: String,
- status: OnboardingPermissionStatus,
- action: @escaping () -> Void
- ) -> some View {
- VStack(alignment: .leading, spacing: Design.Spacing.small) {
- HStack(spacing: Design.Spacing.small) {
- Image(systemName: icon)
- .foregroundStyle(Color.Accent.red)
- Text(title)
- .typography(.bodyEmphasis)
- .foregroundStyle(Color.Text.primary)
- Spacer()
- permissionStatusPill(status)
- }
-
- Text(reason)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
-
- if status != .allowed {
- Button(status == .denied ? "Open Settings" : "Allow Now") {
- if status == .denied {
- openSettings()
- } else {
- action()
- }
- }
- .buttonStyle(.borderedProminent)
- .tint(Color.Accent.red)
- .controlSize(.small)
- }
- }
- .padding(Design.Spacing.medium)
- .background(Color.AppBackground.elevated)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
- }
-
- private func permissionStatusPill(_ status: OnboardingPermissionStatus) -> some View {
- Text(status.title)
- .typography(.caption2)
- .foregroundStyle(status.tint)
- .padding(.horizontal, Design.Spacing.small)
- .padding(.vertical, Design.Spacing.xxSmall)
- .background(status.tint.opacity(0.14))
- .clipShape(.capsule)
- }
-
- private func openSettings() {
- guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
- UIApplication.shared.open(url)
- }
-
- private func refreshPermissionStatuses() {
- cameraStatus = mapCameraStatus(AVCaptureDevice.authorizationStatus(for: .video))
- photosStatus = mapPhotosStatus(PHPhotoLibrary.authorizationStatus(for: .readWrite))
- contactsStatus = mapContactsStatus(CNContactStore.authorizationStatus(for: .contacts))
- }
-
- private func requestCameraPermission() {
- AVCaptureDevice.requestAccess(for: .video) { _ in
- Task { @MainActor in
- refreshPermissionStatuses()
- }
- }
- }
-
- private func requestPhotosPermission() {
- PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in
- Task { @MainActor in
- refreshPermissionStatuses()
- }
- }
- }
-
- private func requestContactsPermission() {
- CNContactStore().requestAccess(for: .contacts) { _, _ in
- Task { @MainActor in
- refreshPermissionStatuses()
- }
- }
- }
-
- private func mapCameraStatus(_ status: AVAuthorizationStatus) -> OnboardingPermissionStatus {
- switch status {
- case .authorized:
- .allowed
- case .notDetermined:
- .notRequested
- case .denied, .restricted:
- .denied
- @unknown default:
- .notRequested
- }
- }
-
- private func mapPhotosStatus(_ status: PHAuthorizationStatus) -> OnboardingPermissionStatus {
- switch status {
- case .authorized, .limited:
- .allowed
- case .notDetermined:
- .notRequested
- case .denied, .restricted:
- .denied
- @unknown default:
- .notRequested
- }
- }
-
- private func mapContactsStatus(_ status: CNAuthorizationStatus) -> OnboardingPermissionStatus {
- switch status {
- case .authorized:
- .allowed
- case .notDetermined:
- .notRequested
- case .denied, .restricted:
- .denied
- @unknown default:
- .notRequested
- }
- }
-}
-
-private enum OnboardingPermissionStatus {
- case notRequested
- case allowed
- case denied
-
- var title: String {
- switch self {
- case .notRequested: "Not enabled"
- case .allowed: "Enabled"
- case .denied: "Blocked"
- }
- }
-
- var tint: Color {
- switch self {
- case .notRequested: .orange
- case .allowed: Color.Accent.mint
- case .denied: Color.Accent.red
- }
- }
-}
-
-#Preview {
- OnboardingView(onComplete: {})
-}
diff --git a/BusinessCard/Views/QRScannerView.swift b/BusinessCard/Views/QRScannerView.swift
deleted file mode 100644
index c5daee4..0000000
--- a/BusinessCard/Views/QRScannerView.swift
+++ /dev/null
@@ -1,312 +0,0 @@
-import SwiftUI
-import Bedrock
-import AVFoundation
-
-struct QRScannerView: View {
- @Environment(\.dismiss) private var dismiss
- let onScan: (String) -> Void
-
- @State private var scannedCode: String?
- @State private var isScanning = true
- @State private var showingPermissionDenied = false
-
- var body: some View {
- NavigationStack {
- ZStack {
- if isScanning {
- QRScannerRepresentable(scannedCode: $scannedCode)
- .ignoresSafeArea()
-
- // Overlay with scanning frame
- ScannerOverlayView()
- }
-
- if let scannedCode {
- ScannedResultView(code: scannedCode) {
- onScan(scannedCode)
- }
- }
- }
- .navigationTitle(String.localized("Scan Card"))
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button(String.localized("Cancel")) {
- dismiss()
- }
- }
- }
- .onChange(of: scannedCode) { _, newValue in
- if newValue != nil {
- isScanning = false
- }
- }
- .onAppear {
- checkCameraPermission()
- }
- .alert(String.localized("Camera Access Required"), isPresented: $showingPermissionDenied) {
- Button(String.localized("Open Settings")) {
- if let url = URL(string: UIApplication.openSettingsURLString) {
- UIApplication.shared.open(url)
- }
- }
- Button(String.localized("Cancel"), role: .cancel) {
- dismiss()
- }
- } message: {
- Text("Please allow camera access in Settings to scan QR codes.")
- }
- }
- }
-
- private func checkCameraPermission() {
- switch AVCaptureDevice.authorizationStatus(for: .video) {
- case .authorized:
- break
- case .notDetermined:
- AVCaptureDevice.requestAccess(for: .video) { granted in
- if !granted {
- Task { @MainActor in
- showingPermissionDenied = true
- }
- }
- }
- case .denied, .restricted:
- showingPermissionDenied = true
- @unknown default:
- break
- }
- }
-}
-
-private struct ScannerOverlayView: View {
- var body: some View {
- GeometryReader { geometry in
- let size = min(geometry.size.width, geometry.size.height) * 0.7
-
- ZStack {
- // Dimmed background
- Color.black.opacity(Design.Opacity.medium)
-
- // Clear center
- RoundedRectangle(cornerRadius: Design.CornerRadius.large)
- .frame(width: size, height: size)
- .blendMode(.destinationOut)
- }
- .compositingGroup()
-
- // Scanning frame corners
- RoundedRectangle(cornerRadius: Design.CornerRadius.large)
- .stroke(Color.Accent.red, lineWidth: Design.LineWidth.thick)
- .frame(width: size, height: size)
- .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
-
- // Instructions
- VStack {
- Spacer()
-
- Text("Point at a QR code")
- .typography(.heading)
- .foregroundStyle(Color.Text.inverted)
- .padding(Design.Spacing.medium)
- .background(Color.black.opacity(Design.Opacity.medium))
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- .padding(.bottom, Design.Spacing.xxxLarge)
- }
- .frame(maxWidth: .infinity)
- }
- }
-}
-
-private struct ScannedResultView: View {
- let code: String
- let onConfirm: () -> Void
-
- private var isVCard: Bool {
- code.contains("BEGIN:VCARD")
- }
-
- private var parsedName: String? {
- guard isVCard else { return nil }
- let lines = code.components(separatedBy: "\n")
- for line in lines {
- if line.hasPrefix("FN:") {
- return String(line.dropFirst(3))
- }
- }
- return nil
- }
-
- var body: some View {
- VStack(spacing: Design.Spacing.xLarge) {
- Image(systemName: isVCard ? "person.crop.circle.badge.checkmark" : "qrcode")
- .font(.system(size: Design.IconSize.xxxLarge))
- .foregroundStyle(Color.Accent.red)
-
- if isVCard {
- VStack(spacing: Design.Spacing.small) {
- Text("Card Found!")
- .typography(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
-
- if let name = parsedName {
- Text(name)
- .typography(.heading)
- .foregroundStyle(Color.Text.secondary)
- }
- }
- } else {
- Text("QR Code Scanned")
- .typography(.title2)
- .bold()
- .foregroundStyle(Color.Text.primary)
- }
-
- if isVCard {
- Button(String.localized("Save Contact"), systemImage: "person.badge.plus") {
- onConfirm()
- }
- .buttonStyle(.borderedProminent)
- .tint(Color.Accent.red)
- .controlSize(.large)
- } else {
- Text("This doesn't appear to be a business card QR code.")
- .typography(.subheading)
- .foregroundStyle(Color.Text.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, Design.Spacing.xLarge)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.AppBackground.base)
- }
-}
-
-// MARK: - Camera View Representable
-
-private struct QRScannerRepresentable: UIViewControllerRepresentable {
- @Binding var scannedCode: String?
-
- func makeUIViewController(context: Context) -> QRScannerViewController {
- let controller = QRScannerViewController()
- controller.delegate = context.coordinator
- return controller
- }
-
- func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {}
-
- func makeCoordinator() -> Coordinator {
- Coordinator(scannedCode: $scannedCode)
- }
-
- class Coordinator: NSObject, QRScannerViewControllerDelegate {
- @Binding var scannedCode: String?
-
- init(scannedCode: Binding) {
- _scannedCode = scannedCode
- }
-
- func didScanCode(_ code: String) {
- Task { @MainActor in
- scannedCode = code
- }
- }
- }
-}
-
-// MARK: - Scanner View Controller
-
-protocol QRScannerViewControllerDelegate: AnyObject {
- func didScanCode(_ code: String)
-}
-
-private class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
- weak var delegate: QRScannerViewControllerDelegate?
-
- private var captureSession: AVCaptureSession?
- private var previewLayer: AVCaptureVideoPreviewLayer?
- private var hasScanned = false
-
- override func viewDidLoad() {
- super.viewDidLoad()
- setupCamera()
- }
-
- override func viewDidLayoutSubviews() {
- super.viewDidLayoutSubviews()
- previewLayer?.frame = view.bounds
- }
-
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- startScanning()
- }
-
- override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- stopScanning()
- }
-
- private func setupCamera() {
- let session = AVCaptureSession()
-
- guard let device = AVCaptureDevice.default(for: .video),
- let input = try? AVCaptureDeviceInput(device: device) else {
- return
- }
-
- if session.canAddInput(input) {
- session.addInput(input)
- }
-
- let output = AVCaptureMetadataOutput()
- if session.canAddOutput(output) {
- session.addOutput(output)
- output.setMetadataObjectsDelegate(self, queue: .main)
- output.metadataObjectTypes = [.qr]
- }
-
- let previewLayer = AVCaptureVideoPreviewLayer(session: session)
- previewLayer.videoGravity = .resizeAspectFill
- previewLayer.frame = view.bounds
- view.layer.addSublayer(previewLayer)
-
- self.captureSession = session
- self.previewLayer = previewLayer
- }
-
- private func startScanning() {
- guard let session = captureSession, !session.isRunning else { return }
- let capturedSession = session
- Task.detached {
- capturedSession.startRunning()
- }
- }
-
- private func stopScanning() {
- guard let session = captureSession, session.isRunning else { return }
- let capturedSession = session
- Task.detached {
- capturedSession.stopRunning()
- }
- }
-
- func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
- guard !hasScanned,
- let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
- let code = metadataObject.stringValue else {
- return
- }
-
- hasScanned = true
- AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
- delegate?.didScanCode(code)
- }
-}
-
-#Preview {
- QRScannerView { code in
- Design.debugLog("Scanned: \(code)")
- }
-}
diff --git a/BusinessCard/Views/Components/ActionRowView.swift b/BusinessCard/Views/Shared/Components/ActionRowContent.swift
similarity index 56%
rename from BusinessCard/Views/Components/ActionRowView.swift
rename to BusinessCard/Views/Shared/Components/ActionRowContent.swift
index 793fe87..fe96610 100644
--- a/BusinessCard/Views/Components/ActionRowView.swift
+++ b/BusinessCard/Views/Shared/Components/ActionRowContent.swift
@@ -1,44 +1,18 @@
import SwiftUI
import Bedrock
-/// A generic action row with icon, title, optional subtitle, and chevron.
-/// Used for share options, settings rows, and navigation items.
-struct ActionRowView: View {
- let title: String
- let subtitle: String?
- let systemImage: String
- @ViewBuilder let action: () -> Action
-
- init(
- title: String,
- subtitle: String? = nil,
- systemImage: String,
- @ViewBuilder action: @escaping () -> Action
- ) {
- self.title = title
- self.subtitle = subtitle
- self.systemImage = systemImage
- self.action = action
- }
-
- var body: some View {
- action()
- .buttonStyle(.plain)
- }
-}
-
/// Content layout for action rows - icon, text, chevron.
struct ActionRowContent: View {
let title: String
let subtitle: String?
let systemImage: String
-
+
init(title: String, subtitle: String? = nil, systemImage: String) {
self.title = title
self.subtitle = subtitle
self.systemImage = systemImage
}
-
+
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
@@ -46,21 +20,21 @@ struct ActionRowContent: View {
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.background(Color.AppBackground.accent)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
-
+
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(title)
.typography(.heading)
.foregroundStyle(Color.Text.primary)
-
+
if let subtitle {
Text(subtitle)
.typography(.subheading)
.foregroundStyle(Color.Text.secondary)
}
}
-
+
Spacer()
-
+
Image(systemName: "chevron.right")
.foregroundStyle(Color.Text.secondary)
}
@@ -69,20 +43,3 @@ struct ActionRowContent: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
-
-#Preview {
- VStack(spacing: Design.Spacing.medium) {
- ActionRowContent(
- title: "Share via NFC",
- subtitle: "Tap phones to share instantly",
- systemImage: "dot.radiowaves.left.and.right"
- )
-
- ActionRowContent(
- title: "Copy Link",
- systemImage: "link"
- )
- }
- .padding()
- .background(Color.AppBackground.base)
-}
diff --git a/BusinessCard/Views/Shared/Components/ActionRowView.swift b/BusinessCard/Views/Shared/Components/ActionRowView.swift
new file mode 100644
index 0000000..088a9af
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/ActionRowView.swift
@@ -0,0 +1,45 @@
+import SwiftUI
+import Bedrock
+
+/// A generic action row with icon, title, optional subtitle, and chevron.
+/// Used for share options, settings rows, and navigation items.
+struct ActionRowView: View {
+ let title: String
+ let subtitle: String?
+ let systemImage: String
+ @ViewBuilder let action: () -> Action
+
+ init(
+ title: String,
+ subtitle: String? = nil,
+ systemImage: String,
+ @ViewBuilder action: @escaping () -> Action
+ ) {
+ self.title = title
+ self.subtitle = subtitle
+ self.systemImage = systemImage
+ self.action = action
+ }
+
+ var body: some View {
+ action()
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ VStack(spacing: Design.Spacing.medium) {
+ ActionRowContent(
+ title: "Share via NFC",
+ subtitle: "Tap phones to share instantly",
+ systemImage: "dot.radiowaves.left.and.right"
+ )
+
+ ActionRowContent(
+ title: "Copy Link",
+ systemImage: "link"
+ )
+ }
+ .padding()
+ .background(Color.AppBackground.base)
+}
diff --git a/BusinessCard/Views/Shared/Components/AddedContactField.swift b/BusinessCard/Views/Shared/Components/AddedContactField.swift
new file mode 100644
index 0000000..6d6dca8
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/AddedContactField.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+/// Represents a contact field that has been added
+struct AddedContactField: Identifiable, Equatable {
+ let id: UUID
+ let fieldType: ContactFieldType
+ var value: String
+ var title: String
+
+ init(id: UUID = UUID(), fieldType: ContactFieldType, value: String = "", title: String = "") {
+ self.id = id
+ self.fieldType = fieldType
+ self.value = value
+ self.title = title
+ }
+
+ static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
+ lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
+ }
+
+ /// Returns the display value for this field (formatted for addresses, raw for others)
+ var displayValue: String {
+ fieldType.formattedDisplayValue(value)
+ }
+
+ /// Returns a short display value suitable for single-line display in lists
+ var shortDisplayValue: String {
+ if fieldType.id == "address" {
+ if let address = PostalAddress.decode(from: value), address.hasValue {
+ return address.singleLineString
+ }
+ }
+ return value
+ }
+}
diff --git a/BusinessCard/Views/Shared/Components/AddedContactFieldsView.swift b/BusinessCard/Views/Shared/Components/AddedContactFieldsView.swift
new file mode 100644
index 0000000..7e07e99
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/AddedContactFieldsView.swift
@@ -0,0 +1,80 @@
+import SwiftUI
+import Bedrock
+
+/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
+struct AddedContactFieldsView: View {
+ @Binding var fields: [AddedContactField]
+ var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
+ let onEdit: (AddedContactField) -> Void
+
+ @State private var draggingField: AddedContactField?
+
+ var body: some View {
+ if fields.isEmpty {
+ EmptyView()
+ } else {
+ VStack(spacing: 0) {
+ ForEach(fields) { field in
+ FieldRow(
+ field: field,
+ themeColor: themeColor,
+ onTap: { onEdit(field) },
+ onDelete: { deleteField(field) }
+ )
+ .draggable(field.id.uuidString) {
+ // Drag preview
+ FieldRowPreview(field: field, themeColor: themeColor)
+ }
+ .dropDestination(for: String.self) { items, _ in
+ guard let droppedId = items.first,
+ let droppedUUID = UUID(uuidString: droppedId),
+ let fromIndex = fields.firstIndex(where: { $0.id == droppedUUID }),
+ let toIndex = fields.firstIndex(where: { $0.id == field.id }),
+ fromIndex != toIndex else {
+ return false
+ }
+ withAnimation(.spring(duration: Design.Animation.quick)) {
+ let movedField = fields.remove(at: fromIndex)
+ fields.insert(movedField, at: toIndex)
+ }
+ return true
+ }
+
+ if field.id != fields.last?.id {
+ Divider()
+ .padding(.leading, Design.CardSize.avatarSize + Design.Spacing.large + Design.Spacing.medium)
+ }
+ }
+ }
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ }
+ }
+
+ private func deleteField(_ field: AddedContactField) {
+ withAnimation {
+ fields.removeAll { $0.id == field.id }
+ }
+ }
+}
+
+#Preview {
+ @Previewable @State var fields: [AddedContactField] = {
+ let address = PostalAddress(street: "6565 Headquarters Dr", city: "Plano", state: "TX", postalCode: "75024")
+ return [
+ AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
+ AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
+ AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
+ AddedContactField(fieldType: .address, value: address.encode(), title: "Work"),
+ AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
+ ]
+ }()
+
+ ScrollView {
+ AddedContactFieldsView(fields: $fields) { field in
+ Design.debugLog("Edit: \(field.fieldType.displayName)")
+ }
+ .padding()
+ }
+ .background(Color.AppBackground.base)
+}
diff --git a/BusinessCard/Views/Components/AddressEditorView.swift b/BusinessCard/Views/Shared/Components/AddressEditorView.swift
similarity index 77%
rename from BusinessCard/Views/Components/AddressEditorView.swift
rename to BusinessCard/Views/Shared/Components/AddressEditorView.swift
index 0d929f5..4418a4f 100644
--- a/BusinessCard/Views/Components/AddressEditorView.swift
+++ b/BusinessCard/Views/Shared/Components/AddressEditorView.swift
@@ -56,31 +56,6 @@ struct AddressEditorView: View {
}
}
-// MARK: - Address Text Field
-
-private struct AddressTextField: View {
- let label: String
- let placeholder: String
- @Binding var text: String
- var textContentType: UITextContentType?
- var keyboardType: UIKeyboardType = .default
-
- var body: some View {
- VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
- Text(label)
- .typography(.caption)
- .foregroundStyle(Color.Text.secondary)
-
- TextField(placeholder, text: $text)
- .textContentType(textContentType)
- .keyboardType(keyboardType)
- .textInputAutocapitalization(.words)
-
- Divider()
- }
- }
-}
-
// MARK: - Preview
#Preview {
diff --git a/BusinessCard/Views/Shared/Components/AddressTextField.swift b/BusinessCard/Views/Shared/Components/AddressTextField.swift
new file mode 100644
index 0000000..451e8f5
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/AddressTextField.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+import UIKit
+import Bedrock
+
+struct AddressTextField: View {
+ let label: String
+ let placeholder: String
+ @Binding var text: String
+ var textContentType: UITextContentType?
+ var keyboardType: UIKeyboardType = .default
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
+ Text(label)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+
+ TextField(placeholder, text: $text)
+ .textContentType(textContentType)
+ .keyboardType(keyboardType)
+ .textInputAutocapitalization(.words)
+
+ Divider()
+ }
+ }
+}
diff --git a/BusinessCard/Views/Components/AvatarBadgeView.swift b/BusinessCard/Views/Shared/Components/AvatarBadgeView.swift
similarity index 100%
rename from BusinessCard/Views/Components/AvatarBadgeView.swift
rename to BusinessCard/Views/Shared/Components/AvatarBadgeView.swift
diff --git a/BusinessCard/Views/Components/CameraCaptureView.swift b/BusinessCard/Views/Shared/Components/CameraCaptureView.swift
similarity index 100%
rename from BusinessCard/Views/Components/CameraCaptureView.swift
rename to BusinessCard/Views/Shared/Components/CameraCaptureView.swift
diff --git a/BusinessCard/Views/Shared/Components/CameraFlow.swift b/BusinessCard/Views/Shared/Components/CameraFlow.swift
new file mode 100644
index 0000000..37ab7be
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/CameraFlow.swift
@@ -0,0 +1,73 @@
+import SwiftUI
+import Bedrock
+
+struct CameraFlow: View {
+ let aspectRatio: CropAspectRatio
+ let allowAspectRatioSelection: Bool
+ let isLogoImage: Bool
+ let onComplete: (Data?) -> Void
+
+ @State private var capturedImageData: Data?
+ @State private var showingCropper = false
+ @State private var showingLogoEditor = false
+ @State private var croppedLogoImage: UIImage?
+ @State private var cameraID = UUID()
+
+ var body: some View {
+ ZStack {
+ if !showingCropper && !showingLogoEditor {
+ CameraCaptureView(shouldDismissOnCapture: false) { imageData in
+ if let imageData {
+ capturedImageData = imageData
+ showingCropper = true
+ } else {
+ onComplete(nil)
+ }
+ }
+ .id(cameraID)
+ .ignoresSafeArea()
+ .transition(.opacity)
+ }
+
+ if showingCropper, let imageData = capturedImageData {
+ PhotoCropperSheet(
+ imageData: imageData,
+ aspectRatio: aspectRatio,
+ allowAspectRatioSelection: allowAspectRatioSelection,
+ shouldDismissOnComplete: false
+ ) { croppedData in
+ if let croppedData {
+ if isLogoImage, let uiImage = UIImage(data: croppedData) {
+ croppedLogoImage = uiImage
+ showingCropper = false
+ showingLogoEditor = true
+ } else {
+ onComplete(croppedData)
+ }
+ } else {
+ showingCropper = false
+ capturedImageData = nil
+ cameraID = UUID()
+ }
+ }
+ .transition(.move(edge: .trailing))
+ }
+
+ if showingLogoEditor, let logoImage = croppedLogoImage {
+ LogoEditorSheet(logoImage: logoImage) { finalData in
+ if let finalData {
+ onComplete(finalData)
+ } else {
+ showingLogoEditor = false
+ croppedLogoImage = nil
+ capturedImageData = nil
+ cameraID = UUID()
+ }
+ }
+ .transition(.move(edge: .trailing))
+ }
+ }
+ .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
+ .animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
+ }
+}
diff --git a/BusinessCard/Views/Components/CameraWithCropper.swift b/BusinessCard/Views/Shared/Components/CameraWithCropper.swift
similarity index 100%
rename from BusinessCard/Views/Components/CameraWithCropper.swift
rename to BusinessCard/Views/Shared/Components/CameraWithCropper.swift
diff --git a/BusinessCard/Views/Components/ContactFieldPickerView.swift b/BusinessCard/Views/Shared/Components/ContactFieldPickerView.swift
similarity index 65%
rename from BusinessCard/Views/Components/ContactFieldPickerView.swift
rename to BusinessCard/Views/Shared/Components/ContactFieldPickerView.swift
index 94ccaad..8801dd6 100644
--- a/BusinessCard/Views/Components/ContactFieldPickerView.swift
+++ b/BusinessCard/Views/Shared/Components/ContactFieldPickerView.swift
@@ -44,36 +44,6 @@ struct ContactFieldPickerView: View {
}
}
-private struct FieldTypeButton: View {
- let fieldType: ContactFieldType
- let themeColor: Color
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- VStack(spacing: Design.Spacing.small) {
- Circle()
- .fill(themeColor)
- .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
- .overlay(
- fieldType.iconImage()
- .typography(.title3)
- .foregroundStyle(.white)
- )
-
- Text(fieldType.displayName)
- .typography(.caption)
- .foregroundStyle(Color.Text.primary)
- .multilineTextAlignment(.center)
- .lineLimit(2)
- .frame(height: Design.Spacing.xLarge * 2)
- }
- }
- .buttonStyle(.plain)
- .accessibilityLabel(fieldType.displayName)
- }
-}
-
#Preview {
ContactFieldPickerView { fieldType in
Design.debugLog("Selected: \(fieldType.displayName)")
diff --git a/BusinessCard/Views/Components/ContactFieldsManagerView.swift b/BusinessCard/Views/Shared/Components/ContactFieldsManagerView.swift
similarity index 100%
rename from BusinessCard/Views/Components/ContactFieldsManagerView.swift
rename to BusinessCard/Views/Shared/Components/ContactFieldsManagerView.swift
diff --git a/BusinessCard/Views/Shared/Components/FieldRow.swift b/BusinessCard/Views/Shared/Components/FieldRow.swift
new file mode 100644
index 0000000..42ed652
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/FieldRow.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+import Bedrock
+
+/// A display row for a contact field - tap to edit, hold to drag
+struct FieldRow: View {
+ let field: AddedContactField
+ let themeColor: Color
+ let onTap: () -> Void
+ let onDelete: () -> Void
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: "line.3.horizontal")
+ .typography(.caption)
+ .foregroundStyle(Color.Text.tertiary)
+ .frame(width: Design.Spacing.large)
+
+ Circle()
+ .fill(themeColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ field.fieldType.iconImage()
+ .typography(.title3)
+ .foregroundStyle(.white)
+ )
+
+ Button(action: onTap) {
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.shortDisplayValue)
+ .typography(.subheading)
+ .foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
+ .lineLimit(1)
+
+ Text(field.title.isEmpty ? field.fieldType.displayName : field.title)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+
+ Button(action: onDelete) {
+ Image(systemName: "xmark.circle.fill")
+ .typography(.title3)
+ .foregroundStyle(Color.Text.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Delete"))
+ .accessibilityHint(String(localized: "Removes this field"))
+ }
+ .padding(Design.Spacing.medium)
+ .contentShape(.rect)
+ }
+}
diff --git a/BusinessCard/Views/Shared/Components/FieldRowPreview.swift b/BusinessCard/Views/Shared/Components/FieldRowPreview.swift
new file mode 100644
index 0000000..4255202
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/FieldRowPreview.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+import Bedrock
+
+/// Preview shown while dragging a field
+struct FieldRowPreview: View {
+ let field: AddedContactField
+ let themeColor: Color
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.medium) {
+ Circle()
+ .fill(themeColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ field.fieldType.iconImage()
+ .typography(.title3)
+ .foregroundStyle(.white)
+ )
+
+ VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
+ Text(field.value.isEmpty ? field.fieldType.displayName : field.shortDisplayValue)
+ .typography(.subheading)
+ .foregroundStyle(Color.Text.primary)
+ .lineLimit(1)
+
+ if !field.title.isEmpty {
+ Text(field.title)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(Design.Spacing.medium)
+ .background(Color.AppBackground.elevated)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ .shadow(radius: Design.Shadow.radiusMedium)
+ }
+}
diff --git a/BusinessCard/Views/Shared/Components/FieldTypeButton.swift b/BusinessCard/Views/Shared/Components/FieldTypeButton.swift
new file mode 100644
index 0000000..c38f735
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/FieldTypeButton.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+import Bedrock
+
+struct FieldTypeButton: View {
+ let fieldType: ContactFieldType
+ let themeColor: Color
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: Design.Spacing.small) {
+ Circle()
+ .fill(themeColor)
+ .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
+ .overlay(
+ fieldType.iconImage()
+ .typography(.title3)
+ .foregroundStyle(.white)
+ )
+
+ Text(fieldType.displayName)
+ .typography(.caption)
+ .foregroundStyle(Color.Text.primary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .frame(height: Design.Spacing.xLarge * 2)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(fieldType.displayName)
+ }
+}
diff --git a/BusinessCard/Views/Shared/Components/HeaderLayoutPickerView.swift b/BusinessCard/Views/Shared/Components/HeaderLayoutPickerView.swift
new file mode 100644
index 0000000..fb672a5
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/HeaderLayoutPickerView.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+import Bedrock
+
+/// A sheet that displays header layout options as a live preview carousel.
+struct HeaderLayoutPickerView: View {
+ @Environment(\.dismiss) private var dismiss
+
+ @Binding var selectedLayout: CardHeaderLayout
+ let photoData: Data?
+ let coverPhotoData: Data?
+ let logoData: Data?
+ let avatarSystemName: String
+ let theme: CardTheme
+ let displayName: String
+ let role: String
+ let company: String
+
+ @State private var currentLayout: CardHeaderLayout
+
+ init(
+ selectedLayout: Binding,
+ photoData: Data?,
+ coverPhotoData: Data?,
+ logoData: Data?,
+ avatarSystemName: String,
+ theme: CardTheme,
+ displayName: String,
+ role: String,
+ company: String
+ ) {
+ self._selectedLayout = selectedLayout
+ self.photoData = photoData
+ self.coverPhotoData = coverPhotoData
+ self.logoData = logoData
+ self.avatarSystemName = avatarSystemName
+ self.theme = theme
+ self.displayName = displayName
+ self.role = role
+ self.company = company
+ self._currentLayout = State(initialValue: selectedLayout.wrappedValue)
+ }
+
+ private var suggestedLayout: CardHeaderLayout {
+ CardHeaderLayout.suggested(
+ hasProfile: photoData != nil,
+ hasCover: coverPhotoData != nil,
+ hasLogo: logoData != nil
+ )
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ ScrollViewReader { proxy in
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: Design.Spacing.large) {
+ ForEach(CardHeaderLayout.allCases) { layout in
+ LayoutPreviewCard(
+ layout: layout,
+ isSelected: currentLayout == layout,
+ isSuggested: layout == suggestedLayout,
+ photoData: photoData,
+ coverPhotoData: coverPhotoData,
+ logoData: logoData,
+ avatarSystemName: avatarSystemName,
+ theme: theme
+ ) {
+ withAnimation(.snappy(duration: Design.Animation.quick)) {
+ currentLayout = layout
+ }
+ }
+ .id(layout)
+ }
+ }
+ .padding(.horizontal, Design.Spacing.xLarge)
+ .padding(.vertical, Design.Spacing.large)
+ }
+ .onAppear {
+ proxy.scrollTo(currentLayout, anchor: .center)
+ }
+ }
+ .scrollClipDisabled()
+
+ Spacer()
+
+ Button {
+ selectedLayout = currentLayout
+ dismiss()
+ } label: {
+ Text("Confirm layout")
+ .typography(.heading)
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Design.Spacing.large)
+ .background(.black)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ }
+ .padding(.horizontal, Design.Spacing.xLarge)
+ .padding(.bottom, Design.Spacing.xLarge)
+ }
+ .background(Color.AppBackground.base)
+ .navigationTitle("Choose a layout")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark")
+ .typography(.body)
+ .foregroundStyle(Color.Text.primary)
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ @Previewable @State var selectedLayout: CardHeaderLayout = .profileBanner
+
+ HeaderLayoutPickerView(
+ selectedLayout: $selectedLayout,
+ photoData: nil,
+ coverPhotoData: nil,
+ logoData: nil,
+ avatarSystemName: "person.crop.circle",
+ theme: .coral,
+ displayName: "John Doe",
+ role: "Developer",
+ company: "Acme Inc"
+ )
+}
diff --git a/BusinessCard/Views/Components/IconRowView.swift b/BusinessCard/Views/Shared/Components/IconRowView.swift
similarity index 100%
rename from BusinessCard/Views/Components/IconRowView.swift
rename to BusinessCard/Views/Shared/Components/IconRowView.swift
diff --git a/BusinessCard/Views/Shared/Components/ImageEditorFlow.swift b/BusinessCard/Views/Shared/Components/ImageEditorFlow.swift
new file mode 100644
index 0000000..20f3f8b
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/ImageEditorFlow.swift
@@ -0,0 +1,145 @@
+import SwiftUI
+import PhotosUI
+import Bedrock
+
+/// A self-contained image editor flow.
+/// Shows source picker first, then presents photo picker or camera as a full-screen cover.
+/// The aspect ratio is determined by the imageType.
+/// For logos, an additional LogoEditorSheet is shown after cropping.
+struct ImageEditorFlow: View {
+ @Environment(\.dismiss) private var dismiss
+
+ let imageType: CardEditorView.ImageType
+ let hasExistingImage: Bool
+ let onComplete: (Data?) -> Void // nil = cancelled/no change, Data = final cropped image
+
+ private enum NextAction {
+ case library
+ case camera
+ }
+
+ @State private var nextAction: NextAction?
+ @State private var showingFullScreenPicker = false
+ @State private var showingFullScreenCamera = false
+
+ private var aspectRatio: CropAspectRatio {
+ imageType.cropAspectRatio
+ }
+
+ /// Only allow aspect ratio selection for logos
+ private var allowAspectRatioSelection: Bool {
+ imageType == .logo
+ }
+
+ /// Whether this is a logo image (needs extra editing step)
+ private var isLogoImage: Bool {
+ imageType == .logo
+ }
+
+ var body: some View {
+ // Source picker is the base content of this sheet
+ sourcePickerView
+ .fullScreenCover(isPresented: $showingFullScreenPicker) {
+ PhotoPickerFlow(
+ aspectRatio: aspectRatio,
+ allowAspectRatioSelection: allowAspectRatioSelection,
+ isLogoImage: isLogoImage,
+ onComplete: { imageData in
+ showingFullScreenPicker = false
+ if let imageData {
+ onComplete(imageData)
+ }
+ // If nil, we stay on source picker
+ }
+ )
+ }
+ .fullScreenCover(isPresented: $showingFullScreenCamera) {
+ CameraFlow(
+ aspectRatio: aspectRatio,
+ allowAspectRatioSelection: allowAspectRatioSelection,
+ isLogoImage: isLogoImage,
+ onComplete: { imageData in
+ showingFullScreenCamera = false
+ if let imageData {
+ onComplete(imageData)
+ }
+ // If nil, we stay on source picker
+ }
+ )
+ }
+ }
+
+ // MARK: - Source Picker
+
+ private var sourcePickerView: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ VStack(spacing: 0) {
+ OptionRow(
+ icon: "photo.on.rectangle",
+ title: String.localized("Select from photo library")
+ ) {
+ showingFullScreenPicker = true
+ }
+
+ Divider()
+ .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
+
+ OptionRow(
+ icon: "camera",
+ title: String.localized("Take photo")
+ ) {
+ showingFullScreenCamera = true
+ }
+
+ if hasExistingImage {
+ Divider()
+ .padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
+
+ OptionRow(
+ icon: "trash",
+ title: String.localized("Remove photo"),
+ isDestructive: true
+ ) {
+ onComplete(Data())
+ }
+ }
+ }
+ .background(Color.AppBackground.card)
+ .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.top, Design.Spacing.medium)
+
+ Spacer()
+ }
+ .background(Color.AppBackground.secondary)
+ .navigationTitle(imageType.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ onComplete(nil)
+ } label: {
+ Image(systemName: "xmark")
+ .typography(.bodyEmphasis)
+ .foregroundStyle(Color.Text.primary)
+ }
+ }
+ }
+ }
+ .presentationDetents([.height(CGFloat((hasExistingImage ? 3 : 2) * 56 + 100))])
+ .presentationDragIndicator(.visible)
+ }
+}
+
+#Preview {
+ Text("Tap to edit")
+ .sheet(isPresented: .constant(true)) {
+ ImageEditorFlow(
+ imageType: .profile,
+ hasExistingImage: false
+ ) { data in
+ Design.debugLog(data != nil ? "Got image" : "Cancelled")
+ }
+ }
+}
diff --git a/BusinessCard/Views/Components/LabelBadgeView.swift b/BusinessCard/Views/Shared/Components/LabelBadgeView.swift
similarity index 100%
rename from BusinessCard/Views/Components/LabelBadgeView.swift
rename to BusinessCard/Views/Shared/Components/LabelBadgeView.swift
diff --git a/BusinessCard/Views/Shared/Components/LayoutBadge.swift b/BusinessCard/Views/Shared/Components/LayoutBadge.swift
new file mode 100644
index 0000000..523790b
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/LayoutBadge.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+import Bedrock
+
+struct LayoutBadge: View {
+ let text: String
+ let iconName: String
+ let backgroundColor: Color
+
+ var body: some View {
+ HStack(spacing: Design.Spacing.xSmall) {
+ Image(systemName: iconName)
+ .typography(.caption2)
+ Text(text)
+ .typography(.caption2)
+ .bold()
+ }
+ .padding(.horizontal, Design.Spacing.small)
+ .padding(.vertical, Design.Spacing.xSmall)
+ .background(backgroundColor)
+ .foregroundStyle(.white)
+ .clipShape(.capsule)
+ }
+}
diff --git a/BusinessCard/Views/Components/HeaderLayoutPickerView.swift b/BusinessCard/Views/Shared/Components/LayoutPreviewCard.swift
similarity index 57%
rename from BusinessCard/Views/Components/HeaderLayoutPickerView.swift
rename to BusinessCard/Views/Shared/Components/LayoutPreviewCard.swift
index e0583b7..47e4d5c 100644
--- a/BusinessCard/Views/Components/HeaderLayoutPickerView.swift
+++ b/BusinessCard/Views/Shared/Components/LayoutPreviewCard.swift
@@ -1,124 +1,7 @@
import SwiftUI
import Bedrock
-/// A sheet that displays header layout options as a live preview carousel.
-struct HeaderLayoutPickerView: View {
- @Environment(\.dismiss) private var dismiss
-
- @Binding var selectedLayout: CardHeaderLayout
- let photoData: Data?
- let coverPhotoData: Data?
- let logoData: Data?
- let avatarSystemName: String
- let theme: CardTheme
- let displayName: String
- let role: String
- let company: String
-
- @State private var currentLayout: CardHeaderLayout
-
- init(
- selectedLayout: Binding,
- photoData: Data?,
- coverPhotoData: Data?,
- logoData: Data?,
- avatarSystemName: String,
- theme: CardTheme,
- displayName: String,
- role: String,
- company: String
- ) {
- self._selectedLayout = selectedLayout
- self.photoData = photoData
- self.coverPhotoData = coverPhotoData
- self.logoData = logoData
- self.avatarSystemName = avatarSystemName
- self.theme = theme
- self.displayName = displayName
- self.role = role
- self.company = company
- self._currentLayout = State(initialValue: selectedLayout.wrappedValue)
- }
-
- private var suggestedLayout: CardHeaderLayout {
- CardHeaderLayout.suggested(
- hasProfile: photoData != nil,
- hasCover: coverPhotoData != nil,
- hasLogo: logoData != nil
- )
- }
-
- var body: some View {
- NavigationStack {
- VStack(spacing: 0) {
- ScrollViewReader { proxy in
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: Design.Spacing.large) {
- ForEach(CardHeaderLayout.allCases) { layout in
- LayoutPreviewCard(
- layout: layout,
- isSelected: currentLayout == layout,
- isSuggested: layout == suggestedLayout,
- photoData: photoData,
- coverPhotoData: coverPhotoData,
- logoData: logoData,
- avatarSystemName: avatarSystemName,
- theme: theme
- ) {
- withAnimation(.snappy(duration: Design.Animation.quick)) {
- currentLayout = layout
- }
- }
- .id(layout)
- }
- }
- .padding(.horizontal, Design.Spacing.xLarge)
- .padding(.vertical, Design.Spacing.large)
- }
- .onAppear {
- proxy.scrollTo(currentLayout, anchor: .center)
- }
- }
- .scrollClipDisabled()
-
- Spacer()
-
- Button {
- selectedLayout = currentLayout
- dismiss()
- } label: {
- Text("Confirm layout")
- .typography(.heading)
- .foregroundStyle(.white)
- .frame(maxWidth: .infinity)
- .padding(.vertical, Design.Spacing.large)
- .background(.black)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- }
- .padding(.horizontal, Design.Spacing.xLarge)
- .padding(.bottom, Design.Spacing.xLarge)
- }
- .background(Color.AppBackground.base)
- .navigationTitle("Choose a layout")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button {
- dismiss()
- } label: {
- Image(systemName: "xmark")
- .typography(.body)
- .foregroundStyle(Color.Text.primary)
- }
- }
- }
- }
- }
-}
-
-// MARK: - Layout Preview Card
-
-private struct LayoutPreviewCard: View {
+struct LayoutPreviewCard: View {
let layout: CardHeaderLayout
let isSelected: Bool
let isSuggested: Bool
@@ -128,13 +11,13 @@ private struct LayoutPreviewCard: View {
let avatarSystemName: String
let theme: CardTheme
let onSelect: () -> Void
-
+
private let cardWidth: CGFloat = 200
private let cardHeight: CGFloat = 280
private let bannerHeight: CGFloat = 100
private let avatarSize: CGFloat = 56
- private let logoRectWidth: CGFloat = 84 // 56 * 1.5 aspect ratio
-
+ private let logoRectWidth: CGFloat = 84
+
private var needsMoreImages: Bool {
!layout.hasAllRequiredImages(
hasProfile: photoData != nil,
@@ -142,11 +25,11 @@ private struct LayoutPreviewCard: View {
hasLogo: logoData != nil
)
}
-
+
private var hasOverlappingContent: Bool {
layout.hasOverlappingContent
}
-
+
var body: some View {
Button(action: onSelect) {
VStack(spacing: 0) {
@@ -155,7 +38,7 @@ private struct LayoutPreviewCard: View {
bannerContent
.frame(height: bannerHeight)
.clipped()
-
+
contentArea
.offset(y: hasOverlappingContent ? -avatarSize / 2 : 0)
.padding(.bottom, hasOverlappingContent ? -avatarSize / 2 : 0)
@@ -168,7 +51,7 @@ private struct LayoutPreviewCard: View {
.stroke(isSelected ? theme.primaryColor : .clear, lineWidth: Design.LineWidth.thick)
)
.shadow(color: Color.Text.secondary.opacity(Design.Opacity.subtle), radius: Design.Shadow.radiusMedium, y: Design.Shadow.offsetMedium)
-
+
badgeOverlay
.offset(y: -Design.Spacing.small)
}
@@ -180,9 +63,7 @@ private struct LayoutPreviewCard: View {
.accessibilityHint(layout.description)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
-
- // MARK: - Banner Content
-
+
@ViewBuilder
private var bannerContent: some View {
switch layout.bannerContent {
@@ -194,7 +75,7 @@ private struct LayoutPreviewCard: View {
coverBannerPreview
}
}
-
+
private var profileBannerPreview: some View {
ZStack {
if let photoData, let uiImage = UIImage(data: photoData) {
@@ -203,7 +84,7 @@ private struct LayoutPreviewCard: View {
.scaledToFill()
} else {
LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
-
+
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "person.fill")
.typography(.title3)
@@ -215,11 +96,11 @@ private struct LayoutPreviewCard: View {
}
}
}
-
+
private var logoBannerPreview: some View {
ZStack {
LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
-
+
if let logoData, let uiImage = UIImage(data: logoData) {
Image(uiImage: uiImage)
.resizable()
@@ -236,7 +117,7 @@ private struct LayoutPreviewCard: View {
}
}
}
-
+
private var coverBannerPreview: some View {
Group {
if let coverData = coverPhotoData, let uiImage = UIImage(data: coverData) {
@@ -246,7 +127,7 @@ private struct LayoutPreviewCard: View {
} else {
ZStack {
LinearGradient(colors: [theme.primaryColor.opacity(Design.Opacity.light), theme.secondaryColor.opacity(Design.Opacity.light)], startPoint: .topLeading, endPoint: .bottomTrailing)
-
+
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "photo.fill")
.typography(.title3)
@@ -260,9 +141,7 @@ private struct LayoutPreviewCard: View {
}
.clipped()
}
-
- // MARK: - Content Area
-
+
private var contentArea: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
contentOverlay
@@ -273,7 +152,7 @@ private struct LayoutPreviewCard: View {
.padding(.top, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.small)
}
-
+
@ViewBuilder
private var contentOverlay: some View {
switch layout.contentOverlay {
@@ -297,7 +176,7 @@ private struct LayoutPreviewCard: View {
}
}
}
-
+
private var placeholderTextLines: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
@@ -305,13 +184,13 @@ private struct LayoutPreviewCard: View {
.frame(height: Design.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xLarge)
-
+
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.frame(height: Design.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, Design.Spacing.xxLarge)
-
+
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Text.tertiary.opacity(Design.Opacity.subtle))
.frame(height: Design.Spacing.small)
@@ -319,9 +198,7 @@ private struct LayoutPreviewCard: View {
.padding(.trailing, Design.Spacing.xLarge)
}
}
-
- // MARK: - Overlay Components
-
+
private var profileAvatar: some View {
Group {
if let photoData, let uiImage = UIImage(data: photoData) {
@@ -340,31 +217,7 @@ private struct LayoutPreviewCard: View {
.clipShape(.circle)
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
}
-
- private var logoBadge: some View {
- Group {
- if let logoData, let uiImage = UIImage(data: logoData) {
- Image(uiImage: uiImage)
- .resizable()
- .scaledToFill()
- .clipped()
- } else {
- VStack(spacing: Design.Spacing.xxSmall) {
- Image(systemName: "building.2")
- .typography(.caption)
- Text("Logo")
- .typography(.caption2)
- }
- .foregroundStyle(theme.textColor)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- }
- .frame(width: avatarSize, height: avatarSize)
- .background(theme.accentColor)
- .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
- .overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
- }
-
+
private var logoRectangle: some View {
Group {
if let logoData, let uiImage = UIImage(data: logoData) {
@@ -388,9 +241,7 @@ private struct LayoutPreviewCard: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.overlay(RoundedRectangle(cornerRadius: Design.CornerRadius.medium).stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium))
}
-
- // MARK: - Badges
-
+
@ViewBuilder
private var badgeOverlay: some View {
if needsMoreImages {
@@ -400,44 +251,3 @@ private struct LayoutPreviewCard: View {
}
}
}
-
-// MARK: - Layout Badge
-
-private struct LayoutBadge: View {
- let text: String
- let iconName: String
- let backgroundColor: Color
-
- var body: some View {
- HStack(spacing: Design.Spacing.xSmall) {
- Image(systemName: iconName)
- .typography(.caption2)
- Text(text)
- .typography(.caption2)
- .bold()
- }
- .padding(.horizontal, Design.Spacing.small)
- .padding(.vertical, Design.Spacing.xSmall)
- .background(backgroundColor)
- .foregroundStyle(.white)
- .clipShape(.capsule)
- }
-}
-
-// MARK: - Preview
-
-#Preview {
- @Previewable @State var selectedLayout: CardHeaderLayout = .profileBanner
-
- HeaderLayoutPickerView(
- selectedLayout: $selectedLayout,
- photoData: nil,
- coverPhotoData: nil,
- logoData: nil,
- avatarSystemName: "person.crop.circle",
- theme: .coral,
- displayName: "John Doe",
- role: "Developer",
- company: "Acme Inc"
- )
-}
diff --git a/BusinessCard/Views/Shared/Components/OptionRow.swift b/BusinessCard/Views/Shared/Components/OptionRow.swift
new file mode 100644
index 0000000..3e9e819
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/OptionRow.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import Bedrock
+
+struct OptionRow: View {
+ let icon: String
+ let title: String
+ var isDestructive: Bool = false
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: icon)
+ .typography(.body)
+ .foregroundStyle(isDestructive ? Color.red : Color.Text.secondary)
+ .frame(width: Design.CardSize.socialIconSize)
+
+ Text(title)
+ .typography(.body)
+ .foregroundStyle(isDestructive ? Color.red : Color.Text.primary)
+
+ Spacer()
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/BusinessCard/Views/Shared/Components/PhotoPickerFlow.swift b/BusinessCard/Views/Shared/Components/PhotoPickerFlow.swift
new file mode 100644
index 0000000..ec3316f
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/PhotoPickerFlow.swift
@@ -0,0 +1,92 @@
+import SwiftUI
+import PhotosUI
+import Bedrock
+
+struct PhotoPickerFlow: View {
+ let aspectRatio: CropAspectRatio
+ let allowAspectRatioSelection: Bool
+ let isLogoImage: Bool
+ let onComplete: (Data?) -> Void
+
+ @State private var selectedPhotoItem: PhotosPickerItem?
+ @State private var imageData: Data?
+ @State private var showingCropper = false
+ @State private var showingLogoEditor = false
+ @State private var croppedLogoImage: UIImage?
+ @State private var pickerID = UUID()
+
+ var body: some View {
+ NavigationStack {
+ PhotosPicker(
+ selection: $selectedPhotoItem,
+ matching: .images,
+ photoLibrary: .shared()
+ ) {
+ EmptyView()
+ }
+ .photosPickerStyle(.inline)
+ .photosPickerDisabledCapabilities([.selectionActions])
+ .ignoresSafeArea()
+ .id(pickerID)
+ .onChange(of: selectedPhotoItem) { _, newValue in
+ guard let newValue else { return }
+ Task { @MainActor in
+ if let data = try? await newValue.loadTransferable(type: Data.self) {
+ imageData = data
+ showingCropper = true
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(String.localized("Cancel")) {
+ onComplete(nil)
+ }
+ }
+ }
+ }
+ .overlay {
+ if showingCropper, let imageData {
+ PhotoCropperSheet(
+ imageData: imageData,
+ aspectRatio: aspectRatio,
+ allowAspectRatioSelection: allowAspectRatioSelection,
+ shouldDismissOnComplete: false
+ ) { croppedData in
+ if let croppedData {
+ if isLogoImage, let uiImage = UIImage(data: croppedData) {
+ croppedLogoImage = uiImage
+ showingCropper = false
+ showingLogoEditor = true
+ } else {
+ onComplete(croppedData)
+ }
+ } else {
+ showingCropper = false
+ self.imageData = nil
+ self.selectedPhotoItem = nil
+ pickerID = UUID()
+ }
+ }
+ .transition(.move(edge: .trailing))
+ }
+
+ if showingLogoEditor, let logoImage = croppedLogoImage {
+ LogoEditorSheet(logoImage: logoImage) { finalData in
+ if let finalData {
+ onComplete(finalData)
+ } else {
+ showingLogoEditor = false
+ croppedLogoImage = nil
+ self.imageData = nil
+ self.selectedPhotoItem = nil
+ pickerID = UUID()
+ }
+ }
+ .transition(.move(edge: .trailing))
+ }
+ }
+ .animation(.easeInOut(duration: Design.Animation.quick), value: showingCropper)
+ .animation(.easeInOut(duration: Design.Animation.quick), value: showingLogoEditor)
+ }
+}
diff --git a/BusinessCard/Views/Components/PhotoPickerWithCropper.swift b/BusinessCard/Views/Shared/Components/PhotoPickerWithCropper.swift
similarity index 100%
rename from BusinessCard/Views/Components/PhotoPickerWithCropper.swift
rename to BusinessCard/Views/Shared/Components/PhotoPickerWithCropper.swift
diff --git a/BusinessCard/Views/Shared/Components/PhotoSourceOption+Presets.swift b/BusinessCard/Views/Shared/Components/PhotoSourceOption+Presets.swift
new file mode 100644
index 0000000..2b7f28c
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/PhotoSourceOption+Presets.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+extension PhotoSourceOption {
+ static let useIcon = PhotoSourceOption(
+ icon: "person.crop.circle",
+ title: String.localized("Use icon instead"),
+ action: "useIcon"
+ )
+
+ static let stockPhotos = PhotoSourceOption(
+ icon: "photo.stack",
+ title: String.localized("Choose from stock photos"),
+ action: "stockPhotos"
+ )
+
+ static let importFromFiles = PhotoSourceOption(
+ icon: "folder",
+ title: String.localized("Import from Files"),
+ action: "importFromFiles"
+ )
+}
diff --git a/BusinessCard/Views/Shared/Components/PhotoSourceOption.swift b/BusinessCard/Views/Shared/Components/PhotoSourceOption.swift
new file mode 100644
index 0000000..545d68d
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/PhotoSourceOption.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+/// A custom option that can be added to the photo source picker.
+struct PhotoSourceOption: Identifiable {
+ let id = UUID()
+ let icon: String
+ let title: String
+ let action: String
+
+ init(icon: String, title: String, action: String) {
+ self.icon = icon
+ self.title = title
+ self.action = action
+ }
+}
diff --git a/BusinessCard/Views/Components/PhotoSourcePicker.swift b/BusinessCard/Views/Shared/Components/PhotoSourcePicker.swift
similarity index 70%
rename from BusinessCard/Views/Components/PhotoSourcePicker.swift
rename to BusinessCard/Views/Shared/Components/PhotoSourcePicker.swift
index 8a49e97..36f335b 100644
--- a/BusinessCard/Views/Components/PhotoSourcePicker.swift
+++ b/BusinessCard/Views/Shared/Components/PhotoSourcePicker.swift
@@ -47,7 +47,7 @@ struct PhotoSourcePicker: View {
// Options list
VStack(spacing: 0) {
// Select from photo library
- OptionRow(
+ PhotoSourcePickerOptionRow(
icon: "photo.on.rectangle",
title: String.localized("Select from photo library"),
action: {
@@ -60,7 +60,7 @@ struct PhotoSourcePicker: View {
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
// Take photo
- OptionRow(
+ PhotoSourcePickerOptionRow(
icon: "camera",
title: String.localized("Take photo"),
action: {
@@ -74,7 +74,7 @@ struct PhotoSourcePicker: View {
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
- OptionRow(
+ PhotoSourcePickerOptionRow(
icon: option.icon,
title: option.title,
action: {
@@ -89,7 +89,7 @@ struct PhotoSourcePicker: View {
Divider()
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.socialIconSize)
- OptionRow(
+ PhotoSourcePickerOptionRow(
icon: "trash",
title: String.localized("Remove photo"),
isDestructive: true,
@@ -127,77 +127,6 @@ struct PhotoSourcePicker: View {
}
}
-// MARK: - Option Row
-
-private struct OptionRow: View {
- let icon: String
- let title: String
- var isDestructive: Bool = false
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: Design.Spacing.medium) {
- Image(systemName: icon)
- .typography(.body)
- .foregroundStyle(isDestructive ? Color.red : Color.Text.secondary)
- .frame(width: Design.CardSize.socialIconSize)
-
- Text(title)
- .typography(.body)
- .foregroundStyle(isDestructive ? Color.red : Color.Text.primary)
-
- Spacer()
- }
- .padding(.horizontal, Design.Spacing.large)
- .padding(.vertical, Design.Spacing.medium)
- .contentShape(.rect)
- }
- .buttonStyle(.plain)
- }
-}
-
-// MARK: - Photo Source Option
-
-/// A custom option that can be added to the photo source picker.
-struct PhotoSourceOption: Identifiable {
- let id = UUID()
- let icon: String
- let title: String
- let action: String // Identifier for handling the action
-
- init(icon: String, title: String, action: String) {
- self.icon = icon
- self.title = title
- self.action = action
- }
-}
-
-// MARK: - Common Option Presets
-
-extension PhotoSourceOption {
- /// Option to use a default avatar icon
- static let useIcon = PhotoSourceOption(
- icon: "person.crop.circle",
- title: String.localized("Use icon instead"),
- action: "useIcon"
- )
-
- /// Option to choose from stock photos
- static let stockPhotos = PhotoSourceOption(
- icon: "photo.stack",
- title: String.localized("Choose from stock photos"),
- action: "stockPhotos"
- )
-
- /// Option to import from Files
- static let importFromFiles = PhotoSourceOption(
- icon: "folder",
- title: String.localized("Import from Files"),
- action: "importFromFiles"
- )
-}
-
// MARK: - Preview
#Preview {
diff --git a/BusinessCard/Views/Shared/Components/PhotoSourcePickerOptionRow.swift b/BusinessCard/Views/Shared/Components/PhotoSourcePickerOptionRow.swift
new file mode 100644
index 0000000..b88f7ea
--- /dev/null
+++ b/BusinessCard/Views/Shared/Components/PhotoSourcePickerOptionRow.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import Bedrock
+
+struct PhotoSourcePickerOptionRow: View {
+ let icon: String
+ let title: String
+ var isDestructive: Bool = false
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: Design.Spacing.medium) {
+ Image(systemName: icon)
+ .typography(.body)
+ .foregroundStyle(isDestructive ? Color.red : Color.Text.secondary)
+ .frame(width: Design.CardSize.socialIconSize)
+
+ Text(title)
+ .typography(.body)
+ .foregroundStyle(isDestructive ? Color.red : Color.Text.primary)
+
+ Spacer()
+ }
+ .padding(.horizontal, Design.Spacing.large)
+ .padding(.vertical, Design.Spacing.medium)
+ .contentShape(.rect)
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/BusinessCard/Views/EmptyStateView.swift b/BusinessCard/Views/Shared/EmptyStateView.swift
similarity index 100%
rename from BusinessCard/Views/EmptyStateView.swift
rename to BusinessCard/Views/Shared/EmptyStateView.swift
diff --git a/BusinessCard/Views/PrimaryActionButton.swift b/BusinessCard/Views/Shared/PrimaryActionButton.swift
similarity index 100%
rename from BusinessCard/Views/PrimaryActionButton.swift
rename to BusinessCard/Views/Shared/PrimaryActionButton.swift