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