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