Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
f230aeb0d9
commit
fe6a2c5943
@ -70,7 +70,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EA8379232F105F2600077F87 /* BusinessCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA8379232F105F2600077F87 /* Business Card.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Business Card.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "BusinessCardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@ -188,7 +188,7 @@
|
||||
EA8379242F105F2600077F87 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EA8379232F105F2600077F87 /* BusinessCard.app */,
|
||||
EA8379232F105F2600077F87 /* Business Card.app */,
|
||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
||||
EA837F982F11B16400077F87 /* BusinessCardWatch Watch App.app */,
|
||||
@ -241,7 +241,7 @@
|
||||
EA69DC812F3C199C00592220 /* Bedrock */,
|
||||
);
|
||||
productName = BusinessCard;
|
||||
productReference = EA8379232F105F2600077F87 /* BusinessCard.app */;
|
||||
productReference = EA8379232F105F2600077F87 /* Business Card.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EA83792F2F105F2800077F87 /* BusinessCardTests */ = {
|
||||
@ -641,7 +641,6 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
@ -679,7 +678,6 @@
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
|
||||
@ -3,4 +3,22 @@
|
||||
uuid = "6FB169DC-E619-40A8-968F-910EF3CF4FA4"
|
||||
type = "1"
|
||||
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>
|
||||
|
||||
@ -12,12 +12,12 @@
|
||||
<key>BusinessCardClip.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>BusinessCardWatch Watch App.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@ -13,6 +13,13 @@ enum AppIdentifiers {
|
||||
|
||||
// MARK: - Runtime Identifiers (read from Info.plist)
|
||||
|
||||
/// Public app name for user-facing UI text.
|
||||
static let publicAppName: String = {
|
||||
(Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
|
||||
?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String)
|
||||
?? "App"
|
||||
}()
|
||||
|
||||
/// App Group identifier for sharing data between app and extensions.
|
||||
static let appGroupIdentifier: String = {
|
||||
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
|
||||
|
||||
9
BusinessCard/Design/BusinessCardAccentColors.swift
Normal file
9
BusinessCard/Design/BusinessCardAccentColors.swift
Normal 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)
|
||||
}
|
||||
9
BusinessCard/Design/BusinessCardBorderColors.swift
Normal file
9
BusinessCard/Design/BusinessCardBorderColors.swift
Normal 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)
|
||||
}
|
||||
10
BusinessCard/Design/BusinessCardButtonColors.swift
Normal file
10
BusinessCard/Design/BusinessCardButtonColors.swift
Normal 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)
|
||||
}
|
||||
9
BusinessCard/Design/BusinessCardInteractiveColors.swift
Normal file
9
BusinessCard/Design/BusinessCardInteractiveColors.swift
Normal 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
|
||||
}
|
||||
9
BusinessCard/Design/BusinessCardStatusColors.swift
Normal file
9
BusinessCard/Design/BusinessCardStatusColors.swift
Normal 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)
|
||||
}
|
||||
12
BusinessCard/Design/BusinessCardSurfaceColors.swift
Normal file
12
BusinessCard/Design/BusinessCardSurfaceColors.swift
Normal 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
|
||||
}
|
||||
11
BusinessCard/Design/BusinessCardTextColors.swift
Normal file
11
BusinessCard/Design/BusinessCardTextColors.swift
Normal 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
|
||||
}
|
||||
@ -1,112 +1,7 @@
|
||||
//
|
||||
// BusinessCardTheme.swift
|
||||
// BusinessCard
|
||||
//
|
||||
// App-specific adaptive theme conforming to Bedrock's color protocols.
|
||||
// Uses warm light colors and deep slate dark colors.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
// MARK: - Surface Colors
|
||||
|
||||
/// Surface colors with warm off-white light tones and deep slate dark tones.
|
||||
public enum BusinessCardSurfaceColors: SurfaceColorProvider {
|
||||
/// Primary background
|
||||
public static let primary = Color.AppBackground.base
|
||||
|
||||
/// Secondary/elevated surface
|
||||
public static let secondary = Color.AppBackground.secondary
|
||||
|
||||
/// Tertiary/card surface - most elevated
|
||||
public static let tertiary = Color.AppBackground.elevated
|
||||
|
||||
/// Overlay background (for sheets/modals)
|
||||
public static let overlay = Color.AppBackground.base
|
||||
|
||||
/// Card/grouped element background
|
||||
public static let card = Color.AppBackground.card
|
||||
|
||||
/// Subtle fill for grouped content sections
|
||||
public static let groupedFill = Color.AppBackground.accent
|
||||
|
||||
/// Section fill for list sections
|
||||
public static let sectionFill = Color.AppBackground.secondary
|
||||
}
|
||||
|
||||
// MARK: - Text Colors
|
||||
|
||||
public enum BusinessCardTextColors: TextColorProvider {
|
||||
public static let primary = Color.AppText.primary
|
||||
public static let secondary = Color.AppText.secondary
|
||||
public static let tertiary = Color.AppText.tertiary
|
||||
public static let disabled = Color.AppText.tertiary.opacity(Design.Opacity.strong)
|
||||
public static let placeholder = Color.AppText.tertiary
|
||||
public static let inverse = Color.AppText.inverted
|
||||
}
|
||||
|
||||
// MARK: - Accent Colors
|
||||
|
||||
public enum BusinessCardAccentColors: AccentColorProvider {
|
||||
/// Primary accent - warm red
|
||||
public static let primary = Color(red: 0.95, green: 0.33, blue: 0.28)
|
||||
|
||||
/// Light variant
|
||||
public static let light = Color(red: 0.98, green: 0.50, blue: 0.45)
|
||||
|
||||
/// Dark variant
|
||||
public static let dark = Color(red: 0.75, green: 0.25, blue: 0.22)
|
||||
|
||||
/// Secondary accent - ink/dark
|
||||
public static let secondary = Color(red: 0.12, green: 0.12, blue: 0.14)
|
||||
}
|
||||
|
||||
// MARK: - Button Colors
|
||||
|
||||
public enum BusinessCardButtonColors: ButtonColorProvider {
|
||||
public static let primaryLight = Color(red: 0.98, green: 0.45, blue: 0.40)
|
||||
public static let primaryDark = Color(red: 0.85, green: 0.28, blue: 0.24)
|
||||
public static let secondary = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
|
||||
public static let destructive = Color.red.opacity(Design.Opacity.heavy)
|
||||
public static let cancelText = Color(red: 0.32, green: 0.34, blue: 0.40)
|
||||
}
|
||||
|
||||
// MARK: - Status Colors
|
||||
|
||||
public enum BusinessCardStatusColors: StatusColorProvider {
|
||||
public static let success = Color(red: 0.2, green: 0.75, blue: 0.4)
|
||||
public static let warning = Color(red: 0.95, green: 0.75, blue: 0.25)
|
||||
public static let error = Color(red: 0.9, green: 0.3, blue: 0.3)
|
||||
public static let info = Color(red: 0.3, green: 0.6, blue: 0.9)
|
||||
}
|
||||
|
||||
// MARK: - Border Colors
|
||||
|
||||
public enum BusinessCardBorderColors: BorderColorProvider {
|
||||
public static let subtle = Color.AppText.tertiary.opacity(Design.Opacity.subtle)
|
||||
public static let standard = Color.AppText.tertiary.opacity(Design.Opacity.hint)
|
||||
public static let emphasized = Color.AppText.secondary.opacity(Design.Opacity.light)
|
||||
public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.medium)
|
||||
}
|
||||
|
||||
// MARK: - Interactive Colors
|
||||
|
||||
public enum BusinessCardInteractiveColors: InteractiveColorProvider {
|
||||
public static let selected = BusinessCardAccentColors.primary.opacity(Design.Opacity.selection)
|
||||
public static let hover = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.subtle)
|
||||
public static let pressed = Color(red: 0.14, green: 0.14, blue: 0.17).opacity(Design.Opacity.hint)
|
||||
public static let focus = BusinessCardAccentColors.light
|
||||
}
|
||||
|
||||
// MARK: - Combined Theme
|
||||
|
||||
/// BusinessCard's complete color theme.
|
||||
/// Note: We use the individual color provider typealiases (AppSurface, AppThemeAccent, etc.)
|
||||
/// directly in views rather than going through the theme type.
|
||||
///
|
||||
/// This enum is not used directly but documents the theme structure.
|
||||
/// The AppColorTheme conformance is omitted to avoid MainActor isolation conflicts.
|
||||
/// BusinessCard's complete color theme wrapper.
|
||||
public enum BusinessCardTheme {
|
||||
public typealias Surface = BusinessCardSurfaceColors
|
||||
public typealias Text = BusinessCardTextColors
|
||||
@ -117,16 +12,7 @@ public enum BusinessCardTheme {
|
||||
public typealias Interactive = BusinessCardInteractiveColors
|
||||
}
|
||||
|
||||
// MARK: - Convenience Typealiases
|
||||
|
||||
/// Short typealiases for cleaner usage throughout the app.
|
||||
/// These avoid conflicts with Bedrock's default typealiases by using unique names.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// .background(AppSurface.primary)
|
||||
/// .foregroundStyle(AppThemeAccent.primary)
|
||||
/// ```
|
||||
typealias AppSurface = BusinessCardSurfaceColors
|
||||
typealias AppThemeText = BusinessCardTextColors
|
||||
typealias AppThemeAccent = BusinessCardAccentColors
|
||||
|
||||
@ -197,17 +197,6 @@ extension Color {
|
||||
typealias Text = AppText
|
||||
}
|
||||
|
||||
// MARK: - Keyboard Dismiss Helpers
|
||||
|
||||
private struct KeyboardDismissModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Adds standard iOS keyboard dismissal behavior:
|
||||
/// - interactive scroll-to-dismiss
|
||||
|
||||
10
BusinessCard/Design/KeyboardDismissModifier.swift
Normal file
10
BusinessCard/Design/KeyboardDismissModifier.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeyboardDismissModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
@ -1,45 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum PreferredShareAction: String, CaseIterable, Sendable {
|
||||
case shareSheet
|
||||
case textMessage
|
||||
case email
|
||||
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .shareSheet: "Share"
|
||||
case .textMessage: "Text"
|
||||
case .email: "Email"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DefaultFollowUpPreset: String, CaseIterable, Sendable {
|
||||
case none
|
||||
case oneWeek
|
||||
case twoWeeks
|
||||
|
||||
var localizedTitle: String {
|
||||
switch self {
|
||||
case .none: "Off"
|
||||
case .oneWeek: "1W"
|
||||
case .twoWeeks: "2W"
|
||||
}
|
||||
}
|
||||
|
||||
func followUpDate(from referenceDate: Date) -> Date? {
|
||||
switch self {
|
||||
case .none:
|
||||
nil
|
||||
case .oneWeek:
|
||||
Calendar.current.date(byAdding: .day, value: 7, to: referenceDate)
|
||||
case .twoWeeks:
|
||||
Calendar.current.date(byAdding: .day, value: 14, to: referenceDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class AppSettings {
|
||||
var id: UUID
|
||||
|
||||
8
BusinessCard/Models/BannerContentType.swift
Normal file
8
BusinessCard/Models/BannerContentType.swift
Normal 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
|
||||
}
|
||||
@ -1,31 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Banner Content Type
|
||||
|
||||
/// What fills the banner area of the card header.
|
||||
enum BannerContentType: Sendable {
|
||||
/// Profile photo fills the entire banner
|
||||
case profile
|
||||
/// Logo (3:2 landscape) fills the banner
|
||||
case logo
|
||||
/// Cover photo fills the banner
|
||||
case cover
|
||||
}
|
||||
|
||||
// MARK: - Content Overlay Type
|
||||
|
||||
/// What overlaps from the banner into the content area.
|
||||
enum ContentOverlayType: Sendable {
|
||||
/// No overlay - content starts immediately below banner
|
||||
case none
|
||||
/// Circular avatar overlapping from banner
|
||||
case avatar
|
||||
/// Logo rectangle (3:2) overlapping from banner
|
||||
case logoRectangle
|
||||
/// Both avatar and logo displayed side-by-side overlapping
|
||||
case avatarAndLogo
|
||||
}
|
||||
|
||||
// MARK: - Card Header Layout
|
||||
|
||||
/// Defines how the business card header arranges profile, cover, and logo images.
|
||||
|
||||
26
BusinessCard/Models/ContactFieldCategory.swift
Normal file
26
BusinessCard/Models/ContactFieldCategory.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,5 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Category for grouping contact field types in the picker
|
||||
enum ContactFieldCategory: String, CaseIterable, Sendable {
|
||||
case contact
|
||||
case social
|
||||
case developer
|
||||
case messaging
|
||||
case payment
|
||||
case creator
|
||||
case scheduling
|
||||
case other
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .contact: return String(localized: "Contact")
|
||||
case .social: return String(localized: "Social Media")
|
||||
case .developer: return String(localized: "Developer")
|
||||
case .messaging: return String(localized: "Messaging")
|
||||
case .payment: return String(localized: "Payment")
|
||||
case .creator: return String(localized: "Support & Funding")
|
||||
case .scheduling: return String(localized: "Scheduling")
|
||||
case .other: return String(localized: "Other")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a contact field type with all its configuration
|
||||
struct ContactFieldType: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
@ -715,132 +690,3 @@ nonisolated private func buildSocialURL(_ value: String, webBase: String) -> URL
|
||||
}
|
||||
|
||||
// MARK: - Phone Text Utilities
|
||||
|
||||
enum PhoneNumberText {
|
||||
nonisolated static func normalizedForStorage(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasLeadingPlus = trimmed.hasPrefix("+")
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
|
||||
guard !digits.isEmpty else { return "" }
|
||||
if hasLeadingPlus {
|
||||
return "+\(digits)"
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
nonisolated static func isValid(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
|
||||
let plusCount = trimmed.filter { $0 == "+" }.count
|
||||
if plusCount > 1 { return false }
|
||||
if plusCount == 1 && !trimmed.hasPrefix("+") { return false }
|
||||
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
return (7...15).contains(digits.count)
|
||||
}
|
||||
|
||||
nonisolated static func formatted(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
|
||||
let hasLeadingPlus = trimmed.hasPrefix("+")
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
guard !digits.isEmpty else { return hasLeadingPlus ? "+" : "" }
|
||||
|
||||
if hasLeadingPlus {
|
||||
if digits.count == 11, digits.first == "1" {
|
||||
return "+1 \(formatUSDigits(String(digits.dropFirst())))"
|
||||
}
|
||||
return "+" + formatInternationalDigits(digits)
|
||||
}
|
||||
|
||||
if digits.count == 11, digits.first == "1" {
|
||||
return "1 \(formatUSDigits(String(digits.dropFirst())))"
|
||||
}
|
||||
if digits.count <= 10 {
|
||||
return formatUSDigits(digits)
|
||||
}
|
||||
return formatInternationalDigits(digits)
|
||||
}
|
||||
|
||||
nonisolated private static func formatUSDigits(_ digits: String) -> String {
|
||||
if digits.isEmpty { return "" }
|
||||
|
||||
if digits.count <= 3 {
|
||||
return digits
|
||||
}
|
||||
|
||||
let area = String(digits.prefix(3))
|
||||
let remaining = String(digits.dropFirst(3))
|
||||
|
||||
if remaining.count <= 3 {
|
||||
return "(\(area)) \(remaining)"
|
||||
}
|
||||
|
||||
let prefix = String(remaining.prefix(3))
|
||||
let line = String(remaining.dropFirst(3).prefix(4))
|
||||
return "(\(area)) \(prefix)-\(line)"
|
||||
}
|
||||
|
||||
nonisolated private static func formatInternationalDigits(_ digits: String) -> String {
|
||||
guard !digits.isEmpty else { return "" }
|
||||
|
||||
if digits.count <= 3 {
|
||||
return digits
|
||||
}
|
||||
|
||||
var groups: [String] = []
|
||||
var index = digits.startIndex
|
||||
|
||||
while index < digits.endIndex {
|
||||
let next = digits.index(index, offsetBy: 3, limitedBy: digits.endIndex) ?? digits.endIndex
|
||||
groups.append(String(digits[index..<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
|
||||
}
|
||||
}
|
||||
|
||||
9
BusinessCard/Models/ContentOverlayType.swift
Normal file
9
BusinessCard/Models/ContentOverlayType.swift
Normal 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
|
||||
}
|
||||
26
BusinessCard/Models/DefaultFollowUpPreset.swift
Normal file
26
BusinessCard/Models/DefaultFollowUpPreset.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
BusinessCard/Models/EmailText.swift
Normal file
24
BusinessCard/Models/EmailText.swift
Normal 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
|
||||
}
|
||||
}
|
||||
89
BusinessCard/Models/PhoneNumberText.swift
Normal file
89
BusinessCard/Models/PhoneNumberText.swift
Normal 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: " ")
|
||||
}
|
||||
}
|
||||
15
BusinessCard/Models/PreferredShareAction.swift
Normal file
15
BusinessCard/Models/PreferredShareAction.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
BusinessCard/Models/SyncableCard.swift
Normal file
21
BusinessCard/Models/SyncableCard.swift
Normal 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?
|
||||
}
|
||||
29
BusinessCard/Models/WebLinkText.swift
Normal file
29
BusinessCard/Models/WebLinkText.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
@ -246,6 +246,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Create one polished card, then share it anywhere in seconds." : {
|
||||
"comment" : "A subtitle for the first onboarding feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Create your first card" : {
|
||||
|
||||
},
|
||||
@ -416,10 +420,6 @@
|
||||
},
|
||||
"Maiden Name" : {
|
||||
|
||||
},
|
||||
"Matt Bruce" : {
|
||||
"comment" : "The name of the developer of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Messaging" : {
|
||||
|
||||
@ -429,6 +429,10 @@
|
||||
},
|
||||
"More..." : {
|
||||
|
||||
},
|
||||
"Next step: create your first card. Once it is saved, you can start sharing immediately." : {
|
||||
"comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No card selected" : {
|
||||
|
||||
@ -438,6 +442,10 @@
|
||||
},
|
||||
"Notes" : {
|
||||
|
||||
},
|
||||
"Onboarding will be shown again the next time you open the app." : {
|
||||
"comment" : "An alert message that appears when the user confirms resetting the onboarding state.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open on Apple Watch" : {
|
||||
"localizations" : {
|
||||
@ -592,6 +600,13 @@
|
||||
},
|
||||
"Removes this field" : {
|
||||
|
||||
},
|
||||
"Reset" : {
|
||||
"comment" : "The text on a button that resets onboarding for a user.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reset Onboarding" : {
|
||||
|
||||
},
|
||||
"Save" : {
|
||||
|
||||
@ -754,6 +769,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show first-run onboarding again on next app launch" : {
|
||||
"comment" : "A description of the reset onboarding feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Social Media" : {
|
||||
|
||||
},
|
||||
@ -933,6 +952,10 @@
|
||||
},
|
||||
"Website URL" : {
|
||||
|
||||
},
|
||||
"Welcome to %@" : {
|
||||
"comment" : "A title and description for the welcome step in the onboarding flow. The argument is the name of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Widgets on iPhone and Watch" : {
|
||||
"comment" : "A title for a view that showcases widgets for iPhone and watch faces.",
|
||||
@ -943,6 +966,10 @@
|
||||
},
|
||||
"Write down a memorable reminder about your contact" : {
|
||||
|
||||
},
|
||||
"You are ready to share" : {
|
||||
"comment" : "A heading displayed in the \"Activation\" step of the onboarding flow.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your Contact Fields" : {
|
||||
|
||||
|
||||
@ -8,9 +8,7 @@ struct BundleAppMetadataProvider: AppMetadataProviding {
|
||||
}
|
||||
|
||||
var appName: String {
|
||||
bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "App"
|
||||
AppIdentifiers.publicAppName
|
||||
}
|
||||
|
||||
var appVersion: String {
|
||||
|
||||
@ -91,29 +91,3 @@ struct SharedCardCloudKitService: SharedCardProviding {
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
/// Errors that can occur during shared card operations.
|
||||
enum SharedCardError: Error, LocalizedError {
|
||||
case invalidURL
|
||||
case uploadFailed(Error)
|
||||
case fetchFailed(Error)
|
||||
case recordNotFound
|
||||
case recordExpired
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return String(localized: "Failed to create share URL")
|
||||
case .uploadFailed(let error):
|
||||
return String(localized: "Upload failed: \(error.localizedDescription)")
|
||||
case .fetchFailed(let error):
|
||||
return String(localized: "Could not load card: \(error.localizedDescription)")
|
||||
case .recordNotFound:
|
||||
return String(localized: "Card not found")
|
||||
case .recordExpired:
|
||||
return String(localized: "This card has expired")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
BusinessCard/Services/SharedCardError.swift
Normal file
24
BusinessCard/Services/SharedCardError.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
|
||||
15
BusinessCard/State/AppAppearance.swift
Normal file
15
BusinessCard/State/AppAppearance.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,24 +3,11 @@ import Observation
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
enum AppAppearance: String, CaseIterable, Sendable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
var preferredColorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: nil
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
var selectedTab: AppTab = .cards
|
||||
var shouldPresentCreateCardFlow = false
|
||||
var cardStore: CardStore
|
||||
var contactsStore: ContactsStore
|
||||
let preferences: AppPreferencesStore
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import SwiftData
|
||||
|
||||
struct RootTabView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showingShareSheet = false
|
||||
@State private var showingOnboarding = false
|
||||
|
||||
@ -43,38 +44,24 @@ struct RootTabView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if !appState.preferences.hasCompletedOnboarding {
|
||||
showingOnboarding = true
|
||||
updateOnboardingPresentation()
|
||||
}
|
||||
.onChange(of: appState.preferences.hasCompletedOnboarding) { _, _ in
|
||||
updateOnboardingPresentation()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
updateOnboardingPresentation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Floating Share Button
|
||||
|
||||
private struct FloatingShareButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "qrcode")
|
||||
.typography(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: Design.CardSize.floatingButtonSize, height: Design.CardSize.floatingButtonSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.Accent.red)
|
||||
.shadow(
|
||||
color: Color.Accent.red.opacity(Design.Opacity.medium),
|
||||
radius: Design.Shadow.radiusMedium,
|
||||
x: Design.Shadow.offsetNone,
|
||||
y: Design.Shadow.offsetSmall
|
||||
)
|
||||
)
|
||||
private func updateOnboardingPresentation() {
|
||||
if appState.preferences.hasCompletedOnboarding {
|
||||
showingOnboarding = false
|
||||
} else if !showingOnboarding {
|
||||
showingOnboarding = true
|
||||
}
|
||||
.accessibilityLabel(String.localized("Share"))
|
||||
.accessibilityHint(String.localized("Opens the share sheet to send your card"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,64 +80,19 @@ struct CardsHomeView: View {
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this card? This action cannot be undone.")
|
||||
}
|
||||
.onAppear {
|
||||
presentPendingCreateCardFlowIfNeeded()
|
||||
}
|
||||
.onChange(of: appState.shouldPresentCreateCardFlow) { _, _ in
|
||||
presentPendingCreateCardFlowIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Page View
|
||||
|
||||
private struct CardPageView: View {
|
||||
let card: BusinessCard
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Design.Spacing.large) {
|
||||
BusinessCardView(card: card)
|
||||
.frame(maxWidth: Design.CardSize.maxCardWidth)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.xLarge)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private struct EmptyCardsView: View {
|
||||
let onCreateCard: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "rectangle.stack.badge.plus")
|
||||
.typography(.title2)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
Text("Create your first card")
|
||||
.typography(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
Text("Design and share polished digital business cards for every context.")
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
|
||||
PrimaryActionButton(
|
||||
title: String.localized("Create Card"),
|
||||
systemImage: "plus"
|
||||
) {
|
||||
onCreateCard()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
private func presentPendingCreateCardFlowIfNeeded() {
|
||||
guard appState.shouldPresentCreateCardFlow else { return }
|
||||
appState.shouldPresentCreateCardFlow = false
|
||||
showingCreateCard = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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] : [])
|
||||
}
|
||||
}
|
||||
@ -246,102 +246,6 @@ struct ContactFieldEditorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Field Header
|
||||
|
||||
private struct FieldHeaderView: View {
|
||||
let fieldType: ContactFieldType
|
||||
let themeColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Circle()
|
||||
.fill(themeColor)
|
||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||
.overlay(
|
||||
fieldType.iconImage()
|
||||
.typography(.title3)
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
|
||||
Text(fieldType.displayName)
|
||||
.typography(.heading)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Suggestion Chip
|
||||
|
||||
private struct SuggestionChip: View {
|
||||
let text: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.typography(.subheading)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(Color.AppBackground.elevated)
|
||||
.clipShape(.capsule)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.Text.secondary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 8
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = layout(subviews: subviews, proposal: proposal)
|
||||
return result.size
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = layout(subviews: subviews, proposal: proposal)
|
||||
|
||||
for (index, position) in result.positions.enumerated() {
|
||||
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
||||
}
|
||||
}
|
||||
|
||||
private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (size: CGSize, positions: [CGPoint]) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
var currentX: CGFloat = 0
|
||||
var currentY: CGFloat = 0
|
||||
var lineHeight: CGFloat = 0
|
||||
var maxX: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
|
||||
if currentX + size.width > maxWidth && currentX > 0 {
|
||||
currentX = 0
|
||||
currentY += lineHeight + spacing
|
||||
lineHeight = 0
|
||||
}
|
||||
|
||||
positions.append(CGPoint(x: currentX, y: currentY))
|
||||
lineHeight = max(lineHeight, size.height)
|
||||
currentX += size.width + spacing
|
||||
maxX = max(maxX, currentX)
|
||||
}
|
||||
|
||||
return (CGSize(width: maxX, height: currentY + lineHeight), positions)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Add Email") {
|
||||
ContactFieldEditorSheet(fieldType: .email) { value, title in
|
||||
Design.debugLog("Saved: \(value), \(title)")
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
28
BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
Normal file
28
BusinessCard/Views/Features/Cards/Sheets/CropGridLines.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
26
BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
Normal file
26
BusinessCard/Views/Features/Cards/Sheets/CropOverlay.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
44
BusinessCard/Views/Features/Cards/Sheets/FlowLayout.swift
Normal file
44
BusinessCard/Views/Features/Cards/Sheets/FlowLayout.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -64,7 +64,7 @@ struct LogoEditorSheet: View {
|
||||
extractSuggestedColors()
|
||||
}
|
||||
.sheet(isPresented: $showingColorPicker) {
|
||||
CustomColorPickerSheet(initialColor: customColor) { selectedColor in
|
||||
LogoCustomColorPickerSheet(initialColor: customColor) { selectedColor in
|
||||
customColor = selectedColor
|
||||
backgroundColor = selectedColor
|
||||
}
|
||||
@ -241,93 +241,6 @@ struct LogoEditorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color Swatch Button
|
||||
|
||||
private struct ColorSwatchButton: View {
|
||||
let color: Color
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: Design.CardSize.colorSwatchSize, height: Design.CardSize.colorSwatchSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(isSelected ? Color.accentColor : Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.medium)
|
||||
.padding(Design.LineWidth.thin)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Color swatch")
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Color Picker Sheet
|
||||
|
||||
private struct CustomColorPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let initialColor: Color
|
||||
let onSelect: (Color) -> Void
|
||||
|
||||
@State private var selectedColor: Color
|
||||
|
||||
init(initialColor: Color, onSelect: @escaping (Color) -> Void) {
|
||||
self.initialColor = initialColor
|
||||
self.onSelect = onSelect
|
||||
self._selectedColor = State(initialValue: initialColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Design.Spacing.xLarge) {
|
||||
ColorPicker("Select a color", selection: $selectedColor, supportsOpacity: false)
|
||||
.labelsHidden()
|
||||
.scaleEffect(2.0)
|
||||
.frame(height: 100)
|
||||
|
||||
// Preview
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(selectedColor)
|
||||
.frame(height: 100)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Design.Spacing.xLarge)
|
||||
.navigationTitle("Custom color")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
onSelect(selectedColor)
|
||||
dismiss()
|
||||
}
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
@ -1,69 +1,6 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Aspect ratio options for the photo cropper
|
||||
enum CropAspectRatio: Identifiable, CaseIterable, Equatable {
|
||||
case original // Use image's original aspect ratio
|
||||
case square // 1:1 for profile photos
|
||||
case threeToTwo // 3:2
|
||||
case fiveToThree // 5:3
|
||||
case fourToThree // 4:3
|
||||
case fiveToFour // 5:4
|
||||
case sevenToFive // 7:5
|
||||
case sixteenToNine // 16:9
|
||||
case banner // Wide ratio for cover/banner photos (roughly 2.3:1)
|
||||
|
||||
var id: String { displayName }
|
||||
|
||||
static var allCases: [CropAspectRatio] {
|
||||
[.original, .square, .threeToTwo, .fiveToThree, .fourToThree, .fiveToFour, .sevenToFive, .sixteenToNine]
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .original: return String.localized("Original")
|
||||
case .square: return String.localized("Square")
|
||||
case .threeToTwo: return "3:2"
|
||||
case .fiveToThree: return "5:3"
|
||||
case .fourToThree: return "4:3"
|
||||
case .fiveToFour: return "5:4"
|
||||
case .sevenToFive: return "7:5"
|
||||
case .sixteenToNine: return "16:9"
|
||||
case .banner: return String.localized("Banner")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ratio (width / height). For .original, pass the image size.
|
||||
func ratio(for imageSize: CGSize? = nil) -> CGFloat {
|
||||
switch self {
|
||||
case .original:
|
||||
guard let size = imageSize, size.height > 0 else { return 1.0 }
|
||||
return size.width / size.height
|
||||
case .square:
|
||||
return 1.0
|
||||
case .threeToTwo:
|
||||
return 3.0 / 2.0
|
||||
case .fiveToThree:
|
||||
return 5.0 / 3.0
|
||||
case .fourToThree:
|
||||
return 4.0 / 3.0
|
||||
case .fiveToFour:
|
||||
return 5.0 / 4.0
|
||||
case .sevenToFive:
|
||||
return 7.0 / 5.0
|
||||
case .sixteenToNine:
|
||||
return 16.0 / 9.0
|
||||
case .banner:
|
||||
return 2.3 // Width is 2.3x height
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple ratio for backwards compatibility (doesn't handle .original properly)
|
||||
var ratio: CGFloat {
|
||||
ratio(for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet that allows the user to crop an image.
|
||||
/// Supports pinch-to-zoom and drag gestures for positioning.
|
||||
/// Use `aspectRatio` to specify square, banner, or custom crop shapes.
|
||||
@ -515,65 +452,6 @@ struct PhotoCropperSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop Overlay
|
||||
|
||||
private struct CropOverlay: View {
|
||||
let cropSize: CGSize
|
||||
let containerSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Semi-transparent overlay
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(Design.Opacity.accent))
|
||||
|
||||
// Clear rectangle in center (can be square or banner)
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
.blendMode(.destinationOut)
|
||||
}
|
||||
.compositingGroup()
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// Border around crop area
|
||||
Rectangle()
|
||||
.stroke(Color.white, lineWidth: Design.LineWidth.thin)
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop Grid Lines
|
||||
|
||||
private struct CropGridLines: View {
|
||||
let cropSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Vertical lines (rule of thirds)
|
||||
HStack(spacing: cropSize.width / 3 - Design.LineWidth.thin) {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(Design.Opacity.light))
|
||||
.frame(width: Design.LineWidth.thin, height: cropSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal lines (rule of thirds)
|
||||
VStack(spacing: cropSize.height / 3 - Design.LineWidth.thin) {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(Design.Opacity.light))
|
||||
.frame(width: cropSize.width, height: Design.LineWidth.thin)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: cropSize.width, height: cropSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
55
BusinessCard/Views/Features/Contacts/ContactsView.swift
Normal file
55
BusinessCard/Views/Features/Contacts/ContactsView.swift
Normal 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))
|
||||
}
|
||||
@ -322,128 +322,6 @@ struct AddContactSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact Photo Row
|
||||
|
||||
private struct ContactPhotoRow: View {
|
||||
@Binding var photoData: Data?
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Photo preview
|
||||
Group {
|
||||
if let photoData, let uiImage = UIImage(data: photoData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.typography(.title2)
|
||||
.foregroundStyle(Color.Text.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(width: Design.CardSize.avatarLarge, height: Design.CardSize.avatarLarge)
|
||||
.clipShape(.circle)
|
||||
.overlay(Circle().stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin))
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text("Profile Photo")
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
Text(photoData == nil ? String.localized("Add a photo") : String.localized("Tap to change"))
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.tertiary)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Labeled Entry Model
|
||||
|
||||
private struct LabeledEntry: Identifiable {
|
||||
let id = UUID()
|
||||
var label: String
|
||||
var value: String
|
||||
}
|
||||
|
||||
// MARK: - Labeled Field Row
|
||||
|
||||
private struct LabeledFieldRow: View {
|
||||
@Binding var entry: LabeledEntry
|
||||
let valuePlaceholder: String
|
||||
let labelSuggestions: [String]
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var autocapitalization: TextInputAutocapitalization = .sentences
|
||||
var formatValue: ((String) -> String)?
|
||||
var isValueValid: ((String) -> Bool)?
|
||||
var validationMessage: String?
|
||||
|
||||
private var trimmedValue: String {
|
||||
entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var showsValidationError: Bool {
|
||||
guard let isValueValid else { return false }
|
||||
guard !trimmedValue.isEmpty else { return false }
|
||||
return !isValueValid(trimmedValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
// Label picker
|
||||
Menu {
|
||||
ForEach(labelSuggestions, id: \.self) { suggestion in
|
||||
Button(suggestion) {
|
||||
entry.label = suggestion
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Text(entry.label)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.typography(.caption2)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 80, alignment: .leading)
|
||||
|
||||
// Value field
|
||||
TextField(valuePlaceholder, text: $entry.value)
|
||||
.keyboardType(keyboardType)
|
||||
.textInputAutocapitalization(autocapitalization)
|
||||
.onChange(of: entry.value) { _, newValue in
|
||||
guard let formatValue else { return }
|
||||
let formatted = formatValue(newValue)
|
||||
if formatted != newValue {
|
||||
entry.value = formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if showsValidationError, let validationMessage {
|
||||
Text(validationMessage)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
.padding(.leading, 80 + Design.Spacing.medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddContactSheet()
|
||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self, AppSettings.self).mainContext))
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct LabeledEntry: Identifiable {
|
||||
let id = UUID()
|
||||
var label: String
|
||||
var value: String
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
BusinessCard/Views/Features/Onboarding/OnboardingStep.swift
Normal file
27
BusinessCard/Views/Features/Onboarding/OnboardingStep.swift
Normal 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
|
||||
}
|
||||
}
|
||||
235
BusinessCard/Views/Features/Onboarding/OnboardingView.swift
Normal file
235
BusinessCard/Views/Features/Onboarding/OnboardingView.swift
Normal 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: {})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ struct SettingsView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var settingsState = SettingsState()
|
||||
@State private var showingResetOnboardingConfirmation = false
|
||||
private let appName = BundleAppMetadataProvider().appName
|
||||
private let appName = AppIdentifiers.publicAppName
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
BusinessCard/Views/Features/Share/QRScannerView.swift
Normal file
86
BusinessCard/Views/Features/Share/QRScannerView.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol QRScannerViewControllerDelegate: AnyObject {
|
||||
func didScanCode(_ code: String)
|
||||
}
|
||||
67
BusinessCard/Views/Features/Share/ScannedResultView.swift
Normal file
67
BusinessCard/Views/Features/Share/ScannedResultView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
37
BusinessCard/Views/Features/Share/ScannerOverlayView.swift
Normal file
37
BusinessCard/Views/Features/Share/ScannerOverlayView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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: {})
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
@ -1,44 +1,18 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A generic action row with icon, title, optional subtitle, and chevron.
|
||||
/// Used for share options, settings rows, and navigation items.
|
||||
struct ActionRowView<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.
|
||||
struct ActionRowContent: View {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let systemImage: String
|
||||
|
||||
|
||||
init(title: String, subtitle: String? = nil, systemImage: String) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: systemImage)
|
||||
@ -46,21 +20,21 @@ struct ActionRowContent: View {
|
||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||
.background(Color.AppBackground.accent)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text(title)
|
||||
.typography(.heading)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.typography(.subheading)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
@ -69,20 +43,3 @@ struct ActionRowContent: View {
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ActionRowContent(
|
||||
title: "Share via NFC",
|
||||
subtitle: "Tap phones to share instantly",
|
||||
systemImage: "dot.radiowaves.left.and.right"
|
||||
)
|
||||
|
||||
ActionRowContent(
|
||||
title: "Copy Link",
|
||||
systemImage: "link"
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.AppBackground.base)
|
||||
}
|
||||
45
BusinessCard/Views/Shared/Components/ActionRowView.swift
Normal file
45
BusinessCard/Views/Shared/Components/ActionRowView.swift
Normal 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)
|
||||
}
|
||||
35
BusinessCard/Views/Shared/Components/AddedContactField.swift
Normal file
35
BusinessCard/Views/Shared/Components/AddedContactField.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -56,31 +56,6 @@ struct AddressEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Address Text Field
|
||||
|
||||
private struct AddressTextField: View {
|
||||
let label: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var textContentType: UITextContentType?
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(label)
|
||||
.typography(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
.textContentType(textContentType)
|
||||
.keyboardType(keyboardType)
|
||||
.textInputAutocapitalization(.words)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
26
BusinessCard/Views/Shared/Components/AddressTextField.swift
Normal file
26
BusinessCard/Views/Shared/Components/AddressTextField.swift
Normal 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
Loading…
Reference in New Issue
Block a user