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. 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 - `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. 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.) - Target iOS 26.0 or later. (Yes, it definitely exists.)
- Swift 6.2 or later, using modern Swift concurrency. - Swift 6.2 or later, using modern Swift concurrency.
- SwiftUI backed up by `@Observable` classes for shared data. - 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. - Do not introduce third-party frameworks without asking first.
- Avoid UIKit unless requested. - 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-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. 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. 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. 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. 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`. 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 are candidates for protocol unification. 2. **Identify common interfaces**: Types that expose similar properties/methods should conform to a shared protocol.
3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended. 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. 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). - **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. - **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. - **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required.
### Examples ### Benefits
**❌ BAD - Concrete implementations without protocols:** - **Reusability**: Shared protocols work across features
```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
- **Testability**: Mock types can conform to protocols for unit testing - **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 - **Maintainability**: Fix a bug in a protocol extension, fix it everywhere
- **Discoverability**: Protocols document the expected interface clearly - **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`. - Always mark `@Observable` classes with `@MainActor`.
- Assume strict Swift concurrency rules are being applied. - 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 Swift-native alternatives to Foundation methods where they exist.
- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL. - Prefer modern Foundation API (e.g., `URL.documentsDirectory`, `appending(path:)`).
- 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. - Never use C-style number formatting; use `format:` modifiers instead.
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. - Prefer static member lookup to struct instances (`.circle` not `Circle()`).
- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. - Never use old-style GCD; use modern Swift concurrency.
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`. - Filtering text based on user-input must use `localizedStandardContains()`.
- Avoid force unwraps and force `try` unless it is unrecoverable. - Avoid force unwraps and force `try` unless unrecoverable.
## SwiftUI instructions ## SwiftUI Instructions
- Always use `foregroundStyle()` instead of `foregroundColor()`. - Always use `foregroundStyle()` instead of `foregroundColor()`.
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. - Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
- Always use the `Tab` API instead of `tabItem()`. - Always use the `Tab` API instead of `tabItem()`.
- Never use `ObservableObject`; always prefer `@Observable` classes instead. - Never use `ObservableObject`; always prefer `@Observable` classes.
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. - Never use `onChange()` in its 1-parameter variant.
- 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 `onTapGesture()` unless you need tap location/count; use `Button`.
- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. - Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)`.
- Never use `UIScreen.main.bounds` to read the size of the available space. - Never use `UIScreen.main.bounds` to read available space.
- Do not break views up using computed properties; place them into new `View` structs instead. - Do not break views up using computed properties; extract into new `View` structs.
- Do not force specific font sizes; prefer using Dynamic Type instead. - Do not force specific font sizes; prefer Dynamic Type.
- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`. - Use `NavigationStack` with `navigationDestination(for:)`.
- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`. - If using an image for a button label, always specify text alongside.
- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`. - Prefer `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)`. - Use `bold()` instead of `fontWeight(.bold)`.
- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`. - Avoid `GeometryReader` if newer alternatives work (e.g., `containerRelativeFrame()`).
- 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 enumerating in `ForEach`, don't convert to Array first.
- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. - Hide scroll indicators with `.scrollIndicators(.hidden)`.
- Avoid `AnyView` unless it is absolutely required. - Avoid `AnyView` unless 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 raw numeric literals** for padding, spacing, opacity, etc.—use Design constants.
- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in the `Color` extension in `DesignConstants.swift` with semantic names. - **Never use inline colors**—define all colors with semantic names.
- Avoid using UIKit colors in SwiftUI code. - Avoid UIKit colors in SwiftUI code.
## View/State separation (MVVM-lite) ## SwiftData Instructions
**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
If SwiftData is configured to use CloudKit: If SwiftData is configured to use CloudKit:
- Never use `@Attribute(.unique)`. - 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. - 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+. - Use **String Catalogs** (`.xcstrings` files) for localization.
- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings. - SwiftUI `Text("literal")` views automatically look up strings in the catalog.
- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension: - For strings outside of `Text` views, use `String(localized:)` or a helper extension.
```swift - Store all user-facing strings in the String Catalog.
extension String { - Support at minimum: English (en), Spanish-Mexico (es-MX), French-Canada (fr-CA).
static func localized(_ key: String) -> String { - Never use `NSLocalizedString`; prefer `String(localized:)`.
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.
## 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: ### Values That MUST Be Constants
- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)`
- **Spacing & Padding**: `Design.Spacing.medium` not `.padding(12)`
- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` - **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16`
- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` - **Font Sizes**: `Design.BaseFontSize.body` not `size: 14`
- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` - **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` - **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2`
- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` - **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10`
- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` - **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: ### Organization
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
### Examples of violations: - Create a `DesignConstants.swift` file using enums for namespacing.
```swift - Extend `Color` with semantic color definitions.
// ❌ BAD - Magic numbers everywhere - View-specific constants go at the top of the view struct with a comment.
.padding(16) - Name constants semantically: `accent` not `pointSix`, `large` not `sixteen`.
.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
```
## Design constants instructions ## Dynamic Type Instructions
- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing: - Always support Dynamic Type for accessibility.
```swift - Use `@ScaledMetric` to scale custom dimensions.
enum Design { - Choose appropriate `relativeTo` text styles based on semantic purpose.
enum Spacing { - For constrained UI elements, you may use fixed sizes but document the reason.
static let xxSmall: CGFloat = 2 - Prefer system text styles: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
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`.
## Dynamic Type instructions ## VoiceOver Accessibility Instructions
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling. - All interactive elements must have meaningful `.accessibilityLabel()`.
- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings: - Use `.accessibilityValue()` for dynamic state.
```swift - Use `.accessibilityHint()` to describe what happens on interaction.
struct MyView: View { - Use `.accessibilityAddTraits()` for element type.
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14 - Hide decorative elements with `.accessibilityHidden(true)`.
@ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24 - Group related elements to reduce navigation complexity.
@ScaledMetric(relativeTo: .caption) private var chipTextSize: CGFloat = 11 - Post accessibility announcements for important events.
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.
## VoiceOver accessibility instructions ## Project Structure
- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`. - Use a consistent project structure organized by feature.
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value). - Follow strict naming conventions for types, properties, and methods.
- Use `.accessibilityHint()` to describe what will happen when interacting with an element: - **One public type per file**—break types into separate files.
```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.
- Write unit tests for core application logic. - Write unit tests for core application logic.
- Only write UI tests if unit tests are not possible. - Only write UI tests if unit tests are not possible.
- Add code comments and documentation comments as needed. - Add code comments and documentation as needed.
- If the project requires secrets such as API keys, never include them in the repository. - 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. - **Keep `README.md` up to date** when adding new functionality.
- Document new features, settings, or gameplay mechanics in the appropriate game's README. - Document new features, settings, or mechanics in the README.
- Update the README when modifying existing behavior that affects how the game works. - Update the README when modifying existing behavior.
- Include any configuration options, keyboard shortcuts, or special interactions. - Include configuration options and 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.
- README updates should be part of the same commit as the feature/change they document. - Maintain a `ROADMAP.md` for tracking feature status.
## PR instructions ## PR Instructions
- If installed, make sure SwiftLint returns no warnings or errors before committing. - If installed, ensure SwiftLint returns no warnings or errors.
- Verify that the game's README.md reflects any new functionality or behavioral changes. - 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; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
EA837E672F107D6800077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA837E662F107D6800077F87 /* Bedrock */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
EA8379312F105F2800077F87 /* PBXContainerItemProxy */ = { EA8379312F105F2800077F87 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@ -78,6 +82,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA837E672F107D6800077F87 /* Bedrock in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -162,6 +167,7 @@
); );
name = BusinessCard; name = BusinessCard;
packageProductDependencies = ( packageProductDependencies = (
EA837E662F107D6800077F87 /* Bedrock */,
); );
productName = BusinessCard; productName = BusinessCard;
productReference = EA8379232F105F2600077F87 /* BusinessCard.app */; productReference = EA8379232F105F2600077F87 /* BusinessCard.app */;
@ -250,6 +256,9 @@
); );
mainGroup = EA83791A2F105F2600077F87; mainGroup = EA83791A2F105F2600077F87;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
EA837E652F107D6800077F87 /* XCLocalSwiftPackageReference "../Frameworks/Bedrock" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = EA8379242F105F2600077F87 /* Products */; productRefGroup = EA8379242F105F2600077F87 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -715,6 +724,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; 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 SwiftUI
import Bedrock
enum Design { // MARK: - App-Specific Sizes
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
}
enum CornerRadius { extension Design {
static let small: CGFloat = 8 /// BusinessCard-specific size constants.
static let medium: CGFloat = 12 enum CardSize {
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 {
static let cardWidth: CGFloat = 320 static let cardWidth: CGFloat = 320
static let cardHeight: CGFloat = 200 static let cardHeight: CGFloat = 200
static let avatarSize: CGFloat = 56 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 { extension Color {
// MARK: - App Backgrounds (Light Theme)
enum AppBackground { enum AppBackground {
static let base = Color(red: 0.97, green: 0.96, blue: 0.94) 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 elevated = Color(red: 1.0, green: 1.0, blue: 1.0)
static let accent = Color(red: 0.95, green: 0.91, blue: 0.86) static let accent = Color(red: 0.95, green: 0.91, blue: 0.86)
} }
// MARK: - Card Theme Palette
enum CardPalette { enum CardPalette {
static let coral = Color(red: 0.95, green: 0.35, blue: 0.33) 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) 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) 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 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 gold = Color(red: 0.95, green: 0.75, blue: 0.25)
static let mint = Color(red: 0.2, green: 0.65, blue: 0.55) static let 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) 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 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 secondary = Color(red: 0.32, green: 0.34, blue: 0.4)
static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98) static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98)
} }
// MARK: - Badge Colors
enum Badge { enum Badge {
static let star = Color(red: 0.98, green: 0.82, blue: 0.34) 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) 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 cardStore: CardStore
var contactsStore: ContactsStore var contactsStore: ContactsStore
let shareLinkService: ShareLinkProviding let shareLinkService: ShareLinkProviding
let qrCodeService: QRCodeProviding
init(modelContext: ModelContext) { init(modelContext: ModelContext) {
self.cardStore = CardStore(modelContext: modelContext) self.cardStore = CardStore(modelContext: modelContext)
self.contactsStore = ContactsStore(modelContext: modelContext) self.contactsStore = ContactsStore(modelContext: modelContext)
self.shareLinkService = ShareLinkService() self.shareLinkService = ShareLinkService()
self.qrCodeService = QRCodeService()
} }
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct BusinessCardView: View { struct BusinessCardView: View {
@ -37,6 +38,8 @@ struct BusinessCardView: View {
} }
} }
// MARK: - Layout Variants
private struct StackedCardLayout: View { private struct StackedCardLayout: View {
let card: BusinessCard let card: BusinessCard
@ -66,7 +69,7 @@ private struct SplitCardLayout: View {
} }
} }
Spacer(minLength: Design.Spacing.medium) 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) Spacer(minLength: Design.Spacing.medium)
CardAvatarBadgeView( AvatarBadgeView(
systemName: card.avatarSystemName, systemName: card.avatarSystemName,
accentColor: card.theme.accentColor, accentColor: card.theme.accentColor,
photoData: card.photoData photoData: card.photoData
@ -93,12 +96,14 @@ private struct PhotoCardLayout: View {
} }
} }
// MARK: - Card Sections
private struct CardHeaderView: View { private struct CardHeaderView: View {
let card: BusinessCard let card: BusinessCard
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
CardAvatarBadgeView( AvatarBadgeView(
systemName: card.avatarSystemName, systemName: card.avatarSystemName,
accentColor: card.theme.accentColor, accentColor: card.theme.accentColor,
photoData: card.photoData photoData: card.photoData
@ -124,7 +129,7 @@ private struct CardHeaderView: View {
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) .foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium))
} }
Spacer(minLength: Design.Spacing.small) 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 { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
if !card.email.isEmpty { if !card.email.isEmpty {
InfoRowView(systemImage: "envelope", text: card.email) IconRowView(systemImage: "envelope", text: card.email)
} }
if !card.phone.isEmpty { if !card.phone.isEmpty {
InfoRowView(systemImage: "phone", text: card.phone) IconRowView(systemImage: "phone", text: card.phone)
} }
if !card.website.isEmpty { if !card.website.isEmpty {
InfoRowView(systemImage: "link", text: card.website) IconRowView(systemImage: "link", text: card.website)
} }
if !card.bio.isEmpty { if !card.bio.isEmpty {
Text(card.bio) Text(card.bio)
@ -182,6 +187,8 @@ private struct SocialLinksRow: View {
} }
} }
// MARK: - Small Components
private struct SocialIconView: View { private struct SocialIconView: View {
let systemImage: String let systemImage: String
@ -195,30 +202,13 @@ private struct SocialIconView: View {
} }
} }
private struct InfoRowView: View { private struct AccentBlockView: 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 {
let color: Color let color: Color
var body: some View { var body: some View {
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(color) .fill(color)
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize) .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay( .overlay(
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.foregroundStyle(Color.Text.inverted) .foregroundStyle(Color.Text.inverted)
@ -226,53 +216,7 @@ private struct CardAccentBlockView: View {
} }
} }
private struct CardAvatarBadgeView: View { // MARK: - Preview
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))
}
}
#Preview { #Preview {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self) let container = try! ModelContainer(for: BusinessCard.self, Contact.self)

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct CardCarouselView: View { struct CardCarouselView: View {
@ -23,7 +24,7 @@ struct CardCarouselView: View {
} }
} }
.tabViewStyle(.page) .tabViewStyle(.page)
.frame(height: Design.Size.cardHeight + Design.Spacing.xxLarge) .frame(height: Design.CardSize.cardHeight + Design.Spacing.xxLarge)
if let selected = cardStore.selectedCard { if let selected = cardStore.selectedCard {
CardDefaultToggleView(card: selected) { CardDefaultToggleView(card: selected) {

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
import PhotosUI import PhotosUI
@ -10,35 +11,35 @@ struct CardEditorView: View {
let onSave: (BusinessCard) -> Void let onSave: (BusinessCard) -> Void
// Basic info // Basic info
@State private var displayName: String = "" @State private var displayName = ""
@State private var role: String = "" @State private var role = ""
@State private var company: String = "" @State private var company = ""
@State private var label: String = "Work" @State private var label = "Work"
@State private var pronouns: String = "" @State private var pronouns = ""
@State private var bio: String = "" @State private var bio = ""
// Contact details // Contact details
@State private var email: String = "" @State private var email = ""
@State private var phone: String = "" @State private var phone = ""
@State private var website: String = "" @State private var website = ""
@State private var location: String = "" @State private var location = ""
// Social media // Social media
@State private var linkedIn: String = "" @State private var linkedIn = ""
@State private var twitter: String = "" @State private var twitter = ""
@State private var instagram: String = "" @State private var instagram = ""
@State private var facebook: String = "" @State private var facebook = ""
@State private var tiktok: String = "" @State private var tiktok = ""
@State private var github: String = "" @State private var github = ""
// Custom links // Custom links
@State private var customLink1Title: String = "" @State private var customLink1Title = ""
@State private var customLink1URL: String = "" @State private var customLink1URL = ""
@State private var customLink2Title: String = "" @State private var customLink2Title = ""
@State private var customLink2URL: String = "" @State private var customLink2URL = ""
// Appearance // 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 selectedTheme: CardTheme = .coral
@State private var selectedLayout: CardLayoutStyle = .stacked @State private var selectedLayout: CardLayoutStyle = .stacked
@ -47,7 +48,6 @@ struct CardEditorView: View {
@State private var photoData: Data? @State private var photoData: Data?
private var isEditing: Bool { card != nil } private var isEditing: Bool { card != nil }
private var isFormValid: Bool { private var isFormValid: Bool {
!displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@ -55,21 +55,57 @@ struct CardEditorView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 { Section {
CardPreviewSection( EditorCardPreview(
displayName: displayName.isEmpty ? String.localized("Your Name") : displayName, displayName: displayName.isEmpty ? String.localized("Your Name") : displayName,
role: role.isEmpty ? String.localized("Your Role") : role, role: role.isEmpty ? String.localized("Your Role") : role,
company: company.isEmpty ? String.localized("Company") : company, company: company.isEmpty ? String.localized("Company") : company,
label: label, label: label,
avatarSystemName: avatarSystemName, avatarSystemName: avatarSystemName,
theme: selectedTheme, theme: selectedTheme,
layoutStyle: selectedLayout,
photoData: photoData photoData: photoData
) )
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
}
var photoSection: some View {
Section(String.localized("Photo")) { Section(String.localized("Photo")) {
PhotoPickerRow( PhotoPickerRow(
selectedPhoto: $selectedPhoto, selectedPhoto: $selectedPhoto,
@ -77,91 +113,56 @@ struct CardEditorView: View {
avatarSystemName: avatarSystemName avatarSystemName: avatarSystemName
) )
} }
}
var personalSection: some View {
Section(String.localized("Personal Information")) { Section(String.localized("Personal Information")) {
TextField(String.localized("Full Name"), text: $displayName) TextField(String.localized("Full Name"), text: $displayName)
.textContentType(.name) .textContentType(.name)
TextField(String.localized("Pronouns"), text: $pronouns) TextField(String.localized("Pronouns"), text: $pronouns)
.accessibilityHint(String.localized("e.g. she/her, he/him, they/them")) .accessibilityHint(String.localized("e.g. she/her, he/him, they/them"))
TextField(String.localized("Role / Title"), text: $role) TextField(String.localized("Role / Title"), text: $role)
.textContentType(.jobTitle) .textContentType(.jobTitle)
TextField(String.localized("Company"), text: $company) TextField(String.localized("Company"), text: $company)
.textContentType(.organizationName) .textContentType(.organizationName)
TextField(String.localized("Card Label"), text: $label) TextField(String.localized("Card Label"), text: $label)
.accessibilityHint(String.localized("A short label like Work or Personal")) .accessibilityHint(String.localized("A short label like Work or Personal"))
TextField(String.localized("Bio"), text: $bio, axis: .vertical) TextField(String.localized("Bio"), text: $bio, axis: .vertical)
.lineLimit(3...6) .lineLimit(3...6)
.accessibilityHint(String.localized("A short description about yourself")) .accessibilityHint(String.localized("A short description about yourself"))
} }
}
var contactSection: some View {
Section(String.localized("Contact Details")) { Section(String.localized("Contact Details")) {
TextField(String.localized("Email"), text: $email) TextField(String.localized("Email"), text: $email)
.textContentType(.emailAddress) .textContentType(.emailAddress)
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
TextField(String.localized("Phone"), text: $phone) TextField(String.localized("Phone"), text: $phone)
.textContentType(.telephoneNumber) .textContentType(.telephoneNumber)
.keyboardType(.phonePad) .keyboardType(.phonePad)
TextField(String.localized("Website"), text: $website) TextField(String.localized("Website"), text: $website)
.textContentType(.URL) .textContentType(.URL)
.keyboardType(.URL) .keyboardType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
TextField(String.localized("Location"), text: $location) TextField(String.localized("Location"), text: $location)
.textContentType(.fullStreetAddress) .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")) { Section(String.localized("Custom Links")) {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
TextField(String.localized("Link 1 Title"), text: $customLink1Title) TextField(String.localized("Link 1 Title"), text: $customLink1Title)
@ -170,7 +171,6 @@ struct CardEditorView: View {
.keyboardType(.URL) .keyboardType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
} }
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
TextField(String.localized("Link 2 Title"), text: $customLink2Title) TextField(String.localized("Link 2 Title"), text: $customLink2Title)
TextField(String.localized("Link 2 URL"), text: $customLink2URL) TextField(String.localized("Link 2 URL"), text: $customLink2URL)
@ -179,10 +179,11 @@ struct CardEditorView: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
} }
} }
}
var appearanceSection: some View {
Section(String.localized("Appearance")) { Section(String.localized("Appearance")) {
AvatarPickerRow(selection: $avatarSystemName) AvatarPickerRow(selection: $avatarSystemName)
Picker(String.localized("Theme"), selection: $selectedTheme) { Picker(String.localized("Theme"), selection: $selectedTheme) {
ForEach(CardTheme.all) { theme in ForEach(CardTheme.all) { theme in
HStack { HStack {
@ -194,45 +195,19 @@ struct CardEditorView: View {
.tag(theme) .tag(theme)
} }
} }
Picker(String.localized("Layout"), selection: $selectedLayout) { Picker(String.localized("Layout"), selection: $selectedLayout) {
ForEach(CardLayoutStyle.allCases) { layout in ForEach(CardLayoutStyle.allCases) { layout in
Text(layout.displayName) Text(layout.displayName).tag(layout)
.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 } guard let card else { return }
displayName = card.displayName displayName = card.displayName
role = card.role role = card.role
@ -260,18 +235,18 @@ struct CardEditorView: View {
photoData = card.photoData photoData = card.photoData
} }
private func saveCard() { func saveCard() {
if let existingCard = card { if let existingCard = card {
updateExistingCard(existingCard) updateCard(existingCard)
onSave(existingCard) onSave(existingCard)
} else { } else {
let newCard = createNewCard() let newCard = createCard()
onSave(newCard) onSave(newCard)
} }
dismiss() dismiss()
} }
private func updateExistingCard(_ card: BusinessCard) { func updateCard(_ card: BusinessCard) {
card.displayName = displayName card.displayName = displayName
card.role = role card.role = role
card.company = company card.company = company
@ -298,37 +273,21 @@ struct CardEditorView: View {
card.photoData = photoData card.photoData = photoData
} }
private func createNewCard() -> BusinessCard { func createCard() -> BusinessCard {
BusinessCard( BusinessCard(
displayName: displayName, displayName: displayName, role: role, company: company, label: label,
role: role, email: email, phone: phone, website: website, location: location,
company: company, isDefault: false, themeName: selectedTheme.name, layoutStyleRawValue: selectedLayout.rawValue,
label: label, avatarSystemName: avatarSystemName, pronouns: pronouns, bio: bio,
email: email, linkedIn: linkedIn, twitter: twitter, instagram: instagram, facebook: facebook,
phone: phone, tiktok: tiktok, github: github, customLink1Title: customLink1Title, customLink1URL: customLink1URL,
website: website, customLink2Title: customLink2Title, customLink2URL: customLink2URL, photoData: photoData
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 { private struct PhotoPickerRow: View {
@Binding var selectedPhoto: PhotosPickerItem? @Binding var selectedPhoto: PhotosPickerItem?
@Binding var photoData: Data? @Binding var photoData: Data?
@ -336,27 +295,17 @@ private struct PhotoPickerRow: View {
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
if let photoData, let uiImage = UIImage(data: photoData) { AvatarBadgeView(
Image(uiImage: uiImage) systemName: avatarSystemName,
.resizable() accentColor: Color.Accent.red,
.scaledToFill() photoData: photoData
.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)
}
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
PhotosPicker(selection: $selectedPhoto, matching: .images) { PhotosPicker(selection: $selectedPhoto, matching: .images) {
Text(photoData == nil ? String.localized("Add Photo") : String.localized("Change Photo")) Text(photoData == nil ? String.localized("Add Photo") : String.localized("Change Photo"))
.foregroundStyle(Color.Accent.red) .foregroundStyle(Color.Accent.red)
} }
if photoData != nil { if photoData != nil {
Button(String.localized("Remove Photo"), role: .destructive) { Button(String.localized("Remove Photo"), role: .destructive) {
photoData = nil photoData = nil
@ -382,7 +331,6 @@ private struct SocialLinkField: View {
Image(systemName: systemImage) Image(systemName: systemImage)
.foregroundStyle(Color.Accent.red) .foregroundStyle(Color.Accent.red)
.frame(width: Design.Spacing.xLarge) .frame(width: Design.Spacing.xLarge)
TextField(title, text: $text, prompt: Text(placeholder)) TextField(title, text: $text, prompt: Text(placeholder))
.textContentType(.URL) .textContentType(.URL)
.keyboardType(.URL) .keyboardType(.URL)
@ -392,96 +340,40 @@ private struct SocialLinkField: View {
} }
} }
private struct CardPreviewSection: View { private struct EditorCardPreview: View {
let displayName: String let displayName: String
let role: String let role: String
let company: String let company: String
let label: String let label: String
let avatarSystemName: String let avatarSystemName: String
let theme: CardTheme let theme: CardTheme
let layoutStyle: CardLayoutStyle
let photoData: Data? let photoData: Data?
var body: some View { 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) { VStack(spacing: Design.Spacing.medium) {
HStack(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) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(displayName) Text(displayName).font(.headline).bold().foregroundStyle(Color.Text.inverted)
.font(.headline) Text(role).font(.subheadline).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull))
.bold() Text(company).font(.caption).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium))
.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) Spacer(minLength: Design.Spacing.small)
LabelBadgeView(label: label, accentColor: theme.accentColor)
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))
} }
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background( .background(
LinearGradient( LinearGradient(colors: [theme.primaryColor, theme.secondaryColor], startPoint: .topLeading, endPoint: .bottomTrailing)
colors: [theme.primaryColor, theme.secondaryColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
) )
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge)) .clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
.shadow( .shadow(color: Color.Text.secondary.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusLarge, x: Design.Shadow.offsetNone, y: Design.Shadow.offsetMedium)
color: Color.Text.secondary.opacity(Design.Opacity.hint), .padding(.vertical, Design.Spacing.medium)
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)
)
}
} }
} }
@ -489,16 +381,8 @@ private struct AvatarPickerRow: View {
@Binding var selection: String @Binding var selection: String
private let avatarOptions = [ private let avatarOptions = [
"person.crop.circle", "person.crop.circle", "person.crop.circle.fill", "person.crop.square", "person.circle", "sparkles",
"person.crop.circle.fill", "music.mic", "briefcase.fill", "building.2.fill", "star.fill", "bolt.fill"
"person.crop.square",
"person.circle",
"sparkles",
"music.mic",
"briefcase.fill",
"building.2.fill",
"star.fill",
"bolt.fill"
] ]
var body: some View { var body: some View {
@ -506,16 +390,13 @@ private struct AvatarPickerRow: View {
Text("Icon (if no photo)") Text("Icon (if no photo)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.Text.secondary) .foregroundStyle(Color.Text.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) {
ForEach(avatarOptions, id: \.self) { icon in ForEach(avatarOptions, id: \.self) { icon in
Button { Button { selection = icon } label: {
selection = icon
} label: {
Image(systemName: icon) Image(systemName: icon)
.font(.title2) .font(.title2)
.foregroundStyle(selection == icon ? Color.Accent.red : Color.Text.secondary) .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) .background(selection == icon ? Color.AppBackground.accent : Color.AppBackground.base)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
@ -528,6 +409,8 @@ private struct AvatarPickerRow: View {
} }
} }
// MARK: - Preview
#Preview("New Card") { #Preview("New Card") {
let container = try! ModelContainer(for: BusinessCard.self, Contact.self) let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
return CardEditorView(card: nil) { _ in } return CardEditorView(card: nil) { _ in }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct CardsHomeView: View { 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 SwiftUI
import Bedrock
import SwiftData import SwiftData
struct ContactDetailView: View { struct ContactDetailView: View {
@ -154,13 +155,13 @@ private struct ContactHeaderView: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .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) .clipShape(.circle)
} else { } else {
Image(systemName: contact.avatarSystemName) Image(systemName: contact.avatarSystemName)
.font(.system(size: Design.BaseFontSize.display)) .font(.system(size: Design.BaseFontSize.display))
.foregroundStyle(Color.Accent.red) .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) .background(Color.AppBackground.accent)
.clipShape(.circle) .clipShape(.circle)
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct ContactsView: View { struct ContactsView: View {
@ -202,13 +203,13 @@ private struct ContactAvatarView: View {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFill() .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)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} else { } else {
Image(systemName: contact.avatarSystemName) Image(systemName: contact.avatarSystemName)
.font(.title2) .font(.title2)
.foregroundStyle(Color.Accent.red) .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) .background(Color.AppBackground.accent)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct CustomizeCardView: View { struct CustomizeCardView: View {
@ -102,7 +103,7 @@ private struct CardStylePickerView: View {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: Design.Spacing.xSmall) {
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(theme.primaryColor) .fill(theme.primaryColor)
.frame(height: Design.Size.avatarSize) .frame(height: Design.CardSize.avatarSize)
.overlay( .overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.stroke( .stroke(

View File

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

View File

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

View File

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

View File

@ -1,24 +1,14 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
/// A QR code view using Bedrock's QRCodeImageView.
struct QRCodeView: View { struct QRCodeView: View {
@Environment(AppState.self) private var appState
let payload: String let payload: String
var body: some View { var body: some View {
if let image = appState.qrCodeService.qrCode(from: payload) { QRCodeImageView(payload: payload)
Image(decorative: image, scale: 1)
.resizable()
.interpolation(.none)
.scaledToFit()
.accessibilityLabel(String.localized("QR code")) .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 SwiftUI
import Bedrock
import AVFoundation import AVFoundation
struct QRScannerView: View { struct QRScannerView: View {

View File

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

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct ShareCardView: View { struct ShareCardView: View {
@ -21,18 +22,13 @@ struct ShareCardView: View {
if let card = appState.cardStore.selectedCard { if let card = appState.cardStore.selectedCard {
QRCodeCardView(card: card) QRCodeCardView(card: card)
ShareOptionsView( ShareOptionsView(
card: card, card: card,
shareLinkService: appState.shareLinkService, shareLinkService: appState.shareLinkService,
showWallet: { showingWalletAlert = true }, showWallet: { showingWalletAlert = true },
showNfc: { showingNfcAlert = true }, showNfc: { showingNfcAlert = true }
onShareAction: { showingContactSheet = true }
) )
TrackShareButton { showingContactSheet = true }
TrackContactButton {
showingContactSheet = true
}
} else { } else {
EmptyStateView( EmptyStateView(
title: String.localized("No card selected"), title: String.localized("No card selected"),
@ -61,7 +57,14 @@ struct ShareCardView: View {
recipientRole: $recipientRole, recipientRole: $recipientRole,
recipientCompany: $recipientCompany 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( appState.contactsStore.recordShare(
for: recipientName, for: recipientName,
role: recipientRole, role: recipientRole,
@ -73,98 +76,8 @@ struct ShareCardView: View {
recipientCompany = "" recipientCompany = ""
} }
} }
}
}
}
}
private struct TrackContactButton: View { // MARK: - QR Code Display
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])
}
}
private struct QRCodeCardView: View { private struct QRCodeCardView: View {
let card: BusinessCard let card: BusinessCard
@ -172,7 +85,7 @@ private struct QRCodeCardView: View {
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
QRCodeView(payload: card.vCardPayload) 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) .padding(Design.Spacing.medium)
.background(Color.AppBackground.elevated) .background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large)) .clipShape(.rect(cornerRadius: Design.CornerRadius.large))
@ -194,52 +107,47 @@ private struct QRCodeCardView: View {
} }
} }
// MARK: - Share Options
private struct ShareOptionsView: View { private struct ShareOptionsView: View {
let card: BusinessCard let card: BusinessCard
let shareLinkService: ShareLinkProviding let shareLinkService: ShareLinkProviding
let showWallet: () -> Void let showWallet: () -> Void
let showNfc: () -> Void let showNfc: () -> Void
let onShareAction: () -> Void
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
ShareOptionShareRow( ShareOptionRow.share(
title: String.localized("Copy link"), title: String.localized("Copy link"),
systemImage: "link", systemImage: "link",
item: shareLinkService.shareURL(for: card) item: shareLinkService.shareURL(for: card)
) )
ShareOptionRow.link(
ShareOptionLinkRow(
title: String.localized("Text your card"), title: String.localized("Text your card"),
systemImage: "message", systemImage: "message",
url: shareLinkService.smsURL(for: card) url: shareLinkService.smsURL(for: card)
) )
ShareOptionRow.link(
ShareOptionLinkRow(
title: String.localized("Email your card"), title: String.localized("Email your card"),
systemImage: "envelope", systemImage: "envelope",
url: shareLinkService.emailURL(for: card) url: shareLinkService.emailURL(for: card)
) )
ShareOptionRow.link(
ShareOptionLinkRow(
title: String.localized("Send via WhatsApp"), title: String.localized("Send via WhatsApp"),
systemImage: "message.fill", systemImage: "message.fill",
url: shareLinkService.whatsappURL(for: card) url: shareLinkService.whatsappURL(for: card)
) )
ShareOptionRow.link(
ShareOptionLinkRow(
title: String.localized("Send via LinkedIn"), title: String.localized("Send via LinkedIn"),
systemImage: "link.circle", systemImage: "link.circle",
url: shareLinkService.linkedInURL(for: card) url: shareLinkService.linkedInURL(for: card)
) )
ShareOptionRow.action(
ShareOptionActionRow(
title: String.localized("Add to Apple Wallet"), title: String.localized("Add to Apple Wallet"),
systemImage: "wallet.pass", systemImage: "wallet.pass",
action: showWallet action: showWallet
) )
ShareOptionRow.action(
ShareOptionActionRow(
title: String.localized("Share via NFC"), title: String.localized("Share via NFC"),
systemImage: "dot.radiowaves.left.and.right", systemImage: "dot.radiowaves.left.and.right",
action: showNfc action: showNfc
@ -251,63 +159,60 @@ private struct ShareOptionsView: View {
} }
} }
private struct ShareOptionLinkRow: View { // MARK: - Track Button
let title: String
let systemImage: String
let url: URL
var body: some View { private struct TrackShareButton: 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
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
Button(action: action) { 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) { 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) Text(title)
.foregroundStyle(Color.Text.primary) .foregroundStyle(Color.Text.primary)
Spacer() Spacer()
@ -319,21 +224,9 @@ private struct ShareOptionActionRow: View {
.background(Color.AppBackground.base) .background(Color.AppBackground.base)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) .clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
} }
.buttonStyle(.plain)
}
} }
private struct ShareRowIcon: View { // MARK: - Preview
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))
}
}
#Preview { #Preview {
ShareCardView() 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 SwiftUI
import Bedrock
struct WidgetsCalloutView: View { struct WidgetsCalloutView: View {
var body: some View { var body: some View {

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Bedrock
import SwiftData import SwiftData
struct WidgetsView: View { struct WidgetsView: View {
@ -49,7 +50,7 @@ private struct PhoneWidgetPreview: View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
QRCodeView(payload: card.vCardPayload) 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) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(card.displayName) Text(card.displayName)
@ -82,7 +83,7 @@ private struct WatchWidgetPreview: View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
QRCodeView(payload: card.vCardPayload) 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) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text("Ready to scan") 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. 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 ## Platforms
- iOS 26+ - iOS 26+
- watchOS 12+ - watchOS 12+
- Swift 6.2 - Swift 6.2
@ -10,20 +11,22 @@ A SwiftUI iOS + watchOS app that creates and shares digital business cards with
## Features ## Features
### My Cards ### My Cards
- Create and browse multiple cards in a carousel - Create and browse multiple cards in a carousel
- Create new cards with the "New Card" button
- Set a default card for sharing - Set a default card for sharing
- Preview bold card styles inspired by modern design - Preview bold card styles inspired by modern design
- **Profile photos**: Add a photo from your library or use an icon - **Profile photos**: Add a photo from your library or use an icon
- **Rich profiles**: Pronouns, bio, social media links, custom URLs - **Rich profiles**: Pronouns, bio, social media links, custom URLs
### Share ### Share
- QR code display for vCard payloads - QR code display for vCard payloads
- Share options: copy link, SMS, email, WhatsApp, LinkedIn - Share options: copy link, SMS, email, WhatsApp, LinkedIn
- **Track shares**: Record who received your card and when - **Track shares**: Record who received your card and when
- Placeholder actions for Apple Wallet and NFC (alerts included) - Placeholder actions for Apple Wallet and NFC (alerts included)
### Customize ### Customize
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet) - Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
- Layout picker for stacked, split, or photo style - Layout picker for stacked, split, or photo style
- **Edit all card details**: Name, role, company, email, phone, website, location - **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 - **Delete cards** you no longer need
### Contacts ### Contacts
- Track who you've shared your card with - Track who you've shared your card with
- **Scan QR codes** to save someone else's business card - **Scan QR codes** to save someone else's business card
- **Notes & annotations**: Add notes about each contact - **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 - Swipe to delete contacts
### Widgets (Preview Only) ### Widgets (Preview Only)
- Phone widget preview mock - Phone widget preview mock
- Watch widget preview mock - Watch widget preview mock
### watchOS App ### watchOS App
- Shows the default card QR code - Shows the default card QR code
- Pick which card is the default on watch - Pick which card is the default on watch
- **Syncs with iPhone** via App Groups - **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 ## Data Sync
### iCloud Sync (iOS) ### 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. 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 ### 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. 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. **Note**: The watch reads data from the iPhone. To update cards on the watch, make changes on the iPhone first.
## Architecture ## Architecture
- SwiftUI views are presentation only - SwiftUI views are presentation only
- Shared app state uses `@Observable` classes on `@MainActor` - Shared app state uses `@Observable` classes on `@MainActor`
- SwiftData for persistence with CloudKit sync - 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) - 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 ## Project Structure
- `BusinessCard/Models` — SwiftData card/contact models
- `BusinessCard/State` — observable app state (CardStore, ContactsStore) ```
- `BusinessCard/Services` — QR generation, share URLs, watch sync BusinessCard/
- `BusinessCard/Views` — SwiftUI screens and components ├── Design/ # Design constants (extends Bedrock)
- `BusinessCard/Design` — design constants and semantic colors ├── Localization/ # String helpers
- `BusinessCard/Protocols` — protocol definitions ├── Models/ # SwiftData models (BusinessCard, Contact)
- `BusinessCardWatch/` — watchOS app target and assets ├── 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 ## Configuration
### Required Capabilities ### Required Capabilities
**iOS Target:** **iOS Target:**
- iCloud (CloudKit enabled) - iCloud (CloudKit enabled)
- App Groups (`group.com.mbrucedogs.BusinessCard`) - App Groups (`group.com.mbrucedogs.BusinessCard`)
- Background Modes (Remote notifications) - Background Modes (Remote notifications)
- Camera (for QR code scanning) - Camera (for QR code scanning)
**watchOS Target:** **watchOS Target:**
- App Groups (`group.com.mbrucedogs.BusinessCard`) - App Groups (`group.com.mbrucedogs.BusinessCard`)
### CloudKit Container ### CloudKit Container
`iCloud.com.mbrucedogs.BusinessCard` `iCloud.com.mbrucedogs.BusinessCard`
## Notes ## Notes
- Share URLs are sample placeholders - Share URLs are sample placeholders
- Wallet/NFC flows are stubs with alerts only - Wallet/NFC flows are stubs with alerts only
- Widget UI is a visual preview (not a WidgetKit extension) - Widget UI is a visual preview (not a WidgetKit extension)
- First launch creates sample cards for demonstration - First launch creates sample cards for demonstration
## Running ## 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 ## Tests
Unit tests cover: Unit tests cover:
- vCard payload formatting - vCard payload formatting
- Default card selection - Default card selection
- Contact search filtering - Contact search filtering
@ -117,3 +156,11 @@ Unit tests cover:
- Adding received cards via QR scan - Adding received cards via QR scan
Run tests with `Cmd+U` in Xcode. 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. This file summarizes project-specific context, architecture, and conventions to speed up future AI work.
## Project Summary ## 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. 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 ## Key Constraints
- iOS 26+, watchOS 12+, Swift 6.2. - iOS 26+, watchOS 12+, Swift 6.2.
- SwiftUI with `@Observable` classes and `@MainActor`. - SwiftUI with `@Observable` classes and `@MainActor`.
- Protocoloriented architecture is prioritized. - Protocoloriented architecture is prioritized.
- No UIKit unless explicitly requested. - No UIKit unless explicitly requested.
- String Catalogs only (`.xcstrings`). - 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 ## Core Data Flow
- `AppState` owns: - `AppState` owns:
- `CardStore` (cards and selection) - `CardStore` (cards and selection)
- `ContactsStore` (contact list + search) - `ContactsStore` (contact list + search)
- `ShareLinkService` (share URLs) - `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. - 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 ## Important Files
### Models ### 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/CardTheme.swift` — card theme palette
- `BusinessCard/Models/CardLayoutStyle.swift` — stacked/split/photo - `BusinessCard/Models/CardLayoutStyle.swift` — stacked/split/photo
### Protocols (POP) ### Protocols (POP)
- `BusinessCard/Protocols/BusinessCardProviding.swift` - `BusinessCard/Protocols/BusinessCardProviding.swift`
- `BusinessCard/Protocols/ContactTracking.swift` - `BusinessCard/Protocols/ContactTracking.swift`
- `BusinessCard/Protocols/QRCodeProviding.swift`
- `BusinessCard/Protocols/ShareLinkProviding.swift` - `BusinessCard/Protocols/ShareLinkProviding.swift`
### State ### State
- `BusinessCard/State/AppState.swift`
- `BusinessCard/State/CardStore.swift` - `BusinessCard/State/AppState.swift` — central state container
- `BusinessCard/State/ContactsStore.swift` - `BusinessCard/State/CardStore.swift` — card CRUD, selection, watch sync
- `BusinessCard/State/ContactsStore.swift` — contacts, search, received cards
### Services ### Services
- `BusinessCard/Services/QRCodeService.swift` — CoreImage QR generation
- `BusinessCard/Services/ShareLinkService.swift` — share URL helpers - `BusinessCard/Services/ShareLinkService.swift` — share URL helpers
- `BusinessCard/Services/WatchSyncService.swift` — App Group sync to watch
### Views ### Views
- `BusinessCard/Views/RootTabView.swift` — tabbed shell
- `BusinessCard/Views/CardsHomeView.swift` — hero + card carousel - `RootTabView.swift` — tabbed shell
- `BusinessCard/Views/ShareCardView.swift` — QR + share actions - `CardsHomeView.swift` — hero + card carousel
- `BusinessCard/Views/CustomizeCardView.swift` — theme/layout controls - `CardEditorView.swift` — create/edit cards with PhotosPicker
- `BusinessCard/Views/ContactsView.swift` — tracking list + search - `BusinessCardView.swift` — card display with photo and social icons
- `BusinessCard/Views/WidgetsView.swift` — preview mockups - `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 ### Design + Localization
- `BusinessCard/Design/DesignConstants.swift`
- `BusinessCard/Design/DesignConstants.swift` — extends Bedrock
- `BusinessCard/Resources/Localizable.xcstrings` - `BusinessCard/Resources/Localizable.xcstrings`
- `BusinessCard/Localization/String+Localization.swift`
### watchOS ### watchOS
- `BusinessCardWatch/BusinessCardWatchApp.swift` - `BusinessCardWatch/BusinessCardWatchApp.swift`
- `BusinessCardWatch/Views/WatchContentView.swift` - `BusinessCardWatch/Views/WatchContentView.swift`
- `BusinessCardWatch/State/WatchCardStore.swift` - `BusinessCardWatch/State/WatchCardStore.swift`
- `BusinessCardWatch/Resources/Localizable.xcstrings` - `BusinessCardWatch/Resources/Localizable.xcstrings`
## Localization ## Localization
- All user-facing strings are in `.xcstrings`. - All user-facing strings are in `.xcstrings`.
- Supported locales: en, esMX, frCA. - Supported locales: en, esMX, frCA.
- Use `String.localized("Key")` for non-Text strings. - Use `String.localized("Key")` for non-Text strings.
## Testing ## 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 ## Known Stubs / TODOs
- Apple Wallet and NFC flows are alert-only placeholders. - Apple Wallet and NFC flows are alert-only placeholders.
- Share URLs are sample placeholders. - Share URLs are sample placeholders.
- Widget previews are not WidgetKit extensions. - Widget previews are not WidgetKit extensions.
- See `ROADMAP.md` for full feature status.
## If You Extend The App ## If You Extend The App
- Add new strings to the String Catalogs.
- Add new constants to `DesignConstants.swift` instead of literals. 1. Add new strings to the String Catalogs.
- Keep view logic UI-only; push business logic to state classes. 2. Use `Design.*` from Bedrock for spacing, opacity, etc.
- Prefer protocols for new capabilities. 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.