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

This commit is contained in:
Matt Bruce 2026-01-08 18:23:39 -06:00
parent 92b8f211bf
commit 9e87de5ce9
30 changed files with 1117 additions and 1137 deletions

626
Agents.md
View File

@ -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<State: Bettable & Observable>: 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.

View File

@ -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 */;
}

View File

@ -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)
@ -87,7 +55,9 @@ extension Color {
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)
@ -95,14 +65,31 @@ extension Color {
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
}

View File

@ -1,5 +0,0 @@
import CoreGraphics
protocol QRCodeProviding {
func qrCode(from payload: String) -> CGImage?
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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,21 +55,57 @@ struct CardEditorView: View {
var body: some View {
NavigationStack {
Form {
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() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Save")) { saveCard() }
.disabled(!isFormValid)
}
}
.onChange(of: selectedPhoto) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
photoData = data
}
}
}
.onAppear { loadCardData() }
}
}
}
// MARK: - Form Sections
private extension CardEditorView {
var previewSection: some View {
Section {
CardPreviewSection(
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,
layoutStyle: selectedLayout,
photoData: photoData
)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
var photoSection: some View {
Section(String.localized("Photo")) {
PhotoPickerRow(
selectedPhoto: $selectedPhoto,
@ -77,91 +113,56 @@ struct CardEditorView: View {
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)
}
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 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)
@ -170,7 +171,6 @@ struct CardEditorView: View {
.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)
@ -179,10 +179,11 @@ struct CardEditorView: View {
.textInputAutocapitalization(.never)
}
}
}
var appearanceSection: some View {
Section(String.localized("Appearance")) {
AvatarPickerRow(selection: $avatarSystemName)
Picker(String.localized("Theme"), selection: $selectedTheme) {
ForEach(CardTheme.all) { theme in
HStack {
@ -194,45 +195,19 @@ struct CardEditorView: View {
.tag(theme)
}
}
Picker(String.localized("Layout"), selection: $selectedLayout) {
ForEach(CardLayoutStyle.allCases) { layout in
Text(layout.displayName)
.tag(layout)
Text(layout.displayName).tag(layout)
}
}
}
}
.navigationTitle(isEditing ? String.localized("Edit Card") : String.localized("New Card"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String.localized("Cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String.localized("Save")) {
saveCard()
}
.disabled(!isFormValid)
}
}
.onChange(of: selectedPhoto) { _, newValue in
Task {
if let data = try? await newValue?.loadTransferable(type: Data.self) {
photoData = data
}
}
}
.onAppear {
loadCardData()
}
}
}
private func loadCardData() {
// 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 }

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
import SwiftData
struct CardsHomeView: View {

View File

@ -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<Action: View>: View {
let title: String
let subtitle: String?
let systemImage: String
@ViewBuilder let action: () -> Action
init(
title: String,
subtitle: String? = nil,
systemImage: String,
@ViewBuilder action: @escaping () -> Action
) {
self.title = title
self.subtitle = subtitle
self.systemImage = systemImage
self.action = action
}
var body: some View {
action()
.buttonStyle(.plain)
}
}
/// Content layout for action rows - icon, text, chevron.
struct ActionRowContent: View {
let title: String
let subtitle: String?
let systemImage: String
init(title: String, subtitle: String? = nil, systemImage: String) {
self.title = title
self.subtitle = subtitle
self.systemImage = systemImage
}
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: systemImage)
.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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
struct EmptyStateView: View {
let title: String

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
struct HeroBannerView: View {
var body: some View {

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
struct PrimaryActionButton: View {
let title: String

View File

@ -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()
QRCodeImageView(payload: payload)
.accessibilityLabel(String.localized("QR code"))
} else {
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.foregroundStyle(Color.Text.secondary)
.padding(Design.Spacing.large)
}
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
import AVFoundation
struct QRScannerView: View {

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
import SwiftData
struct RootTabView: View {

View File

@ -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,7 +57,14 @@ struct ShareCardView: View {
recipientRole: $recipientRole,
recipientCompany: $recipientCompany
) {
if !recipientName.isEmpty, let card = appState.cardStore.selectedCard {
saveContact()
}
}
}
}
private func saveContact() {
guard !recipientName.isEmpty, let card = appState.cardStore.selectedCard else { return }
appState.contactsStore.recordShare(
for: recipientName,
role: recipientRole,
@ -73,98 +76,8 @@ struct ShareCardView: View {
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,63 +159,60 @@ 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) {
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 RowContent: View {
let title: String
let systemImage: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
ShareRowIcon(systemImage: systemImage)
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()
@ -319,21 +224,9 @@ private struct ShareOptionActionRow: View {
.background(Color.AppBackground.base)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
}
}
private struct ShareRowIcon: View {
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))
}
}
// MARK: - Preview
#Preview {
ShareCardView()

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI
import Bedrock
struct WidgetsCalloutView: View {
var body: some View {

View File

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

View File

@ -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 ❤️*

99
ROADMAP.md Normal file
View File

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

View File

@ -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`.
- Protocoloriented 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, esMX, frCA.
- 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.