From 9e87de5ce95fc915a80f6aedd1b78c2c6795061c Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 8 Jan 2026 18:23:39 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Agents.md | 626 ++++++------------ BusinessCard.xcodeproj/project.pbxproj | 23 + BusinessCard/Design/DesignConstants.swift | 119 ++-- BusinessCard/Protocols/QRCodeProviding.swift | 5 - BusinessCard/Services/QRCodeService.swift | 17 - BusinessCard/State/AppState.swift | 2 - BusinessCard/Views/BusinessCardView.swift | 90 +-- BusinessCard/Views/CardCarouselView.swift | 3 +- BusinessCard/Views/CardEditorView.swift | 503 ++++++-------- BusinessCard/Views/CardsHomeView.swift | 1 + .../Views/Components/ActionRowView.swift | 88 +++ .../Views/Components/AvatarBadgeView.swift | 66 ++ .../Views/Components/IconRowView.swift | 36 + .../Views/Components/LabelBadgeView.swift | 28 + BusinessCard/Views/ContactDetailView.swift | 5 +- BusinessCard/Views/ContactsView.swift | 5 +- BusinessCard/Views/CustomizeCardView.swift | 3 +- BusinessCard/Views/EmptyStateView.swift | 1 + BusinessCard/Views/HeroBannerView.swift | 1 + BusinessCard/Views/PrimaryActionButton.swift | 1 + BusinessCard/Views/QRCodeView.swift | 18 +- BusinessCard/Views/QRScannerView.swift | 1 + BusinessCard/Views/RootTabView.swift | 1 + BusinessCard/Views/ShareCardView.swift | 271 +++----- .../Views/Sheets/RecordContactSheet.swift | 66 ++ BusinessCard/Views/WidgetsCalloutView.swift | 1 + BusinessCard/Views/WidgetsView.swift | 5 +- README.md | 67 +- ROADMAP.md | 99 +++ ai_implmentation.md | 102 ++- 30 files changed, 1117 insertions(+), 1137 deletions(-) delete mode 100644 BusinessCard/Protocols/QRCodeProviding.swift delete mode 100644 BusinessCard/Services/QRCodeService.swift create mode 100644 BusinessCard/Views/Components/ActionRowView.swift create mode 100644 BusinessCard/Views/Components/AvatarBadgeView.swift create mode 100644 BusinessCard/Views/Components/IconRowView.swift create mode 100644 BusinessCard/Views/Components/LabelBadgeView.swift create mode 100644 BusinessCard/Views/Sheets/RecordContactSheet.swift create mode 100644 ROADMAP.md diff --git a/Agents.md b/Agents.md index 8c4b93a..0404c5d 100644 --- a/Agents.md +++ b/Agents.md @@ -1,10 +1,10 @@ -# Agent guide for Swift and SwiftUI +# Agent Guide for Swift and SwiftUI This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. -## Additional context files (read first) +## Additional Context Files (Read First) -- `README.md` — product scope, features, and project structure +- `README.md` — Product scope, features, and project structure - `ai_implmentation.md` — AI implementation context and architecture notes @@ -13,501 +13,283 @@ This repository contains an Xcode project written with Swift and SwiftUI. Please You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. -## Core instructions +## Core Instructions - Target iOS 26.0 or later. (Yes, it definitely exists.) - Swift 6.2 or later, using modern Swift concurrency. - SwiftUI backed up by `@Observable` classes for shared data. -- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below. +- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability. +- **Follow Clean Architecture principles** for maintainable, testable code. - Do not introduce third-party frameworks without asking first. - Avoid UIKit unless requested. +## Clean Architecture + +**Separation of concerns is mandatory.** Code should be organized into distinct layers with clear responsibilities and dependencies flowing inward. + +### File Organization Principles + +1. **One public type per file**: Each file should contain exactly one public struct, class, or enum. Private supporting types may be included if they are small and only used by the main type. + +2. **Keep files lean**: Aim for files under 300 lines. If a file exceeds this: + - Extract reusable sub-views into separate files in a `Components/` folder + - Extract sheets/modals into a `Sheets/` folder + - Move complex logic into dedicated types + +3. **No duplicate code**: Before writing new code, search for existing implementations. Extract common patterns into reusable components. + +4. **Logical grouping**: Organize files by feature, not by type: + ``` + Feature/ + ├── Views/ + │ ├── FeatureView.swift + │ ├── Components/ + │ │ ├── FeatureRowView.swift + │ │ └── FeatureHeaderView.swift + │ └── Sheets/ + │ └── FeatureEditSheet.swift + ├── Models/ + │ └── FeatureModel.swift + └── State/ + └── FeatureStore.swift + ``` + +### Layer Responsibilities + +| Layer | Contains | Depends On | +|-------|----------|------------| +| **Views** | SwiftUI views, UI components | State, Models | +| **State** | `@Observable` stores, view models | Models, Services | +| **Services** | Business logic, networking, persistence | Models | +| **Models** | Data types, entities, DTOs | Nothing | +| **Protocols** | Interfaces for services and stores | Models | + +### Architecture Rules + +1. **Views are dumb renderers**: No business logic in views. Views read state and call methods. +2. **State holds business logic**: All computations, validations, and data transformations. +3. **Services are stateless**: Pure functions where possible. Injected via protocols. +4. **Models are simple**: Plain data types. No dependencies on UI or services. + +### Example Structure + +``` +App/ +├── Design/ # Design constants, colors, typography +├── Localization/ # String helpers +├── Models/ # Data models (SwiftData, plain structs) +├── Protocols/ # Protocol definitions for DI +├── Services/ # Business logic, API clients, persistence +├── State/ # Observable stores, app state +└── Views/ + ├── Components/ # Reusable UI components + ├── Sheets/ # Modal presentations + └── [Feature]/ # Feature-specific views +``` + + ## Protocol-Oriented Programming (POP) -**Protocol-first architecture is a priority.** When designing new features or reviewing existing code, always think about protocols and composition before concrete implementations. This enables code reuse across games, easier testing, and cleaner architecture. +**Protocol-first architecture is a priority.** When designing new features, always think about protocols and composition before concrete implementations. -### When architecting new code: +### When Architecting New Code 1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol. 2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first. 3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate. 4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies. -### When reviewing existing code for reuse: +### When Reviewing Existing Code -1. **Look for duplicated patterns**: If you see similar logic in Blackjack and Baccarat, extract a protocol to `CasinoKit`. -2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification. -3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended. +1. **Look for duplicated patterns**: Similar logic across files is a candidate for protocol extraction. +2. **Identify common interfaces**: Types that expose similar properties/methods should conform to a shared protocol. +3. **Check before implementing**: Search for existing protocols that could be adopted or extended. 4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it. -### Protocol design guidelines: +### Protocol Design Guidelines -- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Bettable`, `CardDealing`, `StatisticsProvider`). +- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Shareable`, `DataProviding`, `Persistable`). - **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle). - **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible. - **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required. -### Examples +### Benefits -**❌ BAD - Concrete implementations without protocols:** -```swift -// Blackjack/GameState.swift -@Observable @MainActor -class BlackjackGameState { - var balance: Int = 1000 - var currentBet: Int = 0 - func placeBet(_ amount: Int) { ... } - func resetBet() { ... } -} - -// Baccarat/GameState.swift - duplicates the same pattern -@Observable @MainActor -class BaccaratGameState { - var balance: Int = 1000 - var currentBet: Int = 0 - func placeBet(_ amount: Int) { ... } - func resetBet() { ... } -} -``` - -**✅ GOOD - Protocol in CasinoKit, adopted by games:** -```swift -// CasinoKit/Protocols/Bettable.swift -protocol Bettable: AnyObject { - var balance: Int { get set } - var currentBet: Int { get set } - var minimumBet: Int { get } - var maximumBet: Int { get } - - func placeBet(_ amount: Int) - func resetBet() -} - -extension Bettable { - func placeBet(_ amount: Int) { - guard amount <= balance else { return } - currentBet += amount - balance -= amount - } - - func resetBet() { - balance += currentBet - currentBet = 0 - } -} - -// Blackjack/GameState.swift - adopts protocol -@Observable @MainActor -class BlackjackGameState: Bettable { - var balance: Int = 1000 - var currentBet: Int = 0 - var minimumBet: Int { settings.minBet } - var maximumBet: Int { settings.maxBet } - // placeBet and resetBet come from protocol extension -} -``` - -**❌ BAD - View only works with one concrete type:** -```swift -struct ChipSelectorView: View { - @Bindable var state: BlackjackGameState - // Tightly coupled to Blackjack -} -``` - -**✅ GOOD - View works with any Bettable type:** -```swift -struct ChipSelectorView: View { - @Bindable var state: State - // Reusable across all games -} -``` - -### Common protocols to consider extracting: - -| Capability | Protocol Name | Shared By | -|------------|---------------|-----------| -| Betting mechanics | `Bettable` | All games | -| Statistics tracking | `StatisticsProvider` | All games | -| Game settings | `GameConfigurable` | All games | -| Card management | `CardProviding` | Card games | -| Round lifecycle | `RoundManaging` | All games | -| Result calculation | `ResultCalculating` | All games | - -### Refactoring checklist: - -When you encounter code that could benefit from POP: - -- [ ] Is this logic duplicated across multiple games? -- [ ] Could this type conform to an existing protocol in CasinoKit? -- [ ] Would extracting a protocol make this code testable in isolation? -- [ ] Can views be made generic over a protocol instead of a concrete type? -- [ ] Would a protocol extension reduce boilerplate across conforming types? - -### Benefits: - -- **Reusability**: Shared protocols in `CasinoKit` work across all games +- **Reusability**: Shared protocols work across features - **Testability**: Mock types can conform to protocols for unit testing -- **Flexibility**: New games can adopt existing protocols immediately +- **Flexibility**: New features can adopt existing protocols immediately - **Maintainability**: Fix a bug in a protocol extension, fix it everywhere - **Discoverability**: Protocols document the expected interface clearly -## Swift instructions +## View/State Separation (MVVM-lite) + +**Views should be "dumb" renderers.** All business logic belongs in stores or dedicated view models. + +### What Belongs in State/Store + +- **Business logic**: Calculations, validations, rules +- **Computed properties based on data**: Hints, recommendations, derived values +- **State checks**: `canSubmit`, `isLoading`, `hasError` +- **Data transformations**: Filtering, sorting, aggregations + +### What is Acceptable in Views + +- **Pure UI layout logic**: Adaptive layouts based on size class +- **Visual styling**: Color selection based on state +- **@ViewBuilder sub-views**: Breaking up complex layouts (keep in same file if small) +- **Accessibility labels**: Combining data into accessible descriptions + +### Example + +```swift +// ❌ BAD - Business logic in view +struct MyView: View { + @Bindable var state: FeatureState + + private var isValid: Bool { + !state.name.isEmpty && state.email.contains("@") + } +} + +// ✅ GOOD - Logic in State, view just reads +// In FeatureState: +var isValid: Bool { + !name.isEmpty && email.contains("@") +} + +// In View: +Button("Save") { state.save() } + .disabled(!state.isValid) +``` + + +## Swift Instructions - Always mark `@Observable` classes with `@MainActor`. - Assume strict Swift concurrency rules are being applied. -- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`. -- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL. -- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead. -- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. -- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. -- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`. -- Avoid force unwraps and force `try` unless it is unrecoverable. +- Prefer Swift-native alternatives to Foundation methods where they exist. +- Prefer modern Foundation API (e.g., `URL.documentsDirectory`, `appending(path:)`). +- Never use C-style number formatting; use `format:` modifiers instead. +- Prefer static member lookup to struct instances (`.circle` not `Circle()`). +- Never use old-style GCD; use modern Swift concurrency. +- Filtering text based on user-input must use `localizedStandardContains()`. +- Avoid force unwraps and force `try` unless unrecoverable. -## SwiftUI instructions +## SwiftUI Instructions - Always use `foregroundStyle()` instead of `foregroundColor()`. - Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. - Always use the `Tab` API instead of `tabItem()`. -- Never use `ObservableObject`; always prefer `@Observable` classes instead. -- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. -- Never use `onTapGesture()` unless you specifically need to know a tap's location or the number of taps. All other usages should use `Button`. -- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. -- Never use `UIScreen.main.bounds` to read the size of the available space. -- Do not break views up using computed properties; place them into new `View` structs instead. -- Do not force specific font sizes; prefer using Dynamic Type instead. -- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`. -- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`. -- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`. -- Don't apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`. -- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`. -- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`. -- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. -- Avoid `AnyView` unless it is absolutely required. -- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section). -- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in the `Color` extension in `DesignConstants.swift` with semantic names. -- Avoid using UIKit colors in SwiftUI code. +- Never use `ObservableObject`; always prefer `@Observable` classes. +- Never use `onChange()` in its 1-parameter variant. +- Never use `onTapGesture()` unless you need tap location/count; use `Button`. +- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)`. +- Never use `UIScreen.main.bounds` to read available space. +- Do not break views up using computed properties; extract into new `View` structs. +- Do not force specific font sizes; prefer Dynamic Type. +- Use `NavigationStack` with `navigationDestination(for:)`. +- If using an image for a button label, always specify text alongside. +- Prefer `ImageRenderer` to `UIGraphicsImageRenderer`. +- Use `bold()` instead of `fontWeight(.bold)`. +- Avoid `GeometryReader` if newer alternatives work (e.g., `containerRelativeFrame()`). +- When enumerating in `ForEach`, don't convert to Array first. +- Hide scroll indicators with `.scrollIndicators(.hidden)`. +- Avoid `AnyView` unless absolutely required. +- **Never use raw numeric literals** for padding, spacing, opacity, etc.—use Design constants. +- **Never use inline colors**—define all colors with semantic names. +- Avoid UIKit colors in SwiftUI code. -## View/State separation (MVVM-lite) - -**Views should be "dumb" renderers.** All business logic belongs in `GameState` or dedicated view models. - -### What belongs in the State/ViewModel: -- **Business logic**: Calculations, validations, game rules -- **Computed properties based on game data**: hints, recommendations, derived values -- **State checks**: `isPlayerTurn`, `canHit`, `isGameOver`, `isBetBelowMinimum` -- **Data transformations**: statistics calculations, filtering, aggregations - -### What is acceptable in Views: -- **Pure UI layout logic**: `isIPad`, `maxContentWidth` based on size class -- **Visual styling**: color selection based on state (`valueColor`, `resultColor`) -- **@ViewBuilder sub-views**: breaking up complex layouts -- **Accessibility labels**: combining data into accessible descriptions - -### Examples - -**❌ BAD - Business logic in view:** -```swift -struct MyView: View { - @Bindable var state: GameState - - private var isBetBelowMinimum: Bool { - state.currentBet > 0 && state.currentBet < state.settings.minBet - } - - private var currentHint: String? { - guard let hand = state.activeHand else { return nil } - return state.engine.getHint(playerHand: hand, dealerUpCard: upCard) - } -} -``` - -**✅ GOOD - Logic in GameState, view just reads:** -```swift -// In GameState: -var isBetBelowMinimum: Bool { - currentBet > 0 && currentBet < settings.minBet -} - -var currentHint: String? { - guard settings.showHints, isPlayerTurn else { return nil } - guard let hand = activeHand, let upCard = dealerUpCard else { return nil } - return engine.getHint(playerHand: hand, dealerUpCard: upCard) -} - -// In View: -if state.isBetBelowMinimum { ... } -if let hint = state.currentHint { HintView(hint: hint) } -``` - -### Benefits: -- **Testable**: GameState logic can be unit tested without UI -- **Single source of truth**: No duplicated logic across views -- **Cleaner views**: Views focus purely on layout and presentation -- **Easier debugging**: Logic is centralized, not scattered - - -## SwiftData instructions +## SwiftData Instructions If SwiftData is configured to use CloudKit: - Never use `@Attribute(.unique)`. -- Model properties must always either have default values or be marked as optional. +- Model properties must have default values or be optional. - All relationships must be marked optional. -## Localization instructions +## Localization Instructions -- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+. -- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings. -- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension: - ```swift - extension String { - static func localized(_ key: String) -> String { - String(localized: String.LocalizationValue(key)) - } - static func localized(_ key: String, _ arguments: CVarArg...) -> String { - let format = String(localized: String.LocalizationValue(key)) - return String(format: format, arguments: arguments) - } - } - ``` -- For format strings with interpolation (e.g., "Balance: $%@"), define a key in the String Catalog and use `String.localized("key", value)`. -- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views. -- Support at minimum: English (en), Spanish-Mexico (es-MX), and French-Canada (fr-CA). -- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API. +- Use **String Catalogs** (`.xcstrings` files) for localization. +- SwiftUI `Text("literal")` views automatically look up strings in the catalog. +- For strings outside of `Text` views, use `String(localized:)` or a helper extension. +- Store all user-facing strings in the String Catalog. +- Support at minimum: English (en), Spanish-Mexico (es-MX), French-Canada (fr-CA). +- Never use `NSLocalizedString`; prefer `String(localized:)`. -## No magic numbers or hardcoded values +## Design Constants -**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to: +**Never use raw numeric literals or hardcoded colors directly in views.** -### Values that MUST be constants: -- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)` +### Values That MUST Be Constants + +- **Spacing & Padding**: `Design.Spacing.medium` not `.padding(12)` - **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` - **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` - **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` -- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)` +- **Colors**: `Color.Primary.accent` not `Color(red:green:blue:)` - **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2` - **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` - **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` -- **Component Sizes**: `Design.Size.chipBadge` not `frame(width: 32)` +- **Component Sizes**: `Design.Size.avatar` not `frame(width: 56)` -### What to do when you see a magic number: -1. Check if an appropriate constant already exists in `DesignConstants.swift` -2. If not, add a new constant with a semantic name -3. Use the constant in place of the raw value -4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct +### Organization -### Examples of violations: -```swift -// ❌ BAD - Magic numbers everywhere -.padding(16) -.opacity(0.6) -.frame(width: 80, height: 52) -.shadow(radius: 10, y: 5) -Color(red: 0.25, green: 0.3, blue: 0.45) - -// ✅ GOOD - Named constants -.padding(Design.Spacing.large) -.opacity(Design.Opacity.accent) -.frame(width: Design.Size.bonusZoneWidth, height: Design.Size.topBetRowHeight) -.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) -Color.BettingZone.dragonBonusLight -``` +- Create a `DesignConstants.swift` file using enums for namespacing. +- Extend `Color` with semantic color definitions. +- View-specific constants go at the top of the view struct with a comment. +- Name constants semantically: `accent` not `pointSix`, `large` not `sixteen`. -## Design constants instructions +## Dynamic Type Instructions -- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing: - ```swift - enum Design { - enum Spacing { - static let xxSmall: CGFloat = 2 - static let xSmall: CGFloat = 4 - static let small: CGFloat = 8 - static let medium: CGFloat = 12 - static let large: CGFloat = 16 - static let xLarge: CGFloat = 20 - } - enum CornerRadius { - static let small: CGFloat = 8 - static let medium: CGFloat = 12 - static let large: CGFloat = 16 - } - enum BaseFontSize { - static let small: CGFloat = 10 - static let body: CGFloat = 14 - static let large: CGFloat = 18 - static let title: CGFloat = 24 - } - enum Opacity { - static let subtle: Double = 0.1 - static let hint: Double = 0.2 - static let light: Double = 0.3 - static let medium: Double = 0.5 - static let accent: Double = 0.6 - static let strong: Double = 0.7 - static let heavy: Double = 0.8 - static let almostFull: Double = 0.9 - } - enum LineWidth { - static let thin: CGFloat = 1 - static let medium: CGFloat = 2 - static let thick: CGFloat = 3 - } - enum Shadow { - static let radiusSmall: CGFloat = 2 - static let radiusMedium: CGFloat = 6 - static let radiusLarge: CGFloat = 10 - static let offsetSmall: CGFloat = 1 - static let offsetMedium: CGFloat = 3 - } - enum Animation { - static let quick: Double = 0.3 - static let springDuration: Double = 0.4 - static let staggerDelay1: Double = 0.1 - static let staggerDelay2: Double = 0.25 - } - } - ``` -- For colors used across the app, extend `Color` with semantic color definitions: - ```swift - extension Color { - enum Primary { - static let background = Color(red: 0.1, green: 0.2, blue: 0.3) - static let accent = Color(red: 0.8, green: 0.6, blue: 0.2) - } - enum Button { - static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3) - static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2) - } - } - ``` -- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local: - ```swift - struct MyView: View { - // Layout: fixed card dimensions for consistent appearance - private let cardWidth: CGFloat = 45 - // Typography: constrained space requires fixed size - private let headerFontSize: CGFloat = 18 - // ... - } - ``` -- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`. -- Keep design constants organized by category: Spacing, CornerRadius, BaseFontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow. -- When adding new features, check existing constants first before creating new ones. -- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`. +- Always support Dynamic Type for accessibility. +- Use `@ScaledMetric` to scale custom dimensions. +- Choose appropriate `relativeTo` text styles based on semantic purpose. +- For constrained UI elements, you may use fixed sizes but document the reason. +- Prefer system text styles: `.font(.body)`, `.font(.title)`, `.font(.caption)`. -## Dynamic Type instructions +## VoiceOver Accessibility Instructions -- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling. -- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings: - ```swift - struct MyView: View { - @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14 - @ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24 - @ScaledMetric(relativeTo: .caption) private var chipTextSize: CGFloat = 11 - - var body: some View { - Text("Hello") - .font(.system(size: bodyFontSize, weight: .medium)) - } - } - ``` -- Choose the appropriate `relativeTo` text style based on the semantic purpose: - - `.largeTitle`, `.title`, `.title2`, `.title3` for headings - - `.headline`, `.subheadline` for emphasized content - - `.body` for main content - - `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text -- For constrained UI elements (chips, cards, badges) where overflow would break the design, you may use fixed sizes but document the reason: - ```swift - // Fixed size: chip face has strict space constraints - private let chipValueFontSize: CGFloat = 11 - ``` -- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`. -- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text. +- All interactive elements must have meaningful `.accessibilityLabel()`. +- Use `.accessibilityValue()` for dynamic state. +- Use `.accessibilityHint()` to describe what happens on interaction. +- Use `.accessibilityAddTraits()` for element type. +- Hide decorative elements with `.accessibilityHidden(true)`. +- Group related elements to reduce navigation complexity. +- Post accessibility announcements for important events. -## VoiceOver accessibility instructions +## Project Structure -- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`. -- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value). -- Use `.accessibilityHint()` to describe what will happen when interacting with an element: - ```swift - Button("Deal", action: deal) - .accessibilityHint("Deals cards and starts the round") - ``` -- Use `.accessibilityAddTraits()` to communicate element type: - - `.isButton` for tappable elements that aren't SwiftUI Buttons - - `.isHeader` for section headers - - `.isModal` for modal overlays - - `.updatesFrequently` for live-updating content -- Hide purely decorative elements from VoiceOver: - ```swift - TableBackgroundView() - .accessibilityHidden(true) // Decorative element - ``` -- Group related elements to reduce VoiceOver navigation complexity: - ```swift - VStack { - handLabel - cardStack - valueDisplay - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Player hand") - .accessibilityValue("Ace of Hearts, King of Spades. Value: 1") - ``` -- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context. -- Post accessibility announcements for important events: - ```swift - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - UIAccessibility.post(notification: .announcement, argument: "Player wins!") - } - ``` -- Provide accessibility names for model types that appear in UI: - ```swift - enum Suit { - var accessibilityName: String { - switch self { - case .hearts: return String(localized: "Hearts") - // ... - } - } - } - ``` -- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver. - - -## Project structure - -- Use a consistent project structure, with folder layout determined by app features. -- Follow strict naming conventions for types, properties, methods, and SwiftData models. -- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Use a consistent project structure organized by feature. +- Follow strict naming conventions for types, properties, and methods. +- **One public type per file**—break types into separate files. - Write unit tests for core application logic. - Only write UI tests if unit tests are not possible. -- Add code comments and documentation comments as needed. -- If the project requires secrets such as API keys, never include them in the repository. +- Add code comments and documentation as needed. +- Never include secrets or API keys in the repository. -## Documentation instructions +## Documentation Instructions -- **Always keep each game's `README.md` file up to date** when adding new functionality or making changes that users or developers need to know about. -- Document new features, settings, or gameplay mechanics in the appropriate game's README. -- Update the README when modifying existing behavior that affects how the game works. -- Include any configuration options, keyboard shortcuts, or special interactions. -- If adding a new game to the workspace, create a comprehensive README following the existing games' format. -- README updates should be part of the same commit as the feature/change they document. +- **Keep `README.md` up to date** when adding new functionality. +- Document new features, settings, or mechanics in the README. +- Update the README when modifying existing behavior. +- Include configuration options and special interactions. +- README updates should be part of the same commit as the feature. +- Maintain a `ROADMAP.md` for tracking feature status. -## PR instructions +## PR Instructions -- If installed, make sure SwiftLint returns no warnings or errors before committing. -- Verify that the game's README.md reflects any new functionality or behavioral changes. +- If installed, ensure SwiftLint returns no warnings or errors. +- Verify that documentation reflects any new functionality. +- Check for duplicate code before submitting. +- Ensure all new files follow the one-type-per-file rule. diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj index 46db022..fe643c9 100644 --- a/BusinessCard.xcodeproj/project.pbxproj +++ b/BusinessCard.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ EA8379312F105F2800077F87 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -78,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + EA837E672F107D6800077F87 /* Bedrock in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -162,6 +167,7 @@ ); name = BusinessCard; packageProductDependencies = ( + EA837E662F107D6800077F87 /* Bedrock */, ); productName = BusinessCard; productReference = EA8379232F105F2600077F87 /* BusinessCard.app */; @@ -250,6 +256,9 @@ ); mainGroup = EA83791A2F105F2600077F87; minimizedProjectReferenceProxies = 1; + packageReferences = ( + EA837E652F107D6800077F87 /* XCLocalSwiftPackageReference "../Frameworks/Bedrock" */, + ); preferredProjectObjectVersion = 77; productRefGroup = EA8379242F105F2600077F87 /* Products */; projectDirPath = ""; @@ -715,6 +724,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + EA837E652F107D6800077F87 /* XCLocalSwiftPackageReference "../Frameworks/Bedrock" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../Frameworks/Bedrock; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EA837E662F107D6800077F87 /* Bedrock */ = { + isa = XCSwiftPackageProductDependency; + productName = Bedrock; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = EA83791B2F105F2600077F87 /* Project object */; } diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift index fb20081..c06cee7 100644 --- a/BusinessCard/Design/DesignConstants.swift +++ b/BusinessCard/Design/DesignConstants.swift @@ -1,66 +1,18 @@ +// +// DesignConstants.swift +// BusinessCard +// +// App-specific design extensions to Bedrock's Design system. +// + import SwiftUI +import Bedrock -enum Design { - enum Spacing { - static let xxSmall: CGFloat = 2 - static let xSmall: CGFloat = 4 - static let small: CGFloat = 8 - static let medium: CGFloat = 12 - static let large: CGFloat = 16 - static let xLarge: CGFloat = 20 - static let xxLarge: CGFloat = 28 - static let xxxLarge: CGFloat = 36 - } +// MARK: - App-Specific Sizes - enum CornerRadius { - static let small: CGFloat = 8 - static let medium: CGFloat = 12 - static let large: CGFloat = 18 - static let xLarge: CGFloat = 24 - } - - enum BaseFontSize { - static let small: CGFloat = 12 - static let body: CGFloat = 15 - static let large: CGFloat = 18 - static let title: CGFloat = 24 - static let display: CGFloat = 30 - } - - enum Opacity { - static let subtle: Double = 0.1 - static let hint: Double = 0.2 - static let light: Double = 0.3 - static let medium: Double = 0.5 - static let accent: Double = 0.6 - static let strong: Double = 0.7 - static let heavy: Double = 0.85 - static let almostFull: Double = 0.95 - } - - enum LineWidth { - static let thin: CGFloat = 1 - static let medium: CGFloat = 2 - static let thick: CGFloat = 3 - } - - enum Shadow { - static let radiusSmall: CGFloat = 3 - static let radiusMedium: CGFloat = 8 - static let radiusLarge: CGFloat = 14 - static let offsetNone: CGFloat = 0 - static let offsetSmall: CGFloat = 2 - static let offsetMedium: CGFloat = 6 - } - - enum Animation { - static let quick: Double = 0.25 - static let springDuration: Double = 0.4 - static let staggerDelayShort: Double = 0.08 - static let staggerDelayMedium: Double = 0.16 - } - - enum Size { +extension Design { + /// BusinessCard-specific size constants. + enum CardSize { static let cardWidth: CGFloat = 320 static let cardHeight: CGFloat = 200 static let avatarSize: CGFloat = 56 @@ -71,13 +23,29 @@ enum Design { } } +// MARK: - Shadow Extensions + +extension Design.Shadow { + /// Zero offset for centered shadows. + static let offsetNone: CGFloat = 0 +} + +// MARK: - App Color Theme + +/// BusinessCard's light theme color palette. +/// Uses warm, professional tones suitable for a business card app. extension Color { + + // MARK: - App Backgrounds (Light Theme) + enum AppBackground { static let base = Color(red: 0.97, green: 0.96, blue: 0.94) static let elevated = Color(red: 1.0, green: 1.0, blue: 1.0) static let accent = Color(red: 0.95, green: 0.91, blue: 0.86) } - + + // MARK: - Card Theme Palette + enum CardPalette { static let coral = Color(red: 0.95, green: 0.35, blue: 0.33) static let midnight = Color(red: 0.12, green: 0.16, blue: 0.22) @@ -86,23 +54,42 @@ extension Color { static let violet = Color(red: 0.42, green: 0.36, blue: 0.62) static let sand = Color(red: 0.93, green: 0.83, blue: 0.68) } - - enum Accent { + + // MARK: - App Accent Colors + + enum AppAccent { static let red = Color(red: 0.95, green: 0.33, blue: 0.28) static let gold = Color(red: 0.95, green: 0.75, blue: 0.25) static let mint = Color(red: 0.2, green: 0.65, blue: 0.55) static let ink = Color(red: 0.12, green: 0.12, blue: 0.14) static let slate = Color(red: 0.29, green: 0.33, blue: 0.4) } - - enum Text { + + // MARK: - App Text Colors (Light Theme) + + enum AppText { static let primary = Color(red: 0.14, green: 0.14, blue: 0.17) static let secondary = Color(red: 0.32, green: 0.34, blue: 0.4) static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98) } - + + // MARK: - Badge Colors + enum Badge { static let star = Color(red: 0.98, green: 0.82, blue: 0.34) static let neutral = Color(red: 0.89, green: 0.89, blue: 0.9) } } + +// MARK: - Typealiases for easier migration + +// These typealiases allow existing code to continue using the old names +// while we gradually migrate to the new naming convention. + +extension Color { + /// Legacy alias - use AppAccent instead + typealias Accent = AppAccent + + /// Legacy alias - use AppText instead + typealias Text = AppText +} diff --git a/BusinessCard/Protocols/QRCodeProviding.swift b/BusinessCard/Protocols/QRCodeProviding.swift deleted file mode 100644 index 8f59d29..0000000 --- a/BusinessCard/Protocols/QRCodeProviding.swift +++ /dev/null @@ -1,5 +0,0 @@ -import CoreGraphics - -protocol QRCodeProviding { - func qrCode(from payload: String) -> CGImage? -} diff --git a/BusinessCard/Services/QRCodeService.swift b/BusinessCard/Services/QRCodeService.swift deleted file mode 100644 index 8c8a40b..0000000 --- a/BusinessCard/Services/QRCodeService.swift +++ /dev/null @@ -1,17 +0,0 @@ -import CoreImage -import CoreImage.CIFilterBuiltins -import CoreGraphics - -struct QRCodeService: QRCodeProviding { - private let context = CIContext() - - func qrCode(from payload: String) -> CGImage? { - let data = Data(payload.utf8) - let filter = CIFilter.qrCodeGenerator() - filter.setValue(data, forKey: "inputMessage") - filter.correctionLevel = "M" - guard let outputImage = filter.outputImage else { return nil } - let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) - return context.createCGImage(scaledImage, from: scaledImage.extent) - } -} diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift index 96e85ad..dfb7d95 100644 --- a/BusinessCard/State/AppState.swift +++ b/BusinessCard/State/AppState.swift @@ -9,12 +9,10 @@ final class AppState { var cardStore: CardStore var contactsStore: ContactsStore let shareLinkService: ShareLinkProviding - let qrCodeService: QRCodeProviding init(modelContext: ModelContext) { self.cardStore = CardStore(modelContext: modelContext) self.contactsStore = ContactsStore(modelContext: modelContext) self.shareLinkService = ShareLinkService() - self.qrCodeService = QRCodeService() } } diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift index 26b7128..a94c1ab 100644 --- a/BusinessCard/Views/BusinessCardView.swift +++ b/BusinessCard/Views/BusinessCardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct BusinessCardView: View { @@ -37,6 +38,8 @@ struct BusinessCardView: View { } } +// MARK: - Layout Variants + private struct StackedCardLayout: View { let card: BusinessCard @@ -66,7 +69,7 @@ private struct SplitCardLayout: View { } } Spacer(minLength: Design.Spacing.medium) - CardAccentBlockView(color: card.theme.accentColor) + AccentBlockView(color: card.theme.accentColor) } } } @@ -84,7 +87,7 @@ private struct PhotoCardLayout: View { } } Spacer(minLength: Design.Spacing.medium) - CardAvatarBadgeView( + AvatarBadgeView( systemName: card.avatarSystemName, accentColor: card.theme.accentColor, photoData: card.photoData @@ -93,12 +96,14 @@ private struct PhotoCardLayout: View { } } +// MARK: - Card Sections + private struct CardHeaderView: View { let card: BusinessCard var body: some View { HStack(spacing: Design.Spacing.medium) { - CardAvatarBadgeView( + AvatarBadgeView( systemName: card.avatarSystemName, accentColor: card.theme.accentColor, photoData: card.photoData @@ -124,7 +129,7 @@ private struct CardHeaderView: View { .foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) } Spacer(minLength: Design.Spacing.small) - CardLabelBadgeView(label: card.label, accentColor: card.theme.accentColor) + LabelBadgeView(label: card.label, accentColor: card.theme.accentColor) } } } @@ -135,13 +140,13 @@ private struct CardDetailsView: View { var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { if !card.email.isEmpty { - InfoRowView(systemImage: "envelope", text: card.email) + IconRowView(systemImage: "envelope", text: card.email) } if !card.phone.isEmpty { - InfoRowView(systemImage: "phone", text: card.phone) + IconRowView(systemImage: "phone", text: card.phone) } if !card.website.isEmpty { - InfoRowView(systemImage: "link", text: card.website) + IconRowView(systemImage: "link", text: card.website) } if !card.bio.isEmpty { Text(card.bio) @@ -182,6 +187,8 @@ private struct SocialLinksRow: View { } } +// MARK: - Small Components + private struct SocialIconView: View { let systemImage: String @@ -195,30 +202,13 @@ private struct SocialIconView: View { } } -private struct InfoRowView: View { - let systemImage: String - let text: String - - var body: some View { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: systemImage) - .font(.caption) - .foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.heavy)) - Text(text) - .font(.caption) - .foregroundStyle(Color.Text.inverted) - .lineLimit(1) - } - } -} - -private struct CardAccentBlockView: View { +private struct AccentBlockView: View { let color: Color var body: some View { RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .fill(color) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) + .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .overlay( Image(systemName: "bolt.fill") .foregroundStyle(Color.Text.inverted) @@ -226,53 +216,7 @@ private struct CardAccentBlockView: View { } } -private struct CardAvatarBadgeView: View { - let systemName: String - let accentColor: Color - let photoData: Data? - - var body: some View { - if let photoData, let uiImage = UIImage(data: photoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .clipShape(.circle) - .overlay( - Circle() - .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) - ) - } else { - Circle() - .fill(Color.Text.inverted) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .overlay( - Image(systemName: systemName) - .foregroundStyle(accentColor) - ) - .overlay( - Circle() - .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) - ) - } - } -} - -private struct CardLabelBadgeView: View { - let label: String - let accentColor: Color - - var body: some View { - Text(String.localized(label)) - .font(.caption) - .bold() - .foregroundStyle(Color.Text.inverted) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(accentColor.opacity(Design.Opacity.medium)) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - } -} +// MARK: - Preview #Preview { let container = try! ModelContainer(for: BusinessCard.self, Contact.self) diff --git a/BusinessCard/Views/CardCarouselView.swift b/BusinessCard/Views/CardCarouselView.swift index cae77df..d1a5399 100644 --- a/BusinessCard/Views/CardCarouselView.swift +++ b/BusinessCard/Views/CardCarouselView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct CardCarouselView: View { @@ -23,7 +24,7 @@ struct CardCarouselView: View { } } .tabViewStyle(.page) - .frame(height: Design.Size.cardHeight + Design.Spacing.xxLarge) + .frame(height: Design.CardSize.cardHeight + Design.Spacing.xxLarge) if let selected = cardStore.selectedCard { CardDefaultToggleView(card: selected) { diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift index c42ae08..1cca0da 100644 --- a/BusinessCard/Views/CardEditorView.swift +++ b/BusinessCard/Views/CardEditorView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData import PhotosUI @@ -10,35 +11,35 @@ struct CardEditorView: View { let onSave: (BusinessCard) -> Void // Basic info - @State private var displayName: String = "" - @State private var role: String = "" - @State private var company: String = "" - @State private var label: String = "Work" - @State private var pronouns: String = "" - @State private var bio: String = "" + @State private var displayName = "" + @State private var role = "" + @State private var company = "" + @State private var label = "Work" + @State private var pronouns = "" + @State private var bio = "" // Contact details - @State private var email: String = "" - @State private var phone: String = "" - @State private var website: String = "" - @State private var location: String = "" + @State private var email = "" + @State private var phone = "" + @State private var website = "" + @State private var location = "" // Social media - @State private var linkedIn: String = "" - @State private var twitter: String = "" - @State private var instagram: String = "" - @State private var facebook: String = "" - @State private var tiktok: String = "" - @State private var github: String = "" + @State private var linkedIn = "" + @State private var twitter = "" + @State private var instagram = "" + @State private var facebook = "" + @State private var tiktok = "" + @State private var github = "" // Custom links - @State private var customLink1Title: String = "" - @State private var customLink1URL: String = "" - @State private var customLink2Title: String = "" - @State private var customLink2URL: String = "" + @State private var customLink1Title = "" + @State private var customLink1URL = "" + @State private var customLink2Title = "" + @State private var customLink2URL = "" // Appearance - @State private var avatarSystemName: String = "person.crop.circle" + @State private var avatarSystemName = "person.crop.circle" @State private var selectedTheme: CardTheme = .coral @State private var selectedLayout: CardLayoutStyle = .stacked @@ -47,7 +48,6 @@ struct CardEditorView: View { @State private var photoData: Data? private var isEditing: Bool { card != nil } - private var isFormValid: Bool { !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -55,168 +55,23 @@ struct CardEditorView: View { var body: some View { NavigationStack { Form { - Section { - CardPreviewSection( - displayName: displayName.isEmpty ? String.localized("Your Name") : displayName, - role: role.isEmpty ? String.localized("Your Role") : role, - company: company.isEmpty ? String.localized("Company") : company, - label: label, - avatarSystemName: avatarSystemName, - theme: selectedTheme, - layoutStyle: selectedLayout, - photoData: photoData - ) - } - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - - Section(String.localized("Photo")) { - PhotoPickerRow( - selectedPhoto: $selectedPhoto, - photoData: $photoData, - avatarSystemName: avatarSystemName - ) - } - - Section(String.localized("Personal Information")) { - TextField(String.localized("Full Name"), text: $displayName) - .textContentType(.name) - - TextField(String.localized("Pronouns"), text: $pronouns) - .accessibilityHint(String.localized("e.g. she/her, he/him, they/them")) - - TextField(String.localized("Role / Title"), text: $role) - .textContentType(.jobTitle) - - TextField(String.localized("Company"), text: $company) - .textContentType(.organizationName) - - TextField(String.localized("Card Label"), text: $label) - .accessibilityHint(String.localized("A short label like Work or Personal")) - - TextField(String.localized("Bio"), text: $bio, axis: .vertical) - .lineLimit(3...6) - .accessibilityHint(String.localized("A short description about yourself")) - } - - Section(String.localized("Contact Details")) { - TextField(String.localized("Email"), text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - - TextField(String.localized("Phone"), text: $phone) - .textContentType(.telephoneNumber) - .keyboardType(.phonePad) - - TextField(String.localized("Website"), text: $website) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - - TextField(String.localized("Location"), text: $location) - .textContentType(.fullStreetAddress) - } - - Section(String.localized("Social Media")) { - SocialLinkField( - title: "LinkedIn", - placeholder: "linkedin.com/in/username", - systemImage: "link", - text: $linkedIn - ) - - SocialLinkField( - title: "Twitter / X", - placeholder: "twitter.com/username", - systemImage: "at", - text: $twitter - ) - - SocialLinkField( - title: "Instagram", - placeholder: "instagram.com/username", - systemImage: "camera", - text: $instagram - ) - - SocialLinkField( - title: "Facebook", - placeholder: "facebook.com/username", - systemImage: "person.2", - text: $facebook - ) - - SocialLinkField( - title: "TikTok", - placeholder: "tiktok.com/@username", - systemImage: "play.rectangle", - text: $tiktok - ) - - SocialLinkField( - title: "GitHub", - placeholder: "github.com/username", - systemImage: "chevron.left.forwardslash.chevron.right", - text: $github - ) - } - - Section(String.localized("Custom Links")) { - VStack(alignment: .leading, spacing: Design.Spacing.small) { - TextField(String.localized("Link 1 Title"), text: $customLink1Title) - TextField(String.localized("Link 1 URL"), text: $customLink1URL) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - } - - VStack(alignment: .leading, spacing: Design.Spacing.small) { - TextField(String.localized("Link 2 Title"), text: $customLink2Title) - TextField(String.localized("Link 2 URL"), text: $customLink2URL) - .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - } - } - - Section(String.localized("Appearance")) { - AvatarPickerRow(selection: $avatarSystemName) - - Picker(String.localized("Theme"), selection: $selectedTheme) { - ForEach(CardTheme.all) { theme in - HStack { - Circle() - .fill(theme.primaryColor) - .frame(width: Design.Spacing.large, height: Design.Spacing.large) - Text(theme.localizedName) - } - .tag(theme) - } - } - - Picker(String.localized("Layout"), selection: $selectedLayout) { - ForEach(CardLayoutStyle.allCases) { layout in - Text(layout.displayName) - .tag(layout) - } - } - } + previewSection + photoSection + personalSection + contactSection + socialSection + customLinksSection + appearanceSection } .navigationTitle(isEditing ? String.localized("Edit Card") : String.localized("New Card")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(String.localized("Cancel")) { - dismiss() - } + Button(String.localized("Cancel")) { dismiss() } } - ToolbarItem(placement: .confirmationAction) { - Button(String.localized("Save")) { - saveCard() - } - .disabled(!isFormValid) + Button(String.localized("Save")) { saveCard() } + .disabled(!isFormValid) } } .onChange(of: selectedPhoto) { _, newValue in @@ -226,13 +81,133 @@ struct CardEditorView: View { } } } - .onAppear { - loadCardData() + .onAppear { loadCardData() } + } + } +} + +// MARK: - Form Sections + +private extension CardEditorView { + var previewSection: some View { + Section { + EditorCardPreview( + displayName: displayName.isEmpty ? String.localized("Your Name") : displayName, + role: role.isEmpty ? String.localized("Your Role") : role, + company: company.isEmpty ? String.localized("Company") : company, + label: label, + avatarSystemName: avatarSystemName, + theme: selectedTheme, + photoData: photoData + ) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + + var photoSection: some View { + Section(String.localized("Photo")) { + PhotoPickerRow( + selectedPhoto: $selectedPhoto, + photoData: $photoData, + avatarSystemName: avatarSystemName + ) + } + } + + var personalSection: some View { + Section(String.localized("Personal Information")) { + TextField(String.localized("Full Name"), text: $displayName) + .textContentType(.name) + TextField(String.localized("Pronouns"), text: $pronouns) + .accessibilityHint(String.localized("e.g. she/her, he/him, they/them")) + TextField(String.localized("Role / Title"), text: $role) + .textContentType(.jobTitle) + TextField(String.localized("Company"), text: $company) + .textContentType(.organizationName) + TextField(String.localized("Card Label"), text: $label) + .accessibilityHint(String.localized("A short label like Work or Personal")) + TextField(String.localized("Bio"), text: $bio, axis: .vertical) + .lineLimit(3...6) + .accessibilityHint(String.localized("A short description about yourself")) + } + } + + var contactSection: some View { + Section(String.localized("Contact Details")) { + TextField(String.localized("Email"), text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + TextField(String.localized("Phone"), text: $phone) + .textContentType(.telephoneNumber) + .keyboardType(.phonePad) + TextField(String.localized("Website"), text: $website) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + TextField(String.localized("Location"), text: $location) + .textContentType(.fullStreetAddress) + } + } + + var socialSection: some View { + Section(String.localized("Social Media")) { + SocialLinkField(title: "LinkedIn", placeholder: "linkedin.com/in/username", systemImage: "link", text: $linkedIn) + SocialLinkField(title: "Twitter / X", placeholder: "twitter.com/username", systemImage: "at", text: $twitter) + SocialLinkField(title: "Instagram", placeholder: "instagram.com/username", systemImage: "camera", text: $instagram) + SocialLinkField(title: "Facebook", placeholder: "facebook.com/username", systemImage: "person.2", text: $facebook) + SocialLinkField(title: "TikTok", placeholder: "tiktok.com/@username", systemImage: "play.rectangle", text: $tiktok) + SocialLinkField(title: "GitHub", placeholder: "github.com/username", systemImage: "chevron.left.forwardslash.chevron.right", text: $github) + } + } + + var customLinksSection: some View { + Section(String.localized("Custom Links")) { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + TextField(String.localized("Link 1 Title"), text: $customLink1Title) + TextField(String.localized("Link 1 URL"), text: $customLink1URL) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + } + VStack(alignment: .leading, spacing: Design.Spacing.small) { + TextField(String.localized("Link 2 Title"), text: $customLink2Title) + TextField(String.localized("Link 2 URL"), text: $customLink2URL) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) } } } - private func loadCardData() { + var appearanceSection: some View { + Section(String.localized("Appearance")) { + AvatarPickerRow(selection: $avatarSystemName) + Picker(String.localized("Theme"), selection: $selectedTheme) { + ForEach(CardTheme.all) { theme in + HStack { + Circle() + .fill(theme.primaryColor) + .frame(width: Design.Spacing.large, height: Design.Spacing.large) + Text(theme.localizedName) + } + .tag(theme) + } + } + Picker(String.localized("Layout"), selection: $selectedLayout) { + ForEach(CardLayoutStyle.allCases) { layout in + Text(layout.displayName).tag(layout) + } + } + } + } +} + +// MARK: - Data Operations + +private extension CardEditorView { + func loadCardData() { guard let card else { return } displayName = card.displayName role = card.role @@ -260,18 +235,18 @@ struct CardEditorView: View { photoData = card.photoData } - private func saveCard() { + func saveCard() { if let existingCard = card { - updateExistingCard(existingCard) + updateCard(existingCard) onSave(existingCard) } else { - let newCard = createNewCard() + let newCard = createCard() onSave(newCard) } dismiss() } - private func updateExistingCard(_ card: BusinessCard) { + func updateCard(_ card: BusinessCard) { card.displayName = displayName card.role = role card.company = company @@ -298,37 +273,21 @@ struct CardEditorView: View { card.photoData = photoData } - private func createNewCard() -> BusinessCard { + func createCard() -> BusinessCard { BusinessCard( - displayName: displayName, - role: role, - company: company, - label: label, - email: email, - phone: phone, - website: website, - location: location, - isDefault: false, - themeName: selectedTheme.name, - layoutStyleRawValue: selectedLayout.rawValue, - avatarSystemName: avatarSystemName, - pronouns: pronouns, - bio: bio, - linkedIn: linkedIn, - twitter: twitter, - instagram: instagram, - facebook: facebook, - tiktok: tiktok, - github: github, - customLink1Title: customLink1Title, - customLink1URL: customLink1URL, - customLink2Title: customLink2Title, - customLink2URL: customLink2URL, - photoData: photoData + displayName: displayName, role: role, company: company, label: label, + email: email, phone: phone, website: website, location: location, + isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue, + avatarSystemName: avatarSystemName, pronouns: pronouns, bio: bio, + linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook, + tiktok: tiktok, github: github, customLink1Title: customLink1Title, customLink1URL: customLink1URL, + customLink2Title: customLink2Title, customLink2URL: customLink2URL, photoData: photoData ) } } +// MARK: - Supporting Views + private struct PhotoPickerRow: View { @Binding var selectedPhoto: PhotosPickerItem? @Binding var photoData: Data? @@ -336,27 +295,17 @@ private struct PhotoPickerRow: View { var body: some View { HStack(spacing: Design.Spacing.medium) { - if let photoData, let uiImage = UIImage(data: photoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .clipShape(.circle) - } else { - Image(systemName: avatarSystemName) - .font(.title) - .foregroundStyle(Color.Accent.red) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .background(Color.AppBackground.accent) - .clipShape(.circle) - } + AvatarBadgeView( + systemName: avatarSystemName, + accentColor: Color.Accent.red, + photoData: photoData + ) VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { PhotosPicker(selection: $selectedPhoto, matching: .images) { Text(photoData == nil ? String.localized("Add Photo") : String.localized("Change Photo")) .foregroundStyle(Color.Accent.red) } - if photoData != nil { Button(String.localized("Remove Photo"), role: .destructive) { photoData = nil @@ -382,7 +331,6 @@ private struct SocialLinkField: View { Image(systemName: systemImage) .foregroundStyle(Color.Accent.red) .frame(width: Design.Spacing.xLarge) - TextField(title, text: $text, prompt: Text(placeholder)) .textContentType(.URL) .keyboardType(.URL) @@ -392,96 +340,40 @@ private struct SocialLinkField: View { } } -private struct CardPreviewSection: View { +private struct EditorCardPreview: View { let displayName: String let role: String let company: String let label: String let avatarSystemName: String let theme: CardTheme - let layoutStyle: CardLayoutStyle let photoData: Data? var body: some View { - VStack(spacing: Design.Spacing.medium) { - previewCard - } - .padding(.vertical, Design.Spacing.medium) - } - - private var previewCard: some View { VStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) { - avatarView - + AvatarBadgeView( + systemName: avatarSystemName, + accentColor: theme.accentColor, + photoData: photoData + ) VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(displayName) - .font(.headline) - .bold() - .foregroundStyle(Color.Text.inverted) - Text(role) - .font(.subheadline) - .foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull)) - Text(company) - .font(.caption) - .foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) + Text(displayName).font(.headline).bold().foregroundStyle(Color.Text.inverted) + Text(role).font(.subheadline).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull)) + Text(company).font(.caption).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) } - Spacer(minLength: Design.Spacing.small) - - Text(String.localized(label)) - .font(.caption) - .bold() - .foregroundStyle(Color.Text.inverted) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(theme.accentColor.opacity(Design.Opacity.medium)) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + LabelBadgeView(label: label, accentColor: theme.accentColor) } } .padding(Design.Spacing.large) .frame(maxWidth: .infinity) .background( - LinearGradient( - colors: [theme.primaryColor, theme.secondaryColor], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing) ) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) - .shadow( - color: Color.Text.secondary.opacity(Design.Opacity.hint), - radius: Design.Shadow.radiusLarge, - x: Design.Shadow.offsetNone, - y: Design.Shadow.offsetMedium - ) - } - - @ViewBuilder - private var avatarView: some View { - if let photoData, let uiImage = UIImage(data: photoData) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .clipShape(.circle) - .overlay( - Circle() - .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) - ) - } else { - Circle() - .fill(Color.Text.inverted) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .overlay( - Image(systemName: avatarSystemName) - .foregroundStyle(theme.accentColor) - ) - .overlay( - Circle() - .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) - ) - } + .shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusLarge, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetMedium) + .padding(.vertical, Design.Spacing.medium) } } @@ -489,16 +381,8 @@ private struct AvatarPickerRow: View { @Binding var selection: String private let avatarOptions = [ - "person.crop.circle", - "person.crop.circle.fill", - "person.crop.square", - "person.circle", - "sparkles", - "music.mic", - "briefcase.fill", - "building.2.fill", - "star.fill", - "bolt.fill" + "person.crop.circle", "person.crop.circle.fill", "person.crop.square", "person.circle", "sparkles", + "music.mic", "briefcase.fill", "building.2.fill", "star.fill", "bolt.fill" ] var body: some View { @@ -506,16 +390,13 @@ private struct AvatarPickerRow: View { Text("Icon (if no photo)") .font(.subheadline) .foregroundStyle(Color.Text.secondary) - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) { ForEach(avatarOptions, id: \.self) { icon in - Button { - selection = icon - } label: { + Button { selection = icon } label: { Image(systemName: icon) .font(.title2) .foregroundStyle(selection == icon ? Color.Accent.red : Color.Text.secondary) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) + .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .background(selection == icon ? Color.AppBackground.accent : Color.AppBackground.base) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } @@ -528,6 +409,8 @@ private struct AvatarPickerRow: View { } } +// MARK: - Preview + #Preview("New Card") { let container = try! ModelContainer(for: BusinessCard.self, Contact.self) return CardEditorView(card: nil) { _ in } diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift index f09a29c..6988c6b 100644 --- a/BusinessCard/Views/CardsHomeView.swift +++ b/BusinessCard/Views/CardsHomeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct CardsHomeView: View { diff --git a/BusinessCard/Views/Components/ActionRowView.swift b/BusinessCard/Views/Components/ActionRowView.swift new file mode 100644 index 0000000..fa0d7e4 --- /dev/null +++ b/BusinessCard/Views/Components/ActionRowView.swift @@ -0,0 +1,88 @@ +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) + .foregroundStyle(Color.Accent.red) + .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) + .font(.headline) + .foregroundStyle(Color.Text.primary) + + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(Color.Text.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Color.Text.secondary) + } + .padding(Design.Spacing.large) + .background(Color.AppBackground.elevated) + .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/Components/AvatarBadgeView.swift b/BusinessCard/Views/Components/AvatarBadgeView.swift new file mode 100644 index 0000000..9903b42 --- /dev/null +++ b/BusinessCard/Views/Components/AvatarBadgeView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import Bedrock + +/// Displays a circular avatar with either a photo or a system icon fallback. +/// Reusable across business cards, editor previews, and contact rows. +struct AvatarBadgeView: View { + let systemName: String + let accentColor: Color + let photoData: Data? + let size: CGFloat + + init( + systemName: String = "person.crop.circle", + accentColor: Color = Color.Accent.red, + photoData: Data? = nil, + size: CGFloat = Design.CardSize.avatarSize + ) { + self.systemName = systemName + self.accentColor = accentColor + self.photoData = photoData + self.size = size + } + + var body: some View { + if let photoData, let uiImage = UIImage(data: photoData) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(.circle) + .overlay( + Circle() + .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) + ) + } else { + Circle() + .fill(Color.Text.inverted) + .frame(width: size, height: size) + .overlay( + Image(systemName: systemName) + .foregroundStyle(accentColor) + ) + .overlay( + Circle() + .stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) + ) + } + } +} + +#Preview { + VStack(spacing: Design.Spacing.large) { + AvatarBadgeView( + systemName: "person.crop.circle", + accentColor: Color.CardPalette.coral + ) + + AvatarBadgeView( + systemName: "briefcase.fill", + accentColor: Color.CardPalette.ocean, + size: 80 + ) + } + .padding() + .background(Color.CardPalette.midnight) +} diff --git a/BusinessCard/Views/Components/IconRowView.swift b/BusinessCard/Views/Components/IconRowView.swift new file mode 100644 index 0000000..f230b26 --- /dev/null +++ b/BusinessCard/Views/Components/IconRowView.swift @@ -0,0 +1,36 @@ +import SwiftUI +import Bedrock + +/// A row with an icon and text, used for contact info display. +/// Supports both inverted (on dark) and standard (on light) styles. +struct IconRowView: View { + let systemImage: String + let text: String + var inverted: Bool = true + + var body: some View { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: systemImage) + .font(.caption) + .foregroundStyle(textColor.opacity(Design.Opacity.heavy)) + Text(text) + .font(.caption) + .foregroundStyle(textColor) + .lineLimit(1) + } + } + + private var textColor: Color { + inverted ? Color.Text.inverted : Color.Text.primary + } +} + +#Preview { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + IconRowView(systemImage: "envelope", text: "hello@example.com") + IconRowView(systemImage: "phone", text: "+1 555 123 4567") + IconRowView(systemImage: "link", text: "example.com", inverted: false) + } + .padding() + .background(Color.CardPalette.midnight) +} diff --git a/BusinessCard/Views/Components/LabelBadgeView.swift b/BusinessCard/Views/Components/LabelBadgeView.swift new file mode 100644 index 0000000..06afd5a --- /dev/null +++ b/BusinessCard/Views/Components/LabelBadgeView.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Bedrock + +/// A small badge displaying a label like "Work" or "Personal". +struct LabelBadgeView: View { + let label: String + let accentColor: Color + + var body: some View { + Text(String.localized(label)) + .font(.caption) + .bold() + .foregroundStyle(Color.Text.inverted) + .padding(.horizontal, Design.Spacing.small) + .padding(.vertical, Design.Spacing.xxSmall) + .background(accentColor.opacity(Design.Opacity.medium)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } +} + +#Preview { + HStack { + LabelBadgeView(label: "Work", accentColor: Color.CardPalette.coral) + LabelBadgeView(label: "Personal", accentColor: Color.CardPalette.ocean) + } + .padding() + .background(Color.CardPalette.midnight) +} diff --git a/BusinessCard/Views/ContactDetailView.swift b/BusinessCard/Views/ContactDetailView.swift index a95d864..2f7c09d 100644 --- a/BusinessCard/Views/ContactDetailView.swift +++ b/BusinessCard/Views/ContactDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct ContactDetailView: View { @@ -154,13 +155,13 @@ private struct ContactHeaderView: View { Image(uiImage: uiImage) .resizable() .scaledToFill() - .frame(width: Design.Size.qrSize / 2, height: Design.Size.qrSize / 2) + .frame(width: Design.CardSize.qrSize / 2, height: Design.CardSize.qrSize / 2) .clipShape(.circle) } else { Image(systemName: contact.avatarSystemName) .font(.system(size: Design.BaseFontSize.display)) .foregroundStyle(Color.Accent.red) - .frame(width: Design.Size.qrSize / 2, height: Design.Size.qrSize / 2) + .frame(width: Design.CardSize.qrSize / 2, height: Design.CardSize.qrSize / 2) .background(Color.AppBackground.accent) .clipShape(.circle) } diff --git a/BusinessCard/Views/ContactsView.swift b/BusinessCard/Views/ContactsView.swift index d94ebe0..d870bb3 100644 --- a/BusinessCard/Views/ContactsView.swift +++ b/BusinessCard/Views/ContactsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct ContactsView: View { @@ -202,13 +203,13 @@ private struct ContactAvatarView: View { Image(uiImage: uiImage) .resizable() .scaledToFill() - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) + .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } else { Image(systemName: contact.avatarSystemName) .font(.title2) .foregroundStyle(Color.Accent.red) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) + .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .background(Color.AppBackground.accent) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } diff --git a/BusinessCard/Views/CustomizeCardView.swift b/BusinessCard/Views/CustomizeCardView.swift index 5c11fe8..80a9132 100644 --- a/BusinessCard/Views/CustomizeCardView.swift +++ b/BusinessCard/Views/CustomizeCardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct CustomizeCardView: View { @@ -102,7 +103,7 @@ private struct CardStylePickerView: View { VStack(spacing: Design.Spacing.xSmall) { RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .fill(theme.primaryColor) - .frame(height: Design.Size.avatarSize) + .frame(height: Design.CardSize.avatarSize) .overlay( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) .stroke( diff --git a/BusinessCard/Views/EmptyStateView.swift b/BusinessCard/Views/EmptyStateView.swift index 2a1ad82..91c2a50 100644 --- a/BusinessCard/Views/EmptyStateView.swift +++ b/BusinessCard/Views/EmptyStateView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock struct EmptyStateView: View { let title: String diff --git a/BusinessCard/Views/HeroBannerView.swift b/BusinessCard/Views/HeroBannerView.swift index 950c613..0bf3b64 100644 --- a/BusinessCard/Views/HeroBannerView.swift +++ b/BusinessCard/Views/HeroBannerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock struct HeroBannerView: View { var body: some View { diff --git a/BusinessCard/Views/PrimaryActionButton.swift b/BusinessCard/Views/PrimaryActionButton.swift index b50f1e0..10e9633 100644 --- a/BusinessCard/Views/PrimaryActionButton.swift +++ b/BusinessCard/Views/PrimaryActionButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock struct PrimaryActionButton: View { let title: String diff --git a/BusinessCard/Views/QRCodeView.swift b/BusinessCard/Views/QRCodeView.swift index d26d77f..212d3d4 100644 --- a/BusinessCard/Views/QRCodeView.swift +++ b/BusinessCard/Views/QRCodeView.swift @@ -1,24 +1,14 @@ import SwiftUI +import Bedrock import SwiftData +/// A QR code view using Bedrock's QRCodeImageView. struct QRCodeView: View { - @Environment(AppState.self) private var appState let payload: String var body: some View { - if let image = appState.qrCodeService.qrCode(from: payload) { - Image(decorative: image, scale: 1) - .resizable() - .interpolation(.none) - .scaledToFit() - .accessibilityLabel(String.localized("QR code")) - } else { - Image(systemName: "qrcode") - .resizable() - .scaledToFit() - .foregroundStyle(Color.Text.secondary) - .padding(Design.Spacing.large) - } + QRCodeImageView(payload: payload) + .accessibilityLabel(String.localized("QR code")) } } diff --git a/BusinessCard/Views/QRScannerView.swift b/BusinessCard/Views/QRScannerView.swift index 86ea91e..2ac59bd 100644 --- a/BusinessCard/Views/QRScannerView.swift +++ b/BusinessCard/Views/QRScannerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import AVFoundation struct QRScannerView: View { diff --git a/BusinessCard/Views/RootTabView.swift b/BusinessCard/Views/RootTabView.swift index 914cfb1..37348f2 100644 --- a/BusinessCard/Views/RootTabView.swift +++ b/BusinessCard/Views/RootTabView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct RootTabView: View { diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift index c6dc3c7..f5b71b9 100644 --- a/BusinessCard/Views/ShareCardView.swift +++ b/BusinessCard/Views/ShareCardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct ShareCardView: View { @@ -21,18 +22,13 @@ struct ShareCardView: View { if let card = appState.cardStore.selectedCard { QRCodeCardView(card: card) - ShareOptionsView( card: card, shareLinkService: appState.shareLinkService, showWallet: { showingWalletAlert = true }, - showNfc: { showingNfcAlert = true }, - onShareAction: { showingContactSheet = true } + showNfc: { showingNfcAlert = true } ) - - TrackContactButton { - showingContactSheet = true - } + TrackShareButton { showingContactSheet = true } } else { EmptyStateView( title: String.localized("No card selected"), @@ -61,110 +57,27 @@ struct ShareCardView: View { recipientRole: $recipientRole, recipientCompany: $recipientCompany ) { - if !recipientName.isEmpty, let card = appState.cardStore.selectedCard { - appState.contactsStore.recordShare( - for: recipientName, - role: recipientRole, - company: recipientCompany, - cardLabel: card.label - ) - recipientName = "" - recipientRole = "" - recipientCompany = "" - } + saveContact() } } } } + + private func saveContact() { + guard !recipientName.isEmpty, let card = appState.cardStore.selectedCard else { return } + appState.contactsStore.recordShare( + for: recipientName, + role: recipientRole, + company: recipientCompany, + cardLabel: card.label + ) + recipientName = "" + recipientRole = "" + recipientCompany = "" + } } -private struct TrackContactButton: View { - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: Design.Spacing.medium) { - Image(systemName: "person.badge.plus") - .foregroundStyle(Color.Accent.red) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .background(Color.AppBackground.accent) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text("Track this share") - .font(.headline) - .foregroundStyle(Color.Text.primary) - Text("Record who received your card") - .font(.subheadline) - .foregroundStyle(Color.Text.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundStyle(Color.Text.secondary) - } - .padding(Design.Spacing.large) - .background(Color.AppBackground.elevated) - .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) - } - .buttonStyle(.plain) - .accessibilityHint(String.localized("Opens a form to record who you shared your card with")) - } -} - -private struct RecordContactSheet: View { - @Environment(\.dismiss) private var dismiss - @Binding var recipientName: String - @Binding var recipientRole: String - @Binding var recipientCompany: String - let onSave: () -> Void - - private var isValid: Bool { - !recipientName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - var body: some View { - NavigationStack { - Form { - Section(String.localized("Recipient Details")) { - TextField(String.localized("Name"), text: $recipientName) - .textContentType(.name) - - TextField(String.localized("Role (optional)"), text: $recipientRole) - .textContentType(.jobTitle) - - TextField(String.localized("Company (optional)"), text: $recipientCompany) - .textContentType(.organizationName) - } - - Section { - Text("This person will appear in your Contacts tab so you can track who has your card.") - .font(.footnote) - .foregroundStyle(Color.Text.secondary) - } - } - .navigationTitle(String.localized("Track Share")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String.localized("Cancel")) { - dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button(String.localized("Save")) { - onSave() - dismiss() - } - .disabled(!isValid) - } - } - } - .presentationDetents([.medium]) - } -} +// MARK: - QR Code Display private struct QRCodeCardView: View { let card: BusinessCard @@ -172,7 +85,7 @@ private struct QRCodeCardView: View { var body: some View { VStack(spacing: Design.Spacing.medium) { QRCodeView(payload: card.vCardPayload) - .frame(width: Design.Size.qrSize, height: Design.Size.qrSize) + .frame(width: Design.CardSize.qrSize, height: Design.CardSize.qrSize) .padding(Design.Spacing.medium) .background(Color.AppBackground.elevated) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) @@ -194,52 +107,47 @@ private struct QRCodeCardView: View { } } +// MARK: - Share Options + private struct ShareOptionsView: View { let card: BusinessCard let shareLinkService: ShareLinkProviding let showWallet: () -> Void let showNfc: () -> Void - let onShareAction: () -> Void var body: some View { VStack(spacing: Design.Spacing.small) { - ShareOptionShareRow( + ShareOptionRow.share( title: String.localized("Copy link"), systemImage: "link", item: shareLinkService.shareURL(for: card) ) - - ShareOptionLinkRow( + ShareOptionRow.link( title: String.localized("Text your card"), systemImage: "message", url: shareLinkService.smsURL(for: card) ) - - ShareOptionLinkRow( + ShareOptionRow.link( title: String.localized("Email your card"), systemImage: "envelope", url: shareLinkService.emailURL(for: card) ) - - ShareOptionLinkRow( + ShareOptionRow.link( title: String.localized("Send via WhatsApp"), systemImage: "message.fill", url: shareLinkService.whatsappURL(for: card) ) - - ShareOptionLinkRow( + ShareOptionRow.link( title: String.localized("Send via LinkedIn"), systemImage: "link.circle", url: shareLinkService.linkedInURL(for: card) ) - - ShareOptionActionRow( + ShareOptionRow.action( title: String.localized("Add to Apple Wallet"), systemImage: "wallet.pass", action: showWallet ) - - ShareOptionActionRow( + ShareOptionRow.action( title: String.localized("Share via NFC"), systemImage: "dot.radiowaves.left.and.right", action: showNfc @@ -251,90 +159,75 @@ private struct ShareOptionsView: View { } } -private struct ShareOptionLinkRow: View { - let title: String - let systemImage: String - let url: URL +// MARK: - Track Button - var body: some View { - Link(destination: url) { - HStack(spacing: Design.Spacing.medium) { - ShareRowIcon(systemImage: systemImage) - Text(title) - .foregroundStyle(Color.Text.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(Color.Text.secondary) - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background(Color.AppBackground.base) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - } - .buttonStyle(.plain) - } -} - -private struct ShareOptionShareRow: View { - let title: String - let systemImage: String - let item: URL - - var body: some View { - ShareLink(item: item) { - HStack(spacing: Design.Spacing.medium) { - ShareRowIcon(systemImage: systemImage) - Text(title) - .foregroundStyle(Color.Text.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(Color.Text.secondary) - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background(Color.AppBackground.base) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - } - .buttonStyle(.plain) - } -} - -private struct ShareOptionActionRow: View { - let title: String - let systemImage: String +private struct TrackShareButton: View { let action: () -> Void - + var body: some View { Button(action: action) { - HStack(spacing: Design.Spacing.medium) { - ShareRowIcon(systemImage: systemImage) - Text(title) - .foregroundStyle(Color.Text.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(Color.Text.secondary) - } - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background(Color.AppBackground.base) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + ActionRowContent( + title: String.localized("Track this share"), + subtitle: String.localized("Record who received your card"), + systemImage: "person.badge.plus" + ) + } + .buttonStyle(.plain) + .accessibilityHint(String.localized("Opens a form to record who you shared your card with")) + } +} + +// MARK: - Share Option Row + +private enum ShareOptionRow { + static func link(title: String, systemImage: String, url: URL) -> some View { + Link(destination: url) { + RowContent(title: title, systemImage: systemImage) + } + .buttonStyle(.plain) + } + + static func share(title: String, systemImage: String, item: URL) -> some View { + ShareLink(item: item) { + RowContent(title: title, systemImage: systemImage) + } + .buttonStyle(.plain) + } + + static func action(title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + RowContent(title: title, systemImage: systemImage) } .buttonStyle(.plain) } } -private struct ShareRowIcon: View { +private struct RowContent: View { + let title: String let systemImage: String var body: some View { - Image(systemName: systemImage) - .foregroundStyle(Color.Accent.red) - .frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) - .background(Color.AppBackground.accent) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + HStack(spacing: Design.Spacing.medium) { + Image(systemName: systemImage) + .foregroundStyle(Color.Accent.red) + .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) + .background(Color.AppBackground.accent) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + Text(title) + .foregroundStyle(Color.Text.primary) + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(Color.Text.secondary) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(Color.AppBackground.base) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } } +// MARK: - Preview + #Preview { ShareCardView() .environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext)) diff --git a/BusinessCard/Views/Sheets/RecordContactSheet.swift b/BusinessCard/Views/Sheets/RecordContactSheet.swift new file mode 100644 index 0000000..6069dc9 --- /dev/null +++ b/BusinessCard/Views/Sheets/RecordContactSheet.swift @@ -0,0 +1,66 @@ +import SwiftUI +import Bedrock + +/// A sheet for recording contact details when sharing a card. +struct RecordContactSheet: View { + @Environment(\.dismiss) private var dismiss + @Binding var recipientName: String + @Binding var recipientRole: String + @Binding var recipientCompany: String + let onSave: () -> Void + + private var isValid: Bool { + !recipientName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section(String.localized("Recipient Details")) { + TextField(String.localized("Name"), text: $recipientName) + .textContentType(.name) + + TextField(String.localized("Role (optional)"), text: $recipientRole) + .textContentType(.jobTitle) + + TextField(String.localized("Company (optional)"), text: $recipientCompany) + .textContentType(.organizationName) + } + + Section { + Text("This person will appear in your Contacts tab so you can track who has your card.") + .font(.footnote) + .foregroundStyle(Color.Text.secondary) + } + } + .navigationTitle(String.localized("Track Share")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String.localized("Cancel")) { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button(String.localized("Save")) { + onSave() + dismiss() + } + .disabled(!isValid) + } + } + } + .presentationDetents([.medium]) + } +} + +#Preview { + RecordContactSheet( + recipientName: .constant(""), + recipientRole: .constant(""), + recipientCompany: .constant("") + ) { + print("Saved") + } +} diff --git a/BusinessCard/Views/WidgetsCalloutView.swift b/BusinessCard/Views/WidgetsCalloutView.swift index 84988e0..0573b20 100644 --- a/BusinessCard/Views/WidgetsCalloutView.swift +++ b/BusinessCard/Views/WidgetsCalloutView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock struct WidgetsCalloutView: View { var body: some View { diff --git a/BusinessCard/Views/WidgetsView.swift b/BusinessCard/Views/WidgetsView.swift index b6ddc6b..fca1dc7 100644 --- a/BusinessCard/Views/WidgetsView.swift +++ b/BusinessCard/Views/WidgetsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Bedrock import SwiftData struct WidgetsView: View { @@ -49,7 +50,7 @@ private struct PhoneWidgetPreview: View { HStack(spacing: Design.Spacing.medium) { QRCodeView(payload: card.vCardPayload) - .frame(width: Design.Size.widgetPhoneHeight, height: Design.Size.widgetPhoneHeight) + .frame(width: Design.CardSize.widgetPhoneHeight, height: Design.CardSize.widgetPhoneHeight) VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { Text(card.displayName) @@ -82,7 +83,7 @@ private struct WatchWidgetPreview: View { HStack(spacing: Design.Spacing.medium) { QRCodeView(payload: card.vCardPayload) - .frame(width: Design.Size.widgetWatchSize, height: Design.Size.widgetWatchSize) + .frame(width: Design.CardSize.widgetWatchSize, height: Design.CardSize.widgetWatchSize) VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { Text("Ready to scan") diff --git a/README.md b/README.md index e177959..c978f7d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with QR codes, quick share actions, customization, and contact tracking. Data syncs across devices via iCloud. ## Platforms + - iOS 26+ - watchOS 12+ - Swift 6.2 @@ -10,20 +11,22 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with ## Features ### My Cards + - Create and browse multiple cards in a carousel -- Create new cards with the "New Card" button - Set a default card for sharing - Preview bold card styles inspired by modern design - **Profile photos**: Add a photo from your library or use an icon - **Rich profiles**: Pronouns, bio, social media links, custom URLs ### Share + - QR code display for vCard payloads - Share options: copy link, SMS, email, WhatsApp, LinkedIn - **Track shares**: Record who received your card and when - Placeholder actions for Apple Wallet and NFC (alerts included) ### Customize + - Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet) - Layout picker for stacked, split, or photo style - **Edit all card details**: Name, role, company, email, phone, website, location @@ -32,6 +35,7 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with - **Delete cards** you no longer need ### Contacts + - Track who you've shared your card with - **Scan QR codes** to save someone else's business card - **Notes & annotations**: Add notes about each contact @@ -43,10 +47,12 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with - Swipe to delete contacts ### Widgets (Preview Only) + - Phone widget preview mock - Watch widget preview mock ### watchOS App + - Shows the default card QR code - Pick which card is the default on watch - **Syncs with iPhone** via App Groups @@ -54,56 +60,89 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with ## Data Sync ### iCloud Sync (iOS) + Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your data automatically syncs across all your iPhones and iPads signed into the same iCloud account. ### iPhone to Watch Sync + The iPhone app syncs card data to the paired Apple Watch via App Groups. When you create, edit, or delete cards on your iPhone, the changes appear on your watch. **Note**: The watch reads data from the iPhone. To update cards on the watch, make changes on the iPhone first. ## Architecture + - SwiftUI views are presentation only - Shared app state uses `@Observable` classes on `@MainActor` - SwiftData for persistence with CloudKit sync -- Protocol-oriented design for card data, sharing, contact tracking, and QR generation +- Protocol-oriented design for card data, sharing, and contact tracking - String Catalogs (`.xcstrings`) for localization (en, es-MX, fr-CA) +- **Bedrock package** for shared design constants and utilities + +## Dependencies + +### Bedrock (Local Package) + +The app uses the [Bedrock](ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git) package for: + +- **Design constants**: `Design.Spacing`, `Design.CornerRadius`, `Design.Opacity`, etc. +- **QR code generation**: `QRCodeGenerator`, `QRCodeImageView` +- **Reusable UI components**: Settings views, badges, effects + +App-specific extensions are in `Design/DesignConstants.swift`. ## Project Structure -- `BusinessCard/Models` — SwiftData card/contact models -- `BusinessCard/State` — observable app state (CardStore, ContactsStore) -- `BusinessCard/Services` — QR generation, share URLs, watch sync -- `BusinessCard/Views` — SwiftUI screens and components -- `BusinessCard/Design` — design constants and semantic colors -- `BusinessCard/Protocols` — protocol definitions -- `BusinessCardWatch/` — watchOS app target and assets + +``` +BusinessCard/ +├── Design/ # Design constants (extends Bedrock) +├── Localization/ # String helpers +├── Models/ # SwiftData models (BusinessCard, Contact) +├── Protocols/ # Protocol definitions +├── Resources/ # String Catalogs (.xcstrings) +├── Services/ # Share link service, watch sync +├── State/ # Observable stores (CardStore, ContactsStore) +└── Views/ # SwiftUI screens and components + +BusinessCardWatch/ # watchOS app target +BusinessCardTests/ # Unit tests +``` ## Configuration ### Required Capabilities **iOS Target:** + - iCloud (CloudKit enabled) - App Groups (`group.com.mbrucedogs.BusinessCard`) - Background Modes (Remote notifications) - Camera (for QR code scanning) **watchOS Target:** + - App Groups (`group.com.mbrucedogs.BusinessCard`) ### CloudKit Container + `iCloud.com.mbrucedogs.BusinessCard` ## Notes + - Share URLs are sample placeholders - Wallet/NFC flows are stubs with alerts only - Widget UI is a visual preview (not a WidgetKit extension) - First launch creates sample cards for demonstration ## Running -Open `BusinessCard.xcodeproj` in Xcode and build the iOS and watch targets. + +1. Open `BusinessCard.xcodeproj` in Xcode +2. Ensure Bedrock package is resolved (File → Packages → Resolve Package Versions) +3. Build and run on iOS Simulator or device ## Tests + Unit tests cover: + - vCard payload formatting - Default card selection - Contact search filtering @@ -117,3 +156,11 @@ Unit tests cover: - Adding received cards via QR scan Run tests with `Cmd+U` in Xcode. + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for planned features and implementation status. + +--- + +*Built with SwiftUI, SwiftData, and ❤️* diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..72c00e8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,99 @@ +# BusinessCard Roadmap + +This document tracks planned features and their implementation status. + +## ✅ Completed Features + +### High Priority (Core User Value) + +- [x] **More card fields** - Social media links, custom URLs, pronouns, bio + - Added: pronouns, bio, LinkedIn, Twitter/X, Instagram, Facebook, TikTok, GitHub + - Added: 2 custom link slots (title + URL) + - vCard payload includes social profiles + +- [x] **Profile photo support** - PhotosPicker integration + - Added PhotosPicker to CardEditorView + - Photos stored as Data with `@Attribute(.externalStorage)` + - Photos display on cards, in editor preview, and contact avatars + +- [x] **Contact notes/annotations** - Add notes, tags, follow-up reminders + - Added: notes field (free text) + - Added: tags (comma-separated, displayed as chips) + - Added: follow-up date with overdue indicators + - Added: "where you met" field + - Added: email and phone for received contacts + - Full ContactDetailView for editing annotations + +- [x] **Save received cards** - Scan a QR to add someone else's card + - Added QRScannerView with camera integration + - Parses vCard data from scanned QR codes + - Creates contacts marked as `isReceivedCard` + - "Scan Card" button in Contacts toolbar + +--- + +## 🔲 Planned Features + +### Medium Priority (Differentiation) + +- [ ] **Email signature export** - Generate HTML signature + - Generate professional HTML email signature from card data + - Copy to clipboard or share + - Multiple signature styles/templates + +- [ ] **Card analytics** - View/scan counts + - Would require backend infrastructure + - Track when cards are viewed/scanned + - Show analytics dashboard + +- [ ] **Virtual meeting background** - Generate image with QR + - Create background image with card info + QR code + - Export for Zoom, Teams, etc. + - Multiple background styles + +### Lower Priority (Advanced) + +- [ ] **Real NFC** - Write card to NFC tags + - Requires NFC entitlements + - Requires physical NFC cards/tags + - Write vCard data to NFC + +- [ ] **Apple Wallet** - Add card to Wallet + - Requires PKPass generation + - May need backend for signing passes + - Display QR in Wallet app + +- [ ] **Team features** - Shared team cards + - Requires user accounts + - Requires backend infrastructure + - Team branding, shared templates + +--- + +## 🔧 Technical Improvements + +### Completed + +- [x] **SwiftData persistence** with CloudKit sync +- [x] **Bedrock integration** - Design system, QR code generator +- [x] **iOS-Watch sync** via App Groups +- [x] **Unit tests** for models, stores, and new features + +### Planned + +- [ ] **WidgetKit extension** - Real home screen widgets +- [ ] **Spotlight indexing** - Search cards from iOS search +- [ ] **Siri shortcuts** - "Share my work card" +- [ ] **App Intents** - iOS 17+ action button support + +--- + +## 📝 Notes + +- Features marked with 🔲 are planned but not yet implemented +- Features requiring backend are deferred until infrastructure is available +- Priority may shift based on user feedback + +--- + +*Last updated: January 2026* diff --git a/ai_implmentation.md b/ai_implmentation.md index 20b4f18..8f730c4 100644 --- a/ai_implmentation.md +++ b/ai_implmentation.md @@ -3,80 +3,140 @@ This file summarizes project-specific context, architecture, and conventions to speed up future AI work. ## Project Summary + BusinessCard is a SwiftUI app for building and sharing digital business cards with QR codes. It includes iOS screens for cards, sharing, customization, contact tracking, and widget previews, plus a watchOS companion to show a default card QR code. ## Key Constraints + - iOS 26+, watchOS 12+, Swift 6.2. - SwiftUI with `@Observable` classes and `@MainActor`. - Protocol‑oriented architecture is prioritized. - No UIKit unless explicitly requested. - String Catalogs only (`.xcstrings`). -- No magic numbers in views; use design constants. +- No magic numbers in views; use Bedrock's `Design` constants. +- Uses **Bedrock** package for shared design system and utilities. ## Core Data Flow + - `AppState` owns: - `CardStore` (cards and selection) - `ContactsStore` (contact list + search) - `ShareLinkService` (share URLs) - - `QRCodeService` (QR generation) +- **SwiftData** with CloudKit for persistence and sync. +- **App Groups** for iOS-Watch data sharing. - Views read state via environment and render UI only. +## Dependencies + +### Bedrock Package + +Located at `/Frameworks/Bedrock` (local package). Provides: + +- `Design.Spacing`, `Design.CornerRadius`, `Design.Opacity`, etc. +- `QRCodeGenerator` and `QRCodeImageView` for QR codes +- Reusable settings components + +App-specific extensions are in `Design/DesignConstants.swift`: +- `Design.CardSize` - card dimensions, avatar, QR sizes +- `Design.Shadow.offsetNone` - zero offset extension +- `Color.AppBackground`, `Color.CardPalette`, `Color.AppAccent`, `Color.AppText` + ## Important Files ### Models -- `BusinessCard/Models/BusinessCard.swift` — business card data + vCard payload -- `BusinessCard/Models/Contact.swift` — contact tracking model + +- `BusinessCard/Models/BusinessCard.swift` — SwiftData model with: + - Basic fields: name, role, company, email, phone, website, location + - Rich fields: pronouns, bio, social links (LinkedIn, Twitter, Instagram, etc.) + - Custom links: 2 slots for custom URLs + - Photo: `photoData` stored with `@Attribute(.externalStorage)` + - Computed: `theme`, `layoutStyle`, `vCardPayload`, `hasSocialLinks` + +- `BusinessCard/Models/Contact.swift` — SwiftData model with: + - Basic fields: name, role, company + - Annotations: notes, tags (comma-separated), followUpDate, metAt + - Received cards: isReceivedCard, receivedCardData (vCard) + - Photo: `photoData` + - Computed: `tagList`, `hasFollowUp`, `isFollowUpOverdue` + - Static: `fromVCard(_:)` parser + - `BusinessCard/Models/CardTheme.swift` — card theme palette - `BusinessCard/Models/CardLayoutStyle.swift` — stacked/split/photo ### Protocols (POP) + - `BusinessCard/Protocols/BusinessCardProviding.swift` - `BusinessCard/Protocols/ContactTracking.swift` -- `BusinessCard/Protocols/QRCodeProviding.swift` - `BusinessCard/Protocols/ShareLinkProviding.swift` ### State -- `BusinessCard/State/AppState.swift` -- `BusinessCard/State/CardStore.swift` -- `BusinessCard/State/ContactsStore.swift` + +- `BusinessCard/State/AppState.swift` — central state container +- `BusinessCard/State/CardStore.swift` — card CRUD, selection, watch sync +- `BusinessCard/State/ContactsStore.swift` — contacts, search, received cards ### Services -- `BusinessCard/Services/QRCodeService.swift` — CoreImage QR generation + - `BusinessCard/Services/ShareLinkService.swift` — share URL helpers +- `BusinessCard/Services/WatchSyncService.swift` — App Group sync to watch ### Views -- `BusinessCard/Views/RootTabView.swift` — tabbed shell -- `BusinessCard/Views/CardsHomeView.swift` — hero + card carousel -- `BusinessCard/Views/ShareCardView.swift` — QR + share actions -- `BusinessCard/Views/CustomizeCardView.swift` — theme/layout controls -- `BusinessCard/Views/ContactsView.swift` — tracking list + search -- `BusinessCard/Views/WidgetsView.swift` — preview mockups + +- `RootTabView.swift` — tabbed shell +- `CardsHomeView.swift` — hero + card carousel +- `CardEditorView.swift` — create/edit cards with PhotosPicker +- `BusinessCardView.swift` — card display with photo and social icons +- `ShareCardView.swift` — QR + share actions + track share +- `CustomizeCardView.swift` — theme/layout controls +- `ContactsView.swift` — tracking list with sections +- `ContactDetailView.swift` — full contact view with annotations +- `QRScannerView.swift` — camera-based QR scanner +- `QRCodeView.swift` — wrapper for Bedrock's QRCodeImageView +- `WidgetsView.swift` — preview mockups ### Design + Localization -- `BusinessCard/Design/DesignConstants.swift` + +- `BusinessCard/Design/DesignConstants.swift` — extends Bedrock - `BusinessCard/Resources/Localizable.xcstrings` +- `BusinessCard/Localization/String+Localization.swift` ### watchOS + - `BusinessCardWatch/BusinessCardWatchApp.swift` - `BusinessCardWatch/Views/WatchContentView.swift` - `BusinessCardWatch/State/WatchCardStore.swift` - `BusinessCardWatch/Resources/Localizable.xcstrings` ## Localization + - All user-facing strings are in `.xcstrings`. - Supported locales: en, es‑MX, fr‑CA. - Use `String.localized("Key")` for non-Text strings. ## Testing -- `BusinessCardTests/BusinessCardTests.swift` includes basic unit tests. + +- `BusinessCardTests/BusinessCardTests.swift` covers: + - vCard payload formatting + - Card CRUD operations + - Contact search and filtering + - Social links detection + - Contact notes/tags + - Follow-up status + - vCard parsing for received cards ## Known Stubs / TODOs + - Apple Wallet and NFC flows are alert-only placeholders. - Share URLs are sample placeholders. - Widget previews are not WidgetKit extensions. +- See `ROADMAP.md` for full feature status. ## If You Extend The App -- Add new strings to the String Catalogs. -- Add new constants to `DesignConstants.swift` instead of literals. -- Keep view logic UI-only; push business logic to state classes. -- Prefer protocols for new capabilities. + +1. Add new strings to the String Catalogs. +2. Use `Design.*` from Bedrock for spacing, opacity, etc. +3. Add app-specific constants to `DesignConstants.swift`. +4. Keep view logic UI-only; push business logic to state classes. +5. Prefer protocols for new capabilities. +6. Add unit tests for new model logic. +7. Update `ROADMAP.md` when adding features.