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