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

This commit is contained in:
Matt Bruce 2026-02-11 08:25:22 -06:00
parent f230aeb0d9
commit fe6a2c5943
123 changed files with 2934 additions and 2803 deletions

View File

@ -70,7 +70,7 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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; }; 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; }; 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; }; EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@ -641,7 +641,6 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -679,7 +678,6 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;

View File

@ -3,4 +3,22 @@
uuid = "6FB169DC-E619-40A8-968F-910EF3CF4FA4" uuid = "6FB169DC-E619-40A8-968F-910EF3CF4FA4"
type = "1" type = "1"
version = "2.0"> version = "2.0">
<Breakpoints>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "2110CD86-F605-4B59-B1D9-8B40876A800D"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "BusinessCard/Services/BundleAppMetadataProvider.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "11"
endingLineNumber = "11"
landmarkName = "appName"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket> </Bucket>

View File

@ -12,12 +12,12 @@
<key>BusinessCardClip.xcscheme_^#shared#^_</key> <key>BusinessCardClip.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>1</integer>
</dict> </dict>
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key> <key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>3</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -13,6 +13,13 @@ enum AppIdentifiers {
// MARK: - Runtime Identifiers (read from Info.plist) // 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. /// App Group identifier for sharing data between app and extensions.
static let appGroupIdentifier: String = { static let appGroupIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 SwiftUI
import Bedrock import Bedrock
// MARK: - Surface Colors /// BusinessCard's complete color theme wrapper.
/// 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.
public enum BusinessCardTheme { public enum BusinessCardTheme {
public typealias Surface = BusinessCardSurfaceColors public typealias Surface = BusinessCardSurfaceColors
public typealias Text = BusinessCardTextColors public typealias Text = BusinessCardTextColors
@ -117,16 +12,7 @@ public enum BusinessCardTheme {
public typealias Interactive = BusinessCardInteractiveColors public typealias Interactive = BusinessCardInteractiveColors
} }
// MARK: - Convenience Typealiases
/// Short typealiases for cleaner usage throughout the app. /// 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 AppSurface = BusinessCardSurfaceColors
typealias AppThemeText = BusinessCardTextColors typealias AppThemeText = BusinessCardTextColors
typealias AppThemeAccent = BusinessCardAccentColors typealias AppThemeAccent = BusinessCardAccentColors

View File

@ -197,17 +197,6 @@ extension Color {
typealias Text = AppText 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 { extension View {
/// Adds standard iOS keyboard dismissal behavior: /// Adds standard iOS keyboard dismissal behavior:
/// - interactive scroll-to-dismiss /// - interactive scroll-to-dismiss

View File

@ -0,0 +1,10 @@
import SwiftUI
struct KeyboardDismissModifier: ViewModifier {
func body(content: Content) -> some View {
content
.autocorrectionDisabled(true)
.textInputAutocapitalization(.sentences)
.scrollDismissesKeyboard(.interactively)
}
}

View File

@ -1,45 +1,6 @@
import Foundation import Foundation
import SwiftData 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 @Model
final class AppSettings { final class AppSettings {
var id: UUID var id: UUID

View File

@ -0,0 +1,8 @@
import Foundation
/// What fills the banner area of the card header.
enum BannerContentType: Sendable {
case profile
case logo
case cover
}

View File

@ -1,31 +1,5 @@
import Foundation 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 // MARK: - Card Header Layout
/// Defines how the business card header arranges profile, cover, and logo images. /// Defines how the business card header arranges profile, cover, and logo images.

View File

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

View File

@ -1,30 +1,5 @@
import SwiftUI 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 /// Defines a contact field type with all its configuration
struct ContactFieldType: Identifiable, Hashable, Sendable { struct ContactFieldType: Identifiable, Hashable, Sendable {
let id: String let id: String
@ -715,132 +690,3 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
} }
// MARK: - Phone Text Utilities // 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..<next]))
index = next
}
return groups.joined(separator: " ")
}
}
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
}
}
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
}
}

View File

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

View File

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

View File

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

View File

@ -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..<next]))
index = next
}
return groups.joined(separator: " ")
}
}

View File

@ -0,0 +1,15 @@
import Foundation
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"
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
/// 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?
}

View File

@ -0,0 +1,29 @@
import Foundation
enum WebLinkText {
nonisolated static func normalizedForStorage(_ value: String) -> 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)")
}
}

View File

@ -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" : { "Create your first card" : {
}, },
@ -416,10 +420,6 @@
}, },
"Maiden Name" : { "Maiden Name" : {
},
"Matt Bruce" : {
"comment" : "The name of the developer of the app.",
"isCommentAutoGenerated" : true
}, },
"Messaging" : { "Messaging" : {
@ -429,6 +429,10 @@
}, },
"More..." : { "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" : { "No card selected" : {
@ -438,6 +442,10 @@
}, },
"Notes" : { "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" : { "Open on Apple Watch" : {
"localizations" : { "localizations" : {
@ -592,6 +600,13 @@
}, },
"Removes this field" : { "Removes this field" : {
},
"Reset" : {
"comment" : "The text on a button that resets onboarding for a user.",
"isCommentAutoGenerated" : true
},
"Reset Onboarding" : {
}, },
"Save" : { "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" : { "Social Media" : {
}, },
@ -933,6 +952,10 @@
}, },
"Website URL" : { "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" : { "Widgets on iPhone and Watch" : {
"comment" : "A title for a view that showcases widgets for iPhone and watch faces.", "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" : { "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" : { "Your Contact Fields" : {

View File

@ -8,9 +8,7 @@ struct BundleAppMetadataProvider: AppMetadataProviding {
} }
var appName: String { var appName: String {
bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String AppIdentifiers.publicAppName
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "App"
} }
var appVersion: String { var appVersion: String {

View File

@ -91,29 +91,3 @@ struct SharedCardCloudKitService: SharedCardProviding {
return fileURL 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")
}
}
}

View File

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

View File

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

View File

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

View File

@ -3,24 +3,11 @@ import Observation
import SwiftData import SwiftData
import SwiftUI 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 @Observable
@MainActor @MainActor
final class AppState { final class AppState {
var selectedTab: AppTab = .cards var selectedTab: AppTab = .cards
var shouldPresentCreateCardFlow = false
var cardStore: CardStore var cardStore: CardStore
var contactsStore: ContactsStore var contactsStore: ContactsStore
let preferences: AppPreferencesStore let preferences: AppPreferencesStore

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import SwiftData
struct RootTabView: View { struct RootTabView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@Environment(\.scenePhase) private var scenePhase
@State private var showingShareSheet = false @State private var showingShareSheet = false
@State private var showingOnboarding = false @State private var showingOnboarding = false
@ -43,40 +44,26 @@ struct RootTabView: View {
} }
} }
.onAppear { .onAppear {
if !appState.preferences.hasCompletedOnboarding { updateOnboardingPresentation()
}
.onChange(of: appState.preferences.hasCompletedOnboarding) { _, _ in
updateOnboardingPresentation()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
updateOnboardingPresentation()
}
}
}
private func updateOnboardingPresentation() {
if appState.preferences.hasCompletedOnboarding {
showingOnboarding = false
} else if !showingOnboarding {
showingOnboarding = true showingOnboarding = true
} }
} }
} }
}
// 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
)
)
}
.accessibilityLabel(String.localized("Share"))
.accessibilityHint(String.localized("Opens the share sheet to send your card"))
}
}
#Preview { #Preview {
RootTabView() RootTabView()

View File

@ -80,64 +80,19 @@ struct CardsHomeView: View {
} message: { } message: {
Text("Are you sure you want to delete this card? This action cannot be undone.") 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 func presentPendingCreateCardFlowIfNeeded() {
guard appState.shouldPresentCreateCardFlow else { return }
private struct CardPageView: View { appState.shouldPresentCreateCardFlow = false
let card: BusinessCard showingCreateCard = true
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()
}
} }
} }

View File

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

View File

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

View File

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

View File

@ -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") { #Preview("Add Email") {
ContactFieldEditorSheet(fieldType: .email) { value, title in ContactFieldEditorSheet(fieldType: .email) { value, title in
Design.debugLog("Saved: \(value), \(title)") Design.debugLog("Saved: \(value), \(title)")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ struct LogoEditorSheet: View {
extractSuggestedColors() extractSuggestedColors()
} }
.sheet(isPresented: $showingColorPicker) { .sheet(isPresented: $showingColorPicker) {
CustomColorPickerSheet(initialColor: customColor) { selectedColor in LogoCustomColorPickerSheet(initialColor: customColor) { selectedColor in
customColor = selectedColor customColor = selectedColor
backgroundColor = 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 // MARK: - Preview
#Preview { #Preview {

View File

@ -1,69 +1,6 @@
import SwiftUI import SwiftUI
import Bedrock 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. /// A sheet that allows the user to crop an image.
/// Supports pinch-to-zoom and drag gestures for positioning. /// Supports pinch-to-zoom and drag gestures for positioning.
/// Use `aspectRatio` to specify square, banner, or custom crop shapes. /// 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 // MARK: - Preview
#Preview { #Preview {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 { #Preview {
AddContactSheet() AddContactSheet()
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext)) .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))

View File

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

View File

@ -0,0 +1,7 @@
import Foundation
struct LabeledEntry: Identifiable {
let id = UUID()
var label: String
var value: String
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ struct SettingsView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var settingsState = SettingsState() @State private var settingsState = SettingsState()
@State private var showingResetOnboardingConfirmation = false @State private var showingResetOnboardingConfirmation = false
private let appName = BundleAppMetadataProvider().appName private let appName = AppIdentifiers.publicAppName
var body: some View { var body: some View {
NavigationStack { NavigationStack {

View File

@ -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<String?>) {
_scannedCode = scannedCode
}
func didScanCode(_ code: String) {
Task { @MainActor in
scannedCode = code
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import Foundation
protocol QRScannerViewControllerDelegate: AnyObject {
func didScanCode(_ code: String)
}

View File

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

View File

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

View File

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

View File

@ -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<String?>) {
_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)")
}
}

View File

@ -1,32 +1,6 @@
import SwiftUI import SwiftUI
import Bedrock import Bedrock
/// A generic action row with icon, title, optional subtitle, and chevron.
/// Used for share options, settings rows, and navigation items.
struct ActionRowView<Action: View>: 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. /// Content layout for action rows - icon, text, chevron.
struct ActionRowContent: View { struct ActionRowContent: View {
let title: String let title: String
@ -69,20 +43,3 @@ struct ActionRowContent: View {
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .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)
}

View File

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

View File

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

View File

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

View File

@ -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 // MARK: - Preview
#Preview { #Preview {

View File

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

Some files were not shown because too many files have changed in this diff Show More