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