Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e4f99b8beb
commit
31452ab287
513
Agents.md
Normal file
513
Agents.md
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
# Agent guide for Swift and SwiftUI
|
||||||
|
|
||||||
|
This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage.
|
||||||
|
|
||||||
|
## Additional context files (read first)
|
||||||
|
|
||||||
|
- `README.md` — product scope, features, and project structure
|
||||||
|
- `ai_implmentation.md` — AI implementation context and architecture notes
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- Target iOS 26.0 or later. (Yes, it definitely exists.)
|
||||||
|
- Swift 6.2 or later, using modern Swift concurrency.
|
||||||
|
- SwiftUI backed up by `@Observable` classes for shared data.
|
||||||
|
- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below.
|
||||||
|
- Do not introduce third-party frameworks without asking first.
|
||||||
|
- Avoid UIKit unless requested.
|
||||||
|
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### When architecting new code:
|
||||||
|
|
||||||
|
1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
|
||||||
|
2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first.
|
||||||
|
3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate.
|
||||||
|
4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies.
|
||||||
|
|
||||||
|
### When reviewing existing code for reuse:
|
||||||
|
|
||||||
|
1. **Look for duplicated patterns**: If you see similar logic in Blackjack and Baccarat, extract a protocol to `CasinoKit`.
|
||||||
|
2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification.
|
||||||
|
3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended.
|
||||||
|
4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it.
|
||||||
|
|
||||||
|
### Protocol design guidelines:
|
||||||
|
|
||||||
|
- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Bettable`, `CardDealing`, `StatisticsProvider`).
|
||||||
|
- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle).
|
||||||
|
- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible.
|
||||||
|
- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**❌ BAD - Concrete implementations without protocols:**
|
||||||
|
```swift
|
||||||
|
// Blackjack/GameState.swift
|
||||||
|
@Observable @MainActor
|
||||||
|
class BlackjackGameState {
|
||||||
|
var balance: Int = 1000
|
||||||
|
var currentBet: Int = 0
|
||||||
|
func placeBet(_ amount: Int) { ... }
|
||||||
|
func resetBet() { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baccarat/GameState.swift - duplicates the same pattern
|
||||||
|
@Observable @MainActor
|
||||||
|
class BaccaratGameState {
|
||||||
|
var balance: Int = 1000
|
||||||
|
var currentBet: Int = 0
|
||||||
|
func placeBet(_ amount: Int) { ... }
|
||||||
|
func resetBet() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - Protocol in CasinoKit, adopted by games:**
|
||||||
|
```swift
|
||||||
|
// CasinoKit/Protocols/Bettable.swift
|
||||||
|
protocol Bettable: AnyObject {
|
||||||
|
var balance: Int { get set }
|
||||||
|
var currentBet: Int { get set }
|
||||||
|
var minimumBet: Int { get }
|
||||||
|
var maximumBet: Int { get }
|
||||||
|
|
||||||
|
func placeBet(_ amount: Int)
|
||||||
|
func resetBet()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Bettable {
|
||||||
|
func placeBet(_ amount: Int) {
|
||||||
|
guard amount <= balance else { return }
|
||||||
|
currentBet += amount
|
||||||
|
balance -= amount
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetBet() {
|
||||||
|
balance += currentBet
|
||||||
|
currentBet = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blackjack/GameState.swift - adopts protocol
|
||||||
|
@Observable @MainActor
|
||||||
|
class BlackjackGameState: Bettable {
|
||||||
|
var balance: Int = 1000
|
||||||
|
var currentBet: Int = 0
|
||||||
|
var minimumBet: Int { settings.minBet }
|
||||||
|
var maximumBet: Int { settings.maxBet }
|
||||||
|
// placeBet and resetBet come from protocol extension
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ BAD - View only works with one concrete type:**
|
||||||
|
```swift
|
||||||
|
struct ChipSelectorView: View {
|
||||||
|
@Bindable var state: BlackjackGameState
|
||||||
|
// Tightly coupled to Blackjack
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - View works with any Bettable type:**
|
||||||
|
```swift
|
||||||
|
struct ChipSelectorView<State: Bettable & Observable>: View {
|
||||||
|
@Bindable var state: State
|
||||||
|
// Reusable across all games
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common protocols to consider extracting:
|
||||||
|
|
||||||
|
| Capability | Protocol Name | Shared By |
|
||||||
|
|------------|---------------|-----------|
|
||||||
|
| Betting mechanics | `Bettable` | All games |
|
||||||
|
| Statistics tracking | `StatisticsProvider` | All games |
|
||||||
|
| Game settings | `GameConfigurable` | All games |
|
||||||
|
| Card management | `CardProviding` | Card games |
|
||||||
|
| Round lifecycle | `RoundManaging` | All games |
|
||||||
|
| Result calculation | `ResultCalculating` | All games |
|
||||||
|
|
||||||
|
### Refactoring checklist:
|
||||||
|
|
||||||
|
When you encounter code that could benefit from POP:
|
||||||
|
|
||||||
|
- [ ] Is this logic duplicated across multiple games?
|
||||||
|
- [ ] Could this type conform to an existing protocol in CasinoKit?
|
||||||
|
- [ ] Would extracting a protocol make this code testable in isolation?
|
||||||
|
- [ ] Can views be made generic over a protocol instead of a concrete type?
|
||||||
|
- [ ] Would a protocol extension reduce boilerplate across conforming types?
|
||||||
|
|
||||||
|
### Benefits:
|
||||||
|
|
||||||
|
- **Reusability**: Shared protocols in `CasinoKit` work across all games
|
||||||
|
- **Testability**: Mock types can conform to protocols for unit testing
|
||||||
|
- **Flexibility**: New games can adopt existing protocols immediately
|
||||||
|
- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere
|
||||||
|
- **Discoverability**: Protocols document the expected interface clearly
|
||||||
|
|
||||||
|
|
||||||
|
## Swift instructions
|
||||||
|
|
||||||
|
- Always mark `@Observable` classes with `@MainActor`.
|
||||||
|
- Assume strict Swift concurrency rules are being applied.
|
||||||
|
- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`.
|
||||||
|
- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL.
|
||||||
|
- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead.
|
||||||
|
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
|
||||||
|
- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency.
|
||||||
|
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`.
|
||||||
|
- Avoid force unwraps and force `try` unless it is unrecoverable.
|
||||||
|
|
||||||
|
|
||||||
|
## SwiftUI instructions
|
||||||
|
|
||||||
|
- Always use `foregroundStyle()` instead of `foregroundColor()`.
|
||||||
|
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
|
||||||
|
- Always use the `Tab` API instead of `tabItem()`.
|
||||||
|
- Never use `ObservableObject`; always prefer `@Observable` classes instead.
|
||||||
|
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
|
||||||
|
- Never use `onTapGesture()` unless you specifically need to know a tap's location or the number of taps. All other usages should use `Button`.
|
||||||
|
- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead.
|
||||||
|
- Never use `UIScreen.main.bounds` to read the size of the available space.
|
||||||
|
- Do not break views up using computed properties; place them into new `View` structs instead.
|
||||||
|
- Do not force specific font sizes; prefer using Dynamic Type instead.
|
||||||
|
- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`.
|
||||||
|
- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`.
|
||||||
|
- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`.
|
||||||
|
- Don't apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`.
|
||||||
|
- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`.
|
||||||
|
- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`.
|
||||||
|
- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer.
|
||||||
|
- Avoid `AnyView` unless it is absolutely required.
|
||||||
|
- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section).
|
||||||
|
- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in the `Color` extension in `DesignConstants.swift` with semantic names.
|
||||||
|
- Avoid using UIKit colors in SwiftUI code.
|
||||||
|
|
||||||
|
|
||||||
|
## View/State separation (MVVM-lite)
|
||||||
|
|
||||||
|
**Views should be "dumb" renderers.** All business logic belongs in `GameState` or dedicated view models.
|
||||||
|
|
||||||
|
### What belongs in the State/ViewModel:
|
||||||
|
- **Business logic**: Calculations, validations, game rules
|
||||||
|
- **Computed properties based on game data**: hints, recommendations, derived values
|
||||||
|
- **State checks**: `isPlayerTurn`, `canHit`, `isGameOver`, `isBetBelowMinimum`
|
||||||
|
- **Data transformations**: statistics calculations, filtering, aggregations
|
||||||
|
|
||||||
|
### What is acceptable in Views:
|
||||||
|
- **Pure UI layout logic**: `isIPad`, `maxContentWidth` based on size class
|
||||||
|
- **Visual styling**: color selection based on state (`valueColor`, `resultColor`)
|
||||||
|
- **@ViewBuilder sub-views**: breaking up complex layouts
|
||||||
|
- **Accessibility labels**: combining data into accessible descriptions
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**❌ BAD - Business logic in view:**
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
@Bindable var state: GameState
|
||||||
|
|
||||||
|
private var isBetBelowMinimum: Bool {
|
||||||
|
state.currentBet > 0 && state.currentBet < state.settings.minBet
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentHint: String? {
|
||||||
|
guard let hand = state.activeHand else { return nil }
|
||||||
|
return state.engine.getHint(playerHand: hand, dealerUpCard: upCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ GOOD - Logic in GameState, view just reads:**
|
||||||
|
```swift
|
||||||
|
// In GameState:
|
||||||
|
var isBetBelowMinimum: Bool {
|
||||||
|
currentBet > 0 && currentBet < settings.minBet
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentHint: String? {
|
||||||
|
guard settings.showHints, isPlayerTurn else { return nil }
|
||||||
|
guard let hand = activeHand, let upCard = dealerUpCard else { return nil }
|
||||||
|
return engine.getHint(playerHand: hand, dealerUpCard: upCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In View:
|
||||||
|
if state.isBetBelowMinimum { ... }
|
||||||
|
if let hint = state.currentHint { HintView(hint: hint) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits:
|
||||||
|
- **Testable**: GameState logic can be unit tested without UI
|
||||||
|
- **Single source of truth**: No duplicated logic across views
|
||||||
|
- **Cleaner views**: Views focus purely on layout and presentation
|
||||||
|
- **Easier debugging**: Logic is centralized, not scattered
|
||||||
|
|
||||||
|
|
||||||
|
## SwiftData instructions
|
||||||
|
|
||||||
|
If SwiftData is configured to use CloudKit:
|
||||||
|
|
||||||
|
- Never use `@Attribute(.unique)`.
|
||||||
|
- Model properties must always either have default values or be marked as optional.
|
||||||
|
- All relationships must be marked optional.
|
||||||
|
|
||||||
|
|
||||||
|
## Localization instructions
|
||||||
|
|
||||||
|
- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+.
|
||||||
|
- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings.
|
||||||
|
- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension:
|
||||||
|
```swift
|
||||||
|
extension String {
|
||||||
|
static func localized(_ key: String) -> String {
|
||||||
|
String(localized: String.LocalizationValue(key))
|
||||||
|
}
|
||||||
|
static func localized(_ key: String, _ arguments: CVarArg...) -> String {
|
||||||
|
let format = String(localized: String.LocalizationValue(key))
|
||||||
|
return String(format: format, arguments: arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- For format strings with interpolation (e.g., "Balance: $%@"), define a key in the String Catalog and use `String.localized("key", value)`.
|
||||||
|
- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views.
|
||||||
|
- Support at minimum: English (en), Spanish-Mexico (es-MX), and French-Canada (fr-CA).
|
||||||
|
- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API.
|
||||||
|
|
||||||
|
|
||||||
|
## No magic numbers or hardcoded values
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
### Values that MUST be constants:
|
||||||
|
- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)`
|
||||||
|
- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16`
|
||||||
|
- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14`
|
||||||
|
- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)`
|
||||||
|
- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)`
|
||||||
|
- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2`
|
||||||
|
- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10`
|
||||||
|
- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3`
|
||||||
|
- **Component Sizes**: `Design.Size.chipBadge` not `frame(width: 32)`
|
||||||
|
|
||||||
|
### What to do when you see a magic number:
|
||||||
|
1. Check if an appropriate constant already exists in `DesignConstants.swift`
|
||||||
|
2. If not, add a new constant with a semantic name
|
||||||
|
3. Use the constant in place of the raw value
|
||||||
|
4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct
|
||||||
|
|
||||||
|
### Examples of violations:
|
||||||
|
```swift
|
||||||
|
// ❌ BAD - Magic numbers everywhere
|
||||||
|
.padding(16)
|
||||||
|
.opacity(0.6)
|
||||||
|
.frame(width: 80, height: 52)
|
||||||
|
.shadow(radius: 10, y: 5)
|
||||||
|
Color(red: 0.25, green: 0.3, blue: 0.45)
|
||||||
|
|
||||||
|
// ✅ GOOD - Named constants
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.opacity(Design.Opacity.accent)
|
||||||
|
.frame(width: Design.Size.bonusZoneWidth, height: Design.Size.topBetRowHeight)
|
||||||
|
.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
|
||||||
|
Color.BettingZone.dragonBonusLight
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Design constants instructions
|
||||||
|
|
||||||
|
- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing:
|
||||||
|
```swift
|
||||||
|
enum Design {
|
||||||
|
enum Spacing {
|
||||||
|
static let xxSmall: CGFloat = 2
|
||||||
|
static let xSmall: CGFloat = 4
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let xLarge: CGFloat = 20
|
||||||
|
}
|
||||||
|
enum CornerRadius {
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
}
|
||||||
|
enum BaseFontSize {
|
||||||
|
static let small: CGFloat = 10
|
||||||
|
static let body: CGFloat = 14
|
||||||
|
static let large: CGFloat = 18
|
||||||
|
static let title: CGFloat = 24
|
||||||
|
}
|
||||||
|
enum Opacity {
|
||||||
|
static let subtle: Double = 0.1
|
||||||
|
static let hint: Double = 0.2
|
||||||
|
static let light: Double = 0.3
|
||||||
|
static let medium: Double = 0.5
|
||||||
|
static let accent: Double = 0.6
|
||||||
|
static let strong: Double = 0.7
|
||||||
|
static let heavy: Double = 0.8
|
||||||
|
static let almostFull: Double = 0.9
|
||||||
|
}
|
||||||
|
enum LineWidth {
|
||||||
|
static let thin: CGFloat = 1
|
||||||
|
static let medium: CGFloat = 2
|
||||||
|
static let thick: CGFloat = 3
|
||||||
|
}
|
||||||
|
enum Shadow {
|
||||||
|
static let radiusSmall: CGFloat = 2
|
||||||
|
static let radiusMedium: CGFloat = 6
|
||||||
|
static let radiusLarge: CGFloat = 10
|
||||||
|
static let offsetSmall: CGFloat = 1
|
||||||
|
static let offsetMedium: CGFloat = 3
|
||||||
|
}
|
||||||
|
enum Animation {
|
||||||
|
static let quick: Double = 0.3
|
||||||
|
static let springDuration: Double = 0.4
|
||||||
|
static let staggerDelay1: Double = 0.1
|
||||||
|
static let staggerDelay2: Double = 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- For colors used across the app, extend `Color` with semantic color definitions:
|
||||||
|
```swift
|
||||||
|
extension Color {
|
||||||
|
enum Primary {
|
||||||
|
static let background = Color(red: 0.1, green: 0.2, blue: 0.3)
|
||||||
|
static let accent = Color(red: 0.8, green: 0.6, blue: 0.2)
|
||||||
|
}
|
||||||
|
enum Button {
|
||||||
|
static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3)
|
||||||
|
static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local:
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
// Layout: fixed card dimensions for consistent appearance
|
||||||
|
private let cardWidth: CGFloat = 45
|
||||||
|
// Typography: constrained space requires fixed size
|
||||||
|
private let headerFontSize: CGFloat = 18
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`.
|
||||||
|
- Keep design constants organized by category: Spacing, CornerRadius, BaseFontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow.
|
||||||
|
- When adding new features, check existing constants first before creating new ones.
|
||||||
|
- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`.
|
||||||
|
|
||||||
|
|
||||||
|
## Dynamic Type instructions
|
||||||
|
|
||||||
|
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling.
|
||||||
|
- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings:
|
||||||
|
```swift
|
||||||
|
struct MyView: View {
|
||||||
|
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14
|
||||||
|
@ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var chipTextSize: CGFloat = 11
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text("Hello")
|
||||||
|
.font(.system(size: bodyFontSize, weight: .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Choose the appropriate `relativeTo` text style based on the semantic purpose:
|
||||||
|
- `.largeTitle`, `.title`, `.title2`, `.title3` for headings
|
||||||
|
- `.headline`, `.subheadline` for emphasized content
|
||||||
|
- `.body` for main content
|
||||||
|
- `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text
|
||||||
|
- For constrained UI elements (chips, cards, badges) where overflow would break the design, you may use fixed sizes but document the reason:
|
||||||
|
```swift
|
||||||
|
// Fixed size: chip face has strict space constraints
|
||||||
|
private let chipValueFontSize: CGFloat = 11
|
||||||
|
```
|
||||||
|
- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
|
||||||
|
- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text.
|
||||||
|
|
||||||
|
|
||||||
|
## VoiceOver accessibility instructions
|
||||||
|
|
||||||
|
- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`.
|
||||||
|
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value).
|
||||||
|
- Use `.accessibilityHint()` to describe what will happen when interacting with an element:
|
||||||
|
```swift
|
||||||
|
Button("Deal", action: deal)
|
||||||
|
.accessibilityHint("Deals cards and starts the round")
|
||||||
|
```
|
||||||
|
- Use `.accessibilityAddTraits()` to communicate element type:
|
||||||
|
- `.isButton` for tappable elements that aren't SwiftUI Buttons
|
||||||
|
- `.isHeader` for section headers
|
||||||
|
- `.isModal` for modal overlays
|
||||||
|
- `.updatesFrequently` for live-updating content
|
||||||
|
- Hide purely decorative elements from VoiceOver:
|
||||||
|
```swift
|
||||||
|
TableBackgroundView()
|
||||||
|
.accessibilityHidden(true) // Decorative element
|
||||||
|
```
|
||||||
|
- Group related elements to reduce VoiceOver navigation complexity:
|
||||||
|
```swift
|
||||||
|
VStack {
|
||||||
|
handLabel
|
||||||
|
cardStack
|
||||||
|
valueDisplay
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Player hand")
|
||||||
|
.accessibilityValue("Ace of Hearts, King of Spades. Value: 1")
|
||||||
|
```
|
||||||
|
- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context.
|
||||||
|
- Post accessibility announcements for important events:
|
||||||
|
```swift
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
UIAccessibility.post(notification: .announcement, argument: "Player wins!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Provide accessibility names for model types that appear in UI:
|
||||||
|
```swift
|
||||||
|
enum Suit {
|
||||||
|
var accessibilityName: String {
|
||||||
|
switch self {
|
||||||
|
case .hearts: return String(localized: "Hearts")
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver.
|
||||||
|
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- Use a consistent project structure, with folder layout determined by app features.
|
||||||
|
- Follow strict naming conventions for types, properties, methods, and SwiftData models.
|
||||||
|
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
|
||||||
|
- Write unit tests for core application logic.
|
||||||
|
- Only write UI tests if unit tests are not possible.
|
||||||
|
- Add code comments and documentation comments as needed.
|
||||||
|
- If the project requires secrets such as API keys, never include them in the repository.
|
||||||
|
|
||||||
|
|
||||||
|
## Documentation instructions
|
||||||
|
|
||||||
|
- **Always keep each game's `README.md` file up to date** when adding new functionality or making changes that users or developers need to know about.
|
||||||
|
- Document new features, settings, or gameplay mechanics in the appropriate game's README.
|
||||||
|
- Update the README when modifying existing behavior that affects how the game works.
|
||||||
|
- Include any configuration options, keyboard shortcuts, or special interactions.
|
||||||
|
- If adding a new game to the workspace, create a comprehensive README following the existing games' format.
|
||||||
|
- README updates should be part of the same commit as the feature/change they document.
|
||||||
|
|
||||||
|
|
||||||
|
## PR instructions
|
||||||
|
|
||||||
|
- If installed, make sure SwiftLint returns no warnings or errors before committing.
|
||||||
|
- Verify that the game's README.md reflects any new functionality or behavioral changes.
|
||||||
@ -24,14 +24,33 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
60186E73BC8040538616865B /* BusinessCardWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCardWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA8379232F105F2600077F87 /* BusinessCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA8379232F105F2600077F87 /* BusinessCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusinessCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BusinessCardUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = EA8379222F105F2600077F87 /* BusinessCard */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
05CFDAD65474442D8E3E309E /* BusinessCardWatch */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = BusinessCardWatch;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
EA8379252F105F2600077F87 /* BusinessCard */ = {
|
EA8379252F105F2600077F87 /* BusinessCard */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */,
|
||||||
|
);
|
||||||
path = BusinessCard;
|
path = BusinessCard;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -48,6 +67,13 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
93EDDE26B3EB4E32AF5B58FC /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
EA8379202F105F2600077F87 /* Frameworks */ = {
|
EA8379202F105F2600077F87 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -76,6 +102,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EA8379252F105F2600077F87 /* BusinessCard */,
|
EA8379252F105F2600077F87 /* BusinessCard */,
|
||||||
|
05CFDAD65474442D8E3E309E /* BusinessCardWatch */,
|
||||||
EA8379332F105F2800077F87 /* BusinessCardTests */,
|
EA8379332F105F2800077F87 /* BusinessCardTests */,
|
||||||
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
|
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
|
||||||
EA8379242F105F2600077F87 /* Products */,
|
EA8379242F105F2600077F87 /* Products */,
|
||||||
@ -86,6 +113,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EA8379232F105F2600077F87 /* BusinessCard.app */,
|
EA8379232F105F2600077F87 /* BusinessCard.app */,
|
||||||
|
60186E73BC8040538616865B /* BusinessCardWatch.app */,
|
||||||
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
|
||||||
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
|
||||||
);
|
);
|
||||||
@ -95,6 +123,28 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
D007169724A44109B518B9E6 /* BusinessCardWatch */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 3873468A4B2043BDAA689772 /* Build configuration list for PBXNativeTarget "BusinessCardWatch" */;
|
||||||
|
buildPhases = (
|
||||||
|
7D1EBA94A23F41D5A441C5E4 /* Sources */,
|
||||||
|
93EDDE26B3EB4E32AF5B58FC /* Frameworks */,
|
||||||
|
9F6436BCE5F34967B6A4509D /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
05CFDAD65474442D8E3E309E /* BusinessCardWatch */,
|
||||||
|
);
|
||||||
|
name = BusinessCardWatch;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = BusinessCardWatch;
|
||||||
|
productReference = 60186E73BC8040538616865B /* BusinessCardWatch.app */;
|
||||||
|
productType = "com.apple.product-type.application.watchapp2";
|
||||||
|
};
|
||||||
EA8379222F105F2600077F87 /* BusinessCard */ = {
|
EA8379222F105F2600077F87 /* BusinessCard */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = EA8379442F105F2800077F87 /* Build configuration list for PBXNativeTarget "BusinessCard" */;
|
buildConfigurationList = EA8379442F105F2800077F87 /* Build configuration list for PBXNativeTarget "BusinessCard" */;
|
||||||
@ -173,6 +223,9 @@
|
|||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2600;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
D007169724A44109B518B9E6 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
EA8379222F105F2600077F87 = {
|
EA8379222F105F2600077F87 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
@ -191,6 +244,8 @@
|
|||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
"es-MX",
|
||||||
|
"fr-CA",
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = EA83791A2F105F2600077F87;
|
mainGroup = EA83791A2F105F2600077F87;
|
||||||
@ -201,6 +256,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
EA8379222F105F2600077F87 /* BusinessCard */,
|
EA8379222F105F2600077F87 /* BusinessCard */,
|
||||||
|
D007169724A44109B518B9E6 /* BusinessCardWatch */,
|
||||||
EA83792F2F105F2800077F87 /* BusinessCardTests */,
|
EA83792F2F105F2800077F87 /* BusinessCardTests */,
|
||||||
EA8379392F105F2800077F87 /* BusinessCardUITests */,
|
EA8379392F105F2800077F87 /* BusinessCardUITests */,
|
||||||
);
|
);
|
||||||
@ -208,6 +264,13 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
9F6436BCE5F34967B6A4509D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
EA8379212F105F2600077F87 /* Resources */ = {
|
EA8379212F105F2600077F87 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -232,6 +295,13 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
7D1EBA94A23F41D5A441C5E4 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
EA83791F2F105F2600077F87 /* Sources */ = {
|
EA83791F2F105F2600077F87 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -269,6 +339,56 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
2AA803F1BF6442BEBBEA0D74 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BusinessCardWatch/BusinessCardWatch.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCardWatch;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = watchos;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 6.2;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B9B3B52E9CBF4C0BA6813348 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BusinessCardWatch/BusinessCardWatch.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BusinessCardWatch;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = watchos;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 6.2;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 12.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
EA8379422F105F2800077F87 /* Debug */ = {
|
EA8379422F105F2800077F87 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@ -395,11 +515,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BusinessCard/BusinessCard.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = BusinessCard/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -417,7 +539,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@ -427,11 +549,13 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BusinessCard/BusinessCard.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = BusinessCard/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -449,7 +573,7 @@
|
|||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@ -470,7 +594,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
|
||||||
};
|
};
|
||||||
@ -492,7 +616,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
|
||||||
};
|
};
|
||||||
@ -512,7 +636,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = BusinessCard;
|
TEST_TARGET_NAME = BusinessCard;
|
||||||
};
|
};
|
||||||
@ -532,7 +656,7 @@
|
|||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 6.2;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = BusinessCard;
|
TEST_TARGET_NAME = BusinessCard;
|
||||||
};
|
};
|
||||||
@ -541,6 +665,15 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
3873468A4B2043BDAA689772 /* Build configuration list for PBXNativeTarget "BusinessCardWatch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
2AA803F1BF6442BEBBEA0D74 /* Debug */,
|
||||||
|
B9B3B52E9CBF4C0BA6813348 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */ = {
|
EA83791E2F105F2600077F87 /* Build configuration list for PBXProject "BusinessCard" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@ -5,6 +5,11 @@
|
|||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
<key>BusinessCard.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<key>BusinessCardWatch.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
|
|||||||
20
BusinessCard/BusinessCard.entitlements
Normal file
20
BusinessCard/BusinessCard.entitlements
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.mbrucedogs.BusinessCard</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.mbrucedogs.BusinessCard</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -1,17 +1,42 @@
|
|||||||
//
|
|
||||||
// BusinessCardApp.swift
|
|
||||||
// BusinessCard
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 1/8/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BusinessCardApp: App {
|
struct BusinessCardApp: App {
|
||||||
var body: some Scene {
|
private let modelContainer: ModelContainer
|
||||||
WindowGroup {
|
@State private var appState: AppState
|
||||||
ContentView()
|
|
||||||
|
init() {
|
||||||
|
let schema = Schema([BusinessCard.self, Contact.self])
|
||||||
|
|
||||||
|
let appGroupURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
|
||||||
|
)
|
||||||
|
|
||||||
|
let storeURL = appGroupURL?.appending(path: "BusinessCard.store")
|
||||||
|
?? URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
||||||
|
|
||||||
|
let configuration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
url: storeURL,
|
||||||
|
cloudKitDatabase: .automatic
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let container = try ModelContainer(for: schema, configurations: [configuration])
|
||||||
|
self.modelContainer = container
|
||||||
|
let context = container.mainContext
|
||||||
|
self._appState = State(initialValue: AppState(modelContext: context))
|
||||||
|
} catch {
|
||||||
|
fatalError("Failed to create ModelContainer: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootTabView()
|
||||||
|
.environment(appState)
|
||||||
|
}
|
||||||
|
.modelContainer(modelContainer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,13 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
RootTabView()
|
||||||
Image(systemName: "globe")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
BusinessCard/Design/DesignConstants.swift
Normal file
108
BusinessCard/Design/DesignConstants.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Design {
|
||||||
|
enum Spacing {
|
||||||
|
static let xxSmall: CGFloat = 2
|
||||||
|
static let xSmall: CGFloat = 4
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let xLarge: CGFloat = 20
|
||||||
|
static let xxLarge: CGFloat = 28
|
||||||
|
static let xxxLarge: CGFloat = 36
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CornerRadius {
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 18
|
||||||
|
static let xLarge: CGFloat = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BaseFontSize {
|
||||||
|
static let small: CGFloat = 12
|
||||||
|
static let body: CGFloat = 15
|
||||||
|
static let large: CGFloat = 18
|
||||||
|
static let title: CGFloat = 24
|
||||||
|
static let display: CGFloat = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Opacity {
|
||||||
|
static let subtle: Double = 0.1
|
||||||
|
static let hint: Double = 0.2
|
||||||
|
static let light: Double = 0.3
|
||||||
|
static let medium: Double = 0.5
|
||||||
|
static let accent: Double = 0.6
|
||||||
|
static let strong: Double = 0.7
|
||||||
|
static let heavy: Double = 0.85
|
||||||
|
static let almostFull: Double = 0.95
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LineWidth {
|
||||||
|
static let thin: CGFloat = 1
|
||||||
|
static let medium: CGFloat = 2
|
||||||
|
static let thick: CGFloat = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Shadow {
|
||||||
|
static let radiusSmall: CGFloat = 3
|
||||||
|
static let radiusMedium: CGFloat = 8
|
||||||
|
static let radiusLarge: CGFloat = 14
|
||||||
|
static let offsetNone: CGFloat = 0
|
||||||
|
static let offsetSmall: CGFloat = 2
|
||||||
|
static let offsetMedium: CGFloat = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Animation {
|
||||||
|
static let quick: Double = 0.25
|
||||||
|
static let springDuration: Double = 0.4
|
||||||
|
static let staggerDelayShort: Double = 0.08
|
||||||
|
static let staggerDelayMedium: Double = 0.16
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Size {
|
||||||
|
static let cardWidth: CGFloat = 320
|
||||||
|
static let cardHeight: CGFloat = 200
|
||||||
|
static let avatarSize: CGFloat = 56
|
||||||
|
static let qrSize: CGFloat = 200
|
||||||
|
static let widgetPhoneWidth: CGFloat = 220
|
||||||
|
static let widgetPhoneHeight: CGFloat = 120
|
||||||
|
static let widgetWatchSize: CGFloat = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
enum AppBackground {
|
||||||
|
static let base = Color(red: 0.97, green: 0.96, blue: 0.94)
|
||||||
|
static let elevated = Color(red: 1.0, green: 1.0, blue: 1.0)
|
||||||
|
static let accent = Color(red: 0.95, green: 0.91, blue: 0.86)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CardPalette {
|
||||||
|
static let coral = Color(red: 0.95, green: 0.35, blue: 0.33)
|
||||||
|
static let midnight = Color(red: 0.12, green: 0.16, blue: 0.22)
|
||||||
|
static let ocean = Color(red: 0.08, green: 0.45, blue: 0.56)
|
||||||
|
static let lime = Color(red: 0.73, green: 0.82, blue: 0.34)
|
||||||
|
static let violet = Color(red: 0.42, green: 0.36, blue: 0.62)
|
||||||
|
static let sand = Color(red: 0.93, green: 0.83, blue: 0.68)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Accent {
|
||||||
|
static let red = Color(red: 0.95, green: 0.33, blue: 0.28)
|
||||||
|
static let gold = Color(red: 0.95, green: 0.75, blue: 0.25)
|
||||||
|
static let mint = Color(red: 0.2, green: 0.65, blue: 0.55)
|
||||||
|
static let ink = Color(red: 0.12, green: 0.12, blue: 0.14)
|
||||||
|
static let slate = Color(red: 0.29, green: 0.33, blue: 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Text {
|
||||||
|
static let primary = Color(red: 0.14, green: 0.14, blue: 0.17)
|
||||||
|
static let secondary = Color(red: 0.32, green: 0.34, blue: 0.4)
|
||||||
|
static let inverted = Color(red: 0.98, green: 0.98, blue: 0.98)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Badge {
|
||||||
|
static let star = Color(red: 0.98, green: 0.82, blue: 0.34)
|
||||||
|
static let neutral = Color(red: 0.89, green: 0.89, blue: 0.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BusinessCard/Info.plist
Normal file
10
BusinessCard/Info.plist
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
12
BusinessCard/Localization/String+Localization.swift
Normal file
12
BusinessCard/Localization/String+Localization.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
static func localized(_ key: String) -> String {
|
||||||
|
String(localized: String.LocalizationValue(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func localized(_ key: String, _ arguments: CVarArg...) -> String {
|
||||||
|
let format = String(localized: String.LocalizationValue(key))
|
||||||
|
return String(format: format, arguments: arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
BusinessCard/Models/AppTab.swift
Normal file
11
BusinessCard/Models/AppTab.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppTab: String, CaseIterable, Hashable, Identifiable {
|
||||||
|
case cards
|
||||||
|
case share
|
||||||
|
case customize
|
||||||
|
case contacts
|
||||||
|
case widgets
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
142
BusinessCard/Models/BusinessCard.swift
Normal file
142
BusinessCard/Models/BusinessCard.swift
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class BusinessCard {
|
||||||
|
var id: UUID
|
||||||
|
var displayName: String
|
||||||
|
var role: String
|
||||||
|
var company: String
|
||||||
|
var label: String
|
||||||
|
var email: String
|
||||||
|
var phone: String
|
||||||
|
var website: String
|
||||||
|
var location: String
|
||||||
|
var isDefault: Bool
|
||||||
|
var themeName: String
|
||||||
|
var layoutStyleRawValue: String
|
||||||
|
var avatarSystemName: String
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
displayName: String = "",
|
||||||
|
role: String = "",
|
||||||
|
company: String = "",
|
||||||
|
label: String = "Work",
|
||||||
|
email: String = "",
|
||||||
|
phone: String = "",
|
||||||
|
website: String = "",
|
||||||
|
location: String = "",
|
||||||
|
isDefault: Bool = false,
|
||||||
|
themeName: String = "Coral",
|
||||||
|
layoutStyleRawValue: String = "stacked",
|
||||||
|
avatarSystemName: String = "person.crop.circle",
|
||||||
|
createdAt: Date = .now,
|
||||||
|
updatedAt: Date = .now
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.displayName = displayName
|
||||||
|
self.role = role
|
||||||
|
self.company = company
|
||||||
|
self.label = label
|
||||||
|
self.email = email
|
||||||
|
self.phone = phone
|
||||||
|
self.website = website
|
||||||
|
self.location = location
|
||||||
|
self.isDefault = isDefault
|
||||||
|
self.themeName = themeName
|
||||||
|
self.layoutStyleRawValue = layoutStyleRawValue
|
||||||
|
self.avatarSystemName = avatarSystemName
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var theme: CardTheme {
|
||||||
|
get { CardTheme(rawValue: themeName) ?? .coral }
|
||||||
|
set { themeName = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var layoutStyle: CardLayoutStyle {
|
||||||
|
get { CardLayoutStyle(rawValue: layoutStyleRawValue) ?? .stacked }
|
||||||
|
set { layoutStyleRawValue = newValue.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var shareURL: URL {
|
||||||
|
let base = URL(string: "https://cards.example") ?? URL.documentsDirectory
|
||||||
|
return base.appending(path: id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var vCardPayload: String {
|
||||||
|
let lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:3.0",
|
||||||
|
"FN:\(displayName)",
|
||||||
|
"ORG:\(company)",
|
||||||
|
"TITLE:\(role)",
|
||||||
|
"TEL;TYPE=work:\(phone)",
|
||||||
|
"EMAIL;TYPE=work:\(email)",
|
||||||
|
"URL:\(website)",
|
||||||
|
"ADR;TYPE=work:;;\(location)",
|
||||||
|
"END:VCARD"
|
||||||
|
]
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BusinessCard {
|
||||||
|
@MainActor
|
||||||
|
static func createSamples(in context: ModelContext) {
|
||||||
|
let samples = [
|
||||||
|
BusinessCard(
|
||||||
|
displayName: "Daniel Sullivan",
|
||||||
|
role: "Property Developer",
|
||||||
|
company: "WR Construction",
|
||||||
|
label: "Work",
|
||||||
|
email: "daniel@wrconstruction.co",
|
||||||
|
phone: "+1 (214) 987-7810",
|
||||||
|
website: "wrconstruction.co",
|
||||||
|
location: "Dallas, TX",
|
||||||
|
isDefault: true,
|
||||||
|
themeName: "Coral",
|
||||||
|
layoutStyleRawValue: "split",
|
||||||
|
avatarSystemName: "person.crop.circle"
|
||||||
|
),
|
||||||
|
BusinessCard(
|
||||||
|
displayName: "Maya Chen",
|
||||||
|
role: "Creative Lead",
|
||||||
|
company: "Signal Studio",
|
||||||
|
label: "Creative",
|
||||||
|
email: "maya@signal.studio",
|
||||||
|
phone: "+1 (312) 404-2211",
|
||||||
|
website: "signal.studio",
|
||||||
|
location: "Chicago, IL",
|
||||||
|
isDefault: false,
|
||||||
|
themeName: "Midnight",
|
||||||
|
layoutStyleRawValue: "stacked",
|
||||||
|
avatarSystemName: "sparkles"
|
||||||
|
),
|
||||||
|
BusinessCard(
|
||||||
|
displayName: "DJ Michaels",
|
||||||
|
role: "DJ",
|
||||||
|
company: "Live Sessions",
|
||||||
|
label: "Music",
|
||||||
|
email: "dj@livesessions.fm",
|
||||||
|
phone: "+1 (646) 222-3300",
|
||||||
|
website: "livesessions.fm",
|
||||||
|
location: "New York, NY",
|
||||||
|
isDefault: false,
|
||||||
|
themeName: "Ocean",
|
||||||
|
layoutStyleRawValue: "photo",
|
||||||
|
avatarSystemName: "music.mic"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
context.insert(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
BusinessCard/Models/CardLayoutStyle.swift
Normal file
20
BusinessCard/Models/CardLayoutStyle.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CardLayoutStyle: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
case stacked
|
||||||
|
case split
|
||||||
|
case photo
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .stacked:
|
||||||
|
return String.localized("Stacked")
|
||||||
|
case .split:
|
||||||
|
return String.localized("Split")
|
||||||
|
case .photo:
|
||||||
|
return String.localized("Photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
BusinessCard/Models/CardTheme.swift
Normal file
67
BusinessCard/Models/CardTheme.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Card theme identifier - stores just the name, colors computed on MainActor
|
||||||
|
enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
|
||||||
|
case coral = "Coral"
|
||||||
|
case midnight = "Midnight"
|
||||||
|
case ocean = "Ocean"
|
||||||
|
case lime = "Lime"
|
||||||
|
case violet = "Violet"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
var name: String { rawValue }
|
||||||
|
|
||||||
|
var localizedName: String {
|
||||||
|
String.localized(rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func theme(named name: String) -> CardTheme {
|
||||||
|
CardTheme(rawValue: name) ?? .coral
|
||||||
|
}
|
||||||
|
|
||||||
|
static var all: [CardTheme] { allCases }
|
||||||
|
|
||||||
|
// RGB values - nonisolated
|
||||||
|
private var primaryRGB: (Double, Double, Double) {
|
||||||
|
switch self {
|
||||||
|
case .coral: return (0.95, 0.35, 0.33)
|
||||||
|
case .midnight: return (0.12, 0.16, 0.22)
|
||||||
|
case .ocean: return (0.08, 0.45, 0.56)
|
||||||
|
case .lime: return (0.73, 0.82, 0.34)
|
||||||
|
case .violet: return (0.42, 0.36, 0.62)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondaryRGB: (Double, Double, Double) {
|
||||||
|
switch self {
|
||||||
|
case .coral: return (0.93, 0.83, 0.68)
|
||||||
|
case .midnight: return (0.29, 0.33, 0.4)
|
||||||
|
case .ocean: return (0.2, 0.65, 0.55)
|
||||||
|
case .lime: return (0.93, 0.83, 0.68)
|
||||||
|
case .violet: return (0.29, 0.33, 0.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accentRGB: (Double, Double, Double) {
|
||||||
|
switch self {
|
||||||
|
case .coral: return (0.95, 0.33, 0.28)
|
||||||
|
case .midnight: return (0.95, 0.75, 0.25)
|
||||||
|
case .ocean: return (0.95, 0.75, 0.25)
|
||||||
|
case .lime: return (0.12, 0.12, 0.14)
|
||||||
|
case .violet: return (0.95, 0.75, 0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors - computed from RGB
|
||||||
|
@MainActor var primaryColor: Color {
|
||||||
|
Color(red: primaryRGB.0, green: primaryRGB.1, blue: primaryRGB.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor var secondaryColor: Color {
|
||||||
|
Color(red: secondaryRGB.0, green: secondaryRGB.1, blue: secondaryRGB.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor var accentColor: Color {
|
||||||
|
Color(red: accentRGB.0, green: accentRGB.1, blue: accentRGB.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
BusinessCard/Models/Contact.swift
Normal file
82
BusinessCard/Models/Contact.swift
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class Contact {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var role: String
|
||||||
|
var company: String
|
||||||
|
var avatarSystemName: String
|
||||||
|
var lastSharedDate: Date
|
||||||
|
var cardLabel: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String = "",
|
||||||
|
role: String = "",
|
||||||
|
company: String = "",
|
||||||
|
avatarSystemName: String = "person.crop.circle",
|
||||||
|
lastSharedDate: Date = .now,
|
||||||
|
cardLabel: String = "Work"
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.role = role
|
||||||
|
self.company = company
|
||||||
|
self.avatarSystemName = avatarSystemName
|
||||||
|
self.lastSharedDate = lastSharedDate
|
||||||
|
self.cardLabel = cardLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Contact {
|
||||||
|
static func createSamples(in context: ModelContext) {
|
||||||
|
let samples = [
|
||||||
|
Contact(
|
||||||
|
name: "Kevin Lennox",
|
||||||
|
role: "Branch Manager",
|
||||||
|
company: "Global Bank",
|
||||||
|
avatarSystemName: "person.crop.circle",
|
||||||
|
lastSharedDate: .now.addingTimeInterval(-86400 * 14),
|
||||||
|
cardLabel: "Work"
|
||||||
|
),
|
||||||
|
Contact(
|
||||||
|
name: "Jenny Wright",
|
||||||
|
role: "UX Designer",
|
||||||
|
company: "App Foundry",
|
||||||
|
avatarSystemName: "person.crop.circle.fill",
|
||||||
|
lastSharedDate: .now.addingTimeInterval(-86400 * 45),
|
||||||
|
cardLabel: "Creative"
|
||||||
|
),
|
||||||
|
Contact(
|
||||||
|
name: "Pip McDowell",
|
||||||
|
role: "Creative Director",
|
||||||
|
company: "Future Noise",
|
||||||
|
avatarSystemName: "person.crop.square",
|
||||||
|
lastSharedDate: .now.addingTimeInterval(-86400 * 2),
|
||||||
|
cardLabel: "Creative"
|
||||||
|
),
|
||||||
|
Contact(
|
||||||
|
name: "Ron James",
|
||||||
|
role: "CEO",
|
||||||
|
company: "CloudSwitch",
|
||||||
|
avatarSystemName: "person.circle",
|
||||||
|
lastSharedDate: .now.addingTimeInterval(-86400 * 90),
|
||||||
|
cardLabel: "Work"
|
||||||
|
),
|
||||||
|
Contact(
|
||||||
|
name: "Alex Lindsey",
|
||||||
|
role: "Editor",
|
||||||
|
company: "Post Media Studios",
|
||||||
|
avatarSystemName: "person.crop.circle",
|
||||||
|
lastSharedDate: .now.addingTimeInterval(-86400 * 7),
|
||||||
|
cardLabel: "Press"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
context.insert(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
BusinessCard/Protocols/BusinessCardProviding.swift
Normal file
17
BusinessCard/Protocols/BusinessCardProviding.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol BusinessCardProviding: AnyObject {
|
||||||
|
var cards: [BusinessCard] { get }
|
||||||
|
var selectedCardID: UUID? { get set }
|
||||||
|
|
||||||
|
func selectCard(id: UUID)
|
||||||
|
func addCard(_ card: BusinessCard)
|
||||||
|
func updateCard(_ card: BusinessCard)
|
||||||
|
func deleteCard(_ card: BusinessCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BusinessCardProviding {
|
||||||
|
func selectCard(id: UUID) {
|
||||||
|
selectedCardID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
6
BusinessCard/Protocols/ContactTracking.swift
Normal file
6
BusinessCard/Protocols/ContactTracking.swift
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol ContactTracking {
|
||||||
|
var contacts: [Contact] { get }
|
||||||
|
func recordShare(for name: String, role: String, company: String, cardLabel: String)
|
||||||
|
}
|
||||||
5
BusinessCard/Protocols/QRCodeProviding.swift
Normal file
5
BusinessCard/Protocols/QRCodeProviding.swift
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
protocol QRCodeProviding {
|
||||||
|
func qrCode(from payload: String) -> CGImage?
|
||||||
|
}
|
||||||
9
BusinessCard/Protocols/ShareLinkProviding.swift
Normal file
9
BusinessCard/Protocols/ShareLinkProviding.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol ShareLinkProviding {
|
||||||
|
func shareURL(for card: BusinessCard) -> URL
|
||||||
|
func smsURL(for card: BusinessCard) -> URL
|
||||||
|
func emailURL(for card: BusinessCard) -> URL
|
||||||
|
func whatsappURL(for card: BusinessCard) -> URL
|
||||||
|
func linkedInURL(for card: BusinessCard) -> URL
|
||||||
|
}
|
||||||
511
BusinessCard/Resources/Localizable.xcstrings
Normal file
511
BusinessCard/Resources/Localizable.xcstrings
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
{
|
||||||
|
"version" : "1.0",
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"4.9" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "4.9" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "4.9" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "4.9" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"100k+" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "100k+" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "100k+" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "100k+" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Add a QR widget so your card is always one tap away." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Add a QR widget so your card is always one tap away." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Agrega un widget QR para tener tu tarjeta a un toque." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Ajoutez un widget QR pour avoir votre carte à portée d’un tap." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Add to Apple Wallet" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Add to Apple Wallet" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Agregar a Apple Wallet" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Ajouter à Apple Wallet" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"App Rating" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "App Rating" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Calificación" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Note" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Apple Wallet" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Apple Wallet" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Apple Wallet" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Apple Wallet" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Business card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Business card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Tarjeta de presentación" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Carte professionnelle" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Card style" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Card style" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Estilo de tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Style de carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Change image layout" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Change image layout" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Cambiar distribución de imágenes" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Modifier la disposition des images" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Choose a card in the My Cards tab to start sharing." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Choose a card in the My Cards tab to start sharing." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Elige una tarjeta en Mis tarjetas para comenzar a compartir." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Choisissez une carte dans Mes cartes pour commencer à partager." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Contacts" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Contacts" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Contactos" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Contacts" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Copy link" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Copy link" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Copiar enlace" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Copier le lien" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Create your digital business card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Create your digital business card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Crea tu tarjeta digital" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Créez votre carte numérique" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Create multiple business cards" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Create multiple business cards" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Crea varias tarjetas de presentación" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Créez plusieurs cartes professionnelles" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Customize" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Customize" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Personalizar" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Personnaliser" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Customize your card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Customize your card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Personaliza tu tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Personnalisez votre carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Default card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Default card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Tarjeta predeterminada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Carte par défaut" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Design and share polished cards for every context." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Design and share polished cards for every context." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Diseña y comparte tarjetas pulidas para cada contexto." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Concevez et partagez des cartes soignées pour chaque contexte." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Edit your card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Edit your card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Editar tu tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Modifier votre carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Email your card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Email your card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar por correo" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer par courriel" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Google" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Google" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Google" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Google" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Hold your phone near another device to share instantly. NFC setup is on the way." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Hold your phone near another device to share instantly. NFC setup is on the way." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Acerca tu teléfono a otro dispositivo para compartir al instante. La configuración NFC llegará pronto." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Approchez votre téléphone d’un autre appareil pour partager instantanément. La configuration NFC arrive bientôt." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Images & layout" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Images & layout" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Imágenes y diseño" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Images et mise en page" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Layout" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Diseño" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Disposition" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"My Cards" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "My Cards" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Mis tarjetas" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Mes cartes" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NFC Sharing" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "NFC Sharing" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Compartir por NFC" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Partage NFC" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"No card selected" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "No card selected" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "No hay tarjeta seleccionada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Aucune carte sélectionnée" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"OK" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "OK" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Open on Apple Watch" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Open on Apple Watch" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Abrir en Apple Watch" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir sur Apple Watch" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Phone Widget" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Phone Widget" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Widget del teléfono" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Widget du téléphone" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Photo" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Photo" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Foto" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Photo" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Point your camera at the QR code to receive the card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Point your camera at the QR code to receive the card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Apunta tu cámara al código QR para recibir la tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Pointez votre caméra sur le code QR pour recevoir la carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"QR code" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "QR code" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Código QR" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Code QR" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Ready to scan" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Ready to scan" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Listo para escanear" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Prêt à scanner" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Reviews" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Reviews" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Reseñas" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Avis" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Search contacts" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Search contacts" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Buscar contactos" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Rechercher des contacts" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Send Work Card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Send Work Card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar tarjeta de trabajo" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer la carte de travail" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Send my card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Send my card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar mi tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer ma carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Send via LinkedIn" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Send via LinkedIn" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar por LinkedIn" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer via LinkedIn" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Send via WhatsApp" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Send via WhatsApp" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar por WhatsApp" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer via WhatsApp" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Set as default" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Set as default" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Establecer como predeterminada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Définir par défaut" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sets this card as your default sharing card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Sets this card as your default sharing card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Establece esta tarjeta como la predeterminada para compartir" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Définit cette carte comme carte de partage par défaut" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Share" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Share" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Compartir" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Partager" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Share via NFC" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Share via NFC" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Compartir por NFC" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Partager via NFC" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Share with anyone" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Share with anyone" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Comparte con cualquiera" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Partagez avec tout le monde" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Share using widgets on your phone or watch" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Share using widgets on your phone or watch" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Comparte con widgets en tu teléfono o reloj" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Partagez avec des widgets sur votre téléphone ou montre" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ShareEmailBody" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Here is %@'s digital business card: %@" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Aquí está la tarjeta digital de %@: %@" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Voici la carte numérique de %@ : %@" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ShareEmailSubject" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "%@'s business card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Tarjeta de %@" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Carte de %@" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ShareTextBody" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Hi, I'm %@. Tap this link to get my business card: %@" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Hola, soy %@. Toca este enlace para obtener mi tarjeta: %@" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Bonjour, je suis %@. Touchez ce lien pour obtenir ma carte : %@" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ShareWhatsAppBody" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Hi, I'm %@. Here's my card: %@" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Hola, soy %@. Aquí está mi tarjeta: %@" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Bonjour, je suis %@. Voici ma carte : %@" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Split" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Split" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Dividida" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Divisée" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Stacked" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Stacked" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Apilada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Empilée" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tap to share" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Tap to share" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Toca para compartir" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Touchez pour partager" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Tesla" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Tesla" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Tesla" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Tesla" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Text your card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Text your card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Enviar por mensaje" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer par texto" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"The #1 Digital Business Card App" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "The #1 Digital Business Card App" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "La app #1 de tarjetas digitales" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "L’app no 1 de cartes numériques" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Track who receives your card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Track who receives your card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Rastrea quién recibe tu tarjeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Suivez qui reçoit votre carte" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Used by Industry Leaders" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Used by Industry Leaders" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Usada por líderes de la industria" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Utilisée par des leaders de l’industrie" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Wallet export is coming soon. We'll let you know as soon as it's ready." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Wallet export is coming soon. We'll let you know as soon as it's ready." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "La exportación a Wallet llegará pronto. Te avisaremos cuando esté lista." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "L’exportation vers Wallet arrive bientôt. Nous vous informerons dès que ce sera prêt." } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Coral" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Coral" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Coral" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Corail" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Midnight" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Midnight" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Medianoche" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Minuit" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Ocean" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Ocean" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Océano" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Océan" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Lime" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Lime" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Lima" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Lime" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Violet" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Violet" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Violeta" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Violet" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Watch Widget" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Watch Widget" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Widget del reloj" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Widget de la montre" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Widgets" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Work" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Work" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Trabajo" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Travail" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Creative" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Creative" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Creativa" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Créatif" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Music" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Music" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Música" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Musique" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Press" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Press" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Prensa" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Presse" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Citi" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Citi" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Citi" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Citi" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Select a card to start customizing." : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Select a card to start customizing." } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Selecciona una tarjeta para comenzar a personalizar." } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Sélectionnez une carte pour commencer à personnaliser." } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
BusinessCard/Services/QRCodeService.swift
Normal file
17
BusinessCard/Services/QRCodeService.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
BusinessCard/Services/ShareLinkService.swift
Normal file
32
BusinessCard/Services/ShareLinkService.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ShareLinkService: ShareLinkProviding {
|
||||||
|
func shareURL(for card: BusinessCard) -> URL {
|
||||||
|
card.shareURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func smsURL(for card: BusinessCard) -> URL {
|
||||||
|
let body = String.localized("ShareTextBody", card.displayName, card.shareURL.absoluteString)
|
||||||
|
let query = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return URL(string: "sms:&body=\(query)") ?? card.shareURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailURL(for card: BusinessCard) -> URL {
|
||||||
|
let subject = String.localized("ShareEmailSubject", card.displayName)
|
||||||
|
let body = String.localized("ShareEmailBody", card.displayName, card.shareURL.absoluteString)
|
||||||
|
let subjectQuery = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
let bodyQuery = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return URL(string: "mailto:?subject=\(subjectQuery)&body=\(bodyQuery)") ?? card.shareURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func whatsappURL(for card: BusinessCard) -> URL {
|
||||||
|
let message = String.localized("ShareWhatsAppBody", card.displayName, card.shareURL.absoluteString)
|
||||||
|
let query = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return URL(string: "https://wa.me/?text=\(query)") ?? card.shareURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkedInURL(for card: BusinessCard) -> URL {
|
||||||
|
let query = card.shareURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return URL(string: "https://www.linkedin.com/sharing/share-offsite/?url=\(query)") ?? card.shareURL
|
||||||
|
}
|
||||||
|
}
|
||||||
63
BusinessCard/Services/WatchSyncService.swift
Normal file
63
BusinessCard/Services/WatchSyncService.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Syncs card data to watchOS via shared App Group UserDefaults
|
||||||
|
struct WatchSyncService {
|
||||||
|
private static let appGroupID = "group.com.mbrucedogs.BusinessCard"
|
||||||
|
private static let cardsKey = "SyncedCards"
|
||||||
|
|
||||||
|
private static var sharedDefaults: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: appGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs the given cards to the shared App Group for watchOS to read
|
||||||
|
static func syncCards(_ cards: [BusinessCard]) {
|
||||||
|
guard let defaults = sharedDefaults else { return }
|
||||||
|
|
||||||
|
let syncableCards = cards.map { card in
|
||||||
|
SyncableCard(
|
||||||
|
id: card.id,
|
||||||
|
displayName: card.displayName,
|
||||||
|
role: card.role,
|
||||||
|
company: card.company,
|
||||||
|
email: card.email,
|
||||||
|
phone: card.phone,
|
||||||
|
website: card.website,
|
||||||
|
location: card.location,
|
||||||
|
isDefault: card.isDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let encoded = try? JSONEncoder().encode(syncableCards) {
|
||||||
|
defaults.set(encoded, forKey: cardsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simplified card structure that can be shared between iOS and watchOS
|
||||||
|
struct SyncableCard: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
var displayName: String
|
||||||
|
var role: String
|
||||||
|
var company: String
|
||||||
|
var email: String
|
||||||
|
var phone: String
|
||||||
|
var website: String
|
||||||
|
var location: String
|
||||||
|
var isDefault: Bool
|
||||||
|
|
||||||
|
var vCardPayload: String {
|
||||||
|
let lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:3.0",
|
||||||
|
"FN:\(displayName)",
|
||||||
|
"ORG:\(company)",
|
||||||
|
"TITLE:\(role)",
|
||||||
|
"TEL;TYPE=work:\(phone)",
|
||||||
|
"EMAIL;TYPE=work:\(email)",
|
||||||
|
"URL:\(website)",
|
||||||
|
"ADR;TYPE=work:;;\(location)",
|
||||||
|
"END:VCARD"
|
||||||
|
]
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
20
BusinessCard/State/AppState.swift
Normal file
20
BusinessCard/State/AppState.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class AppState {
|
||||||
|
var selectedTab: AppTab = .cards
|
||||||
|
var cardStore: CardStore
|
||||||
|
var contactsStore: ContactsStore
|
||||||
|
let shareLinkService: ShareLinkProviding
|
||||||
|
let qrCodeService: QRCodeProviding
|
||||||
|
|
||||||
|
init(modelContext: ModelContext) {
|
||||||
|
self.cardStore = CardStore(modelContext: modelContext)
|
||||||
|
self.contactsStore = ContactsStore(modelContext: modelContext)
|
||||||
|
self.shareLinkService = ShareLinkService()
|
||||||
|
self.qrCodeService = QRCodeService()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
BusinessCard/State/CardStore.swift
Normal file
102
BusinessCard/State/CardStore.swift
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class CardStore: BusinessCardProviding {
|
||||||
|
private let modelContext: ModelContext
|
||||||
|
private(set) var cards: [BusinessCard] = []
|
||||||
|
var selectedCardID: UUID?
|
||||||
|
|
||||||
|
init(modelContext: ModelContext) {
|
||||||
|
self.modelContext = modelContext
|
||||||
|
fetchCards()
|
||||||
|
|
||||||
|
if cards.isEmpty {
|
||||||
|
BusinessCard.createSamples(in: modelContext)
|
||||||
|
saveContext()
|
||||||
|
fetchCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selectedCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
|
||||||
|
syncToWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedCard: BusinessCard? {
|
||||||
|
guard let selectedCardID else { return nil }
|
||||||
|
return cards.first(where: { $0.id == selectedCardID })
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCards() {
|
||||||
|
let descriptor = FetchDescriptor<BusinessCard>(
|
||||||
|
sortBy: [SortDescriptor(\.createdAt, order: .forward)]
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
cards = try modelContext.fetch(descriptor)
|
||||||
|
} catch {
|
||||||
|
cards = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCard(_ card: BusinessCard) {
|
||||||
|
modelContext.insert(card)
|
||||||
|
saveContext()
|
||||||
|
fetchCards()
|
||||||
|
selectedCardID = card.id
|
||||||
|
syncToWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCard(_ card: BusinessCard) {
|
||||||
|
card.updatedAt = .now
|
||||||
|
saveContext()
|
||||||
|
fetchCards()
|
||||||
|
syncToWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCard(_ card: BusinessCard) {
|
||||||
|
let wasSelected = selectedCardID == card.id
|
||||||
|
modelContext.delete(card)
|
||||||
|
saveContext()
|
||||||
|
fetchCards()
|
||||||
|
|
||||||
|
if wasSelected {
|
||||||
|
selectedCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
|
||||||
|
}
|
||||||
|
syncToWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedTheme(_ theme: CardTheme) {
|
||||||
|
guard let card = selectedCard else { return }
|
||||||
|
card.theme = theme
|
||||||
|
updateCard(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedLayout(_ layout: CardLayoutStyle) {
|
||||||
|
guard let card = selectedCard else { return }
|
||||||
|
card.layoutStyle = layout
|
||||||
|
updateCard(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultCard(_ card: BusinessCard) {
|
||||||
|
for existingCard in cards {
|
||||||
|
existingCard.isDefault = existingCard.id == card.id
|
||||||
|
}
|
||||||
|
selectedCardID = card.id
|
||||||
|
saveContext()
|
||||||
|
fetchCards()
|
||||||
|
syncToWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveContext() {
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
// Handle error silently for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncToWatch() {
|
||||||
|
WatchSyncService.syncCards(cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
BusinessCard/State/ContactsStore.swift
Normal file
83
BusinessCard/State/ContactsStore.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class ContactsStore: ContactTracking {
|
||||||
|
private let modelContext: ModelContext
|
||||||
|
private(set) var contacts: [Contact] = []
|
||||||
|
var searchQuery: String = ""
|
||||||
|
|
||||||
|
init(modelContext: ModelContext) {
|
||||||
|
self.modelContext = modelContext
|
||||||
|
fetchContacts()
|
||||||
|
|
||||||
|
if contacts.isEmpty {
|
||||||
|
Contact.createSamples(in: modelContext)
|
||||||
|
saveContext()
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchContacts() {
|
||||||
|
let descriptor = FetchDescriptor<Contact>(
|
||||||
|
sortBy: [SortDescriptor(\.lastSharedDate, order: .reverse)]
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
contacts = try modelContext.fetch(descriptor)
|
||||||
|
} catch {
|
||||||
|
contacts = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleContacts: [Contact] {
|
||||||
|
let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedQuery.isEmpty else { return contacts }
|
||||||
|
return contacts.filter { contact in
|
||||||
|
contact.name.localizedStandardContains(trimmedQuery)
|
||||||
|
|| contact.company.localizedStandardContains(trimmedQuery)
|
||||||
|
|| contact.role.localizedStandardContains(trimmedQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordShare(for name: String, role: String, company: String, cardLabel: String) {
|
||||||
|
// Check if contact already exists
|
||||||
|
if let existingContact = contacts.first(where: { $0.name == name && $0.company == company }) {
|
||||||
|
existingContact.lastSharedDate = .now
|
||||||
|
existingContact.cardLabel = cardLabel
|
||||||
|
} else {
|
||||||
|
let newContact = Contact(
|
||||||
|
name: name,
|
||||||
|
role: role,
|
||||||
|
company: company,
|
||||||
|
avatarSystemName: "person.crop.circle",
|
||||||
|
lastSharedDate: .now,
|
||||||
|
cardLabel: cardLabel
|
||||||
|
)
|
||||||
|
modelContext.insert(newContact)
|
||||||
|
}
|
||||||
|
saveContext()
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteContact(_ contact: Contact) {
|
||||||
|
modelContext.delete(contact)
|
||||||
|
saveContext()
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativeShareDate(for contact: Contact) -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter.localizedString(for: contact.lastSharedDate, relativeTo: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveContext() {
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
// Handle error silently for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
BusinessCard/Views/BusinessCardView.swift
Normal file
204
BusinessCard/Views/BusinessCardView.swift
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct BusinessCardView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
switch card.layoutStyle {
|
||||||
|
case .stacked:
|
||||||
|
StackedCardLayout(card: card)
|
||||||
|
case .split:
|
||||||
|
SplitCardLayout(card: card)
|
||||||
|
case .photo:
|
||||||
|
PhotoCardLayout(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [card.theme.primaryColor, card.theme.secondaryColor],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String.localized("Business card"))
|
||||||
|
.accessibilityValue("\(card.displayName), \(card.role), \(card.company)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StackedCardLayout: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
CardHeaderView(card: card)
|
||||||
|
Divider()
|
||||||
|
.overlay(Color.Text.inverted.opacity(Design.Opacity.medium))
|
||||||
|
CardDetailsView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SplitCardLayout: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
CardHeaderView(card: card)
|
||||||
|
CardDetailsView(card: card)
|
||||||
|
}
|
||||||
|
Spacer(minLength: Design.Spacing.medium)
|
||||||
|
CardAccentBlockView(color: card.theme.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PhotoCardLayout: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
CardHeaderView(card: card)
|
||||||
|
CardDetailsView(card: card)
|
||||||
|
}
|
||||||
|
Spacer(minLength: Design.Spacing.medium)
|
||||||
|
CardAvatarBadgeView(systemName: card.avatarSystemName, accentColor: card.theme.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardHeaderView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
CardAvatarBadgeView(systemName: card.avatarSystemName, accentColor: card.theme.accentColor)
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(card.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.inverted)
|
||||||
|
Text(card.role)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull))
|
||||||
|
Text(card.company)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
Spacer(minLength: Design.Spacing.small)
|
||||||
|
CardLabelBadgeView(label: card.label, accentColor: card.theme.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardDetailsView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
InfoRowView(systemImage: "envelope", text: card.email)
|
||||||
|
InfoRowView(systemImage: "phone", text: card.phone)
|
||||||
|
InfoRowView(systemImage: "link", text: card.website)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InfoRowView: View {
|
||||||
|
let systemImage: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.heavy))
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.inverted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardAccentBlockView: View {
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.foregroundStyle(Color.Text.inverted)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardAvatarBadgeView: View {
|
||||||
|
let systemName: String
|
||||||
|
let accentColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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 {
|
||||||
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
let card = BusinessCard(
|
||||||
|
displayName: "Daniel Sullivan",
|
||||||
|
role: "Property Developer",
|
||||||
|
company: "WR Construction",
|
||||||
|
email: "daniel@example.com",
|
||||||
|
phone: "+1 555 123 4567",
|
||||||
|
website: "example.com",
|
||||||
|
location: "Dallas, TX",
|
||||||
|
themeName: "Coral",
|
||||||
|
layoutStyleRawValue: "split"
|
||||||
|
)
|
||||||
|
context.insert(card)
|
||||||
|
|
||||||
|
return BusinessCardView(card: card)
|
||||||
|
.padding()
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
}
|
||||||
56
BusinessCard/Views/CardCarouselView.swift
Normal file
56
BusinessCard/Views/CardCarouselView.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct CardCarouselView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var cardStore = appState.cardStore
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
HStack {
|
||||||
|
Text("Create multiple business cards")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
TabView(selection: $cardStore.selectedCardID) {
|
||||||
|
ForEach(cardStore.cards) { card in
|
||||||
|
BusinessCardView(card: card)
|
||||||
|
.tag(Optional(card.id))
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.frame(height: Design.Size.cardHeight + Design.Spacing.xxLarge)
|
||||||
|
|
||||||
|
if let selected = cardStore.selectedCard {
|
||||||
|
CardDefaultToggleView(card: selected) {
|
||||||
|
cardStore.setDefaultCard(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardDefaultToggleView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(
|
||||||
|
card.isDefault ? String.localized("Default card") : String.localized("Set as default"),
|
||||||
|
systemImage: card.isDefault ? "checkmark.seal.fill" : "checkmark.seal",
|
||||||
|
action: action
|
||||||
|
)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color.Accent.red)
|
||||||
|
.accessibilityHint(String.localized("Sets this card as your default sharing card"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CardCarouselView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
297
BusinessCard/Views/CardEditorView.swift
Normal file
297
BusinessCard/Views/CardEditorView.swift
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct CardEditorView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let card: BusinessCard?
|
||||||
|
let onSave: (BusinessCard) -> Void
|
||||||
|
|
||||||
|
@State private var displayName: String = ""
|
||||||
|
@State private var role: String = ""
|
||||||
|
@State private var company: String = ""
|
||||||
|
@State private var label: String = "Work"
|
||||||
|
@State private var email: String = ""
|
||||||
|
@State private var phone: String = ""
|
||||||
|
@State private var website: String = ""
|
||||||
|
@State private var location: String = ""
|
||||||
|
@State private var avatarSystemName: String = "person.crop.circle"
|
||||||
|
@State private var selectedTheme: CardTheme = .coral
|
||||||
|
@State private var selectedLayout: CardLayoutStyle = .stacked
|
||||||
|
|
||||||
|
private var isEditing: Bool { card != nil }
|
||||||
|
|
||||||
|
private var isFormValid: Bool {
|
||||||
|
!displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
CardPreviewSection(
|
||||||
|
displayName: displayName.isEmpty ? String.localized("Your Name") : displayName,
|
||||||
|
role: role.isEmpty ? String.localized("Your Role") : role,
|
||||||
|
company: company.isEmpty ? String.localized("Company") : company,
|
||||||
|
label: label,
|
||||||
|
avatarSystemName: avatarSystemName,
|
||||||
|
theme: selectedTheme,
|
||||||
|
layoutStyle: selectedLayout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
|
||||||
|
Section(String.localized("Personal Information")) {
|
||||||
|
TextField(String.localized("Full Name"), text: $displayName)
|
||||||
|
.textContentType(.name)
|
||||||
|
.accessibilityLabel(String.localized("Full Name"))
|
||||||
|
|
||||||
|
TextField(String.localized("Role / Title"), text: $role)
|
||||||
|
.textContentType(.jobTitle)
|
||||||
|
.accessibilityLabel(String.localized("Role"))
|
||||||
|
|
||||||
|
TextField(String.localized("Company"), text: $company)
|
||||||
|
.textContentType(.organizationName)
|
||||||
|
.accessibilityLabel(String.localized("Company"))
|
||||||
|
|
||||||
|
TextField(String.localized("Card Label"), text: $label)
|
||||||
|
.accessibilityLabel(String.localized("Card Label"))
|
||||||
|
.accessibilityHint(String.localized("A short label like Work or Personal"))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(String.localized("Contact Details")) {
|
||||||
|
TextField(String.localized("Email"), text: $email)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel(String.localized("Email"))
|
||||||
|
|
||||||
|
TextField(String.localized("Phone"), text: $phone)
|
||||||
|
.textContentType(.telephoneNumber)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.accessibilityLabel(String.localized("Phone"))
|
||||||
|
|
||||||
|
TextField(String.localized("Website"), text: $website)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.accessibilityLabel(String.localized("Website"))
|
||||||
|
|
||||||
|
TextField(String.localized("Location"), text: $location)
|
||||||
|
.textContentType(.fullStreetAddress)
|
||||||
|
.accessibilityLabel(String.localized("Location"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String.localized("Cancel")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String.localized("Save")) {
|
||||||
|
saveCard()
|
||||||
|
}
|
||||||
|
.disabled(!isFormValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let card {
|
||||||
|
displayName = card.displayName
|
||||||
|
role = card.role
|
||||||
|
company = card.company
|
||||||
|
label = card.label
|
||||||
|
email = card.email
|
||||||
|
phone = card.phone
|
||||||
|
website = card.website
|
||||||
|
location = card.location
|
||||||
|
avatarSystemName = card.avatarSystemName
|
||||||
|
selectedTheme = card.theme
|
||||||
|
selectedLayout = card.layoutStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCard() {
|
||||||
|
if let existingCard = card {
|
||||||
|
existingCard.displayName = displayName
|
||||||
|
existingCard.role = role
|
||||||
|
existingCard.company = company
|
||||||
|
existingCard.label = label
|
||||||
|
existingCard.email = email
|
||||||
|
existingCard.phone = phone
|
||||||
|
existingCard.website = website
|
||||||
|
existingCard.location = location
|
||||||
|
existingCard.avatarSystemName = avatarSystemName
|
||||||
|
existingCard.theme = selectedTheme
|
||||||
|
existingCard.layoutStyle = selectedLayout
|
||||||
|
onSave(existingCard)
|
||||||
|
} else {
|
||||||
|
let newCard = BusinessCard(
|
||||||
|
displayName: displayName,
|
||||||
|
role: role,
|
||||||
|
company: company,
|
||||||
|
label: label,
|
||||||
|
email: email,
|
||||||
|
phone: phone,
|
||||||
|
website: website,
|
||||||
|
location: location,
|
||||||
|
isDefault: false,
|
||||||
|
themeName: selectedTheme.name,
|
||||||
|
layoutStyleRawValue: selectedLayout.rawValue,
|
||||||
|
avatarSystemName: avatarSystemName
|
||||||
|
)
|
||||||
|
onSave(newCard)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardPreviewSection: View {
|
||||||
|
let displayName: String
|
||||||
|
let role: String
|
||||||
|
let company: String
|
||||||
|
let label: String
|
||||||
|
let avatarSystemName: String
|
||||||
|
let theme: CardTheme
|
||||||
|
let layoutStyle: CardLayoutStyle
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
previewCard
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previewCard: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.Text.inverted)
|
||||||
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: avatarSystemName)
|
||||||
|
.foregroundStyle(theme.accentColor)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.inverted)
|
||||||
|
Text(role)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull))
|
||||||
|
Text(company)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Design.Spacing.small)
|
||||||
|
|
||||||
|
Text(String.localized(label))
|
||||||
|
.font(.caption)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.inverted)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
.background(theme.accentColor.opacity(Design.Opacity.medium))
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [theme.primaryColor, theme.secondaryColor],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AvatarPickerRow: View {
|
||||||
|
@Binding var selection: String
|
||||||
|
|
||||||
|
private let avatarOptions = [
|
||||||
|
"person.crop.circle",
|
||||||
|
"person.crop.circle.fill",
|
||||||
|
"person.crop.square",
|
||||||
|
"person.circle",
|
||||||
|
"sparkles",
|
||||||
|
"music.mic",
|
||||||
|
"briefcase.fill",
|
||||||
|
"building.2.fill",
|
||||||
|
"star.fill",
|
||||||
|
"bolt.fill"
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text("Icon")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: Design.Spacing.small) {
|
||||||
|
ForEach(avatarOptions, id: \.self) { icon in
|
||||||
|
Button {
|
||||||
|
selection = icon
|
||||||
|
} label: {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(selection == icon ? Color.Accent.red : Color.Text.secondary)
|
||||||
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
||||||
|
.background(selection == icon ? Color.AppBackground.accent : Color.AppBackground.base)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(icon)
|
||||||
|
.accessibilityAddTraits(selection == icon ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("New Card") {
|
||||||
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
return CardEditorView(card: nil) { _ in }
|
||||||
|
.environment(AppState(modelContext: container.mainContext))
|
||||||
|
}
|
||||||
80
BusinessCard/Views/CardsHomeView.swift
Normal file
80
BusinessCard/Views/CardsHomeView.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct CardsHomeView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@State private var showingCreateCard = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
HeroBannerView()
|
||||||
|
SectionTitleView(
|
||||||
|
title: String.localized("Create your digital business card"),
|
||||||
|
subtitle: String.localized("Design and share polished cards for every context.")
|
||||||
|
)
|
||||||
|
CardCarouselView()
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
PrimaryActionButton(
|
||||||
|
title: String.localized("Send my card"),
|
||||||
|
systemImage: "paperplane.fill"
|
||||||
|
) {
|
||||||
|
appState.selectedTab = .share
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(String.localized("New Card"), systemImage: "plus") {
|
||||||
|
showingCreateCard = true
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color.Accent.ink)
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityHint(String.localized("Create a new business card"))
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsCalloutView()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.AppBackground.base, Color.AppBackground.accent],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.navigationTitle(String.localized("My Cards"))
|
||||||
|
.sheet(isPresented: $showingCreateCard) {
|
||||||
|
CardEditorView(card: nil) { newCard in
|
||||||
|
appState.cardStore.addCard(newCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SectionTitleView: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title3)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
return CardsHomeView()
|
||||||
|
.environment(AppState(modelContext: container.mainContext))
|
||||||
|
}
|
||||||
115
BusinessCard/Views/ContactsView.swift
Normal file
115
BusinessCard/Views/ContactsView.swift
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ContactsView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var contactsStore = appState.contactsStore
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if contactsStore.contacts.isEmpty {
|
||||||
|
EmptyContactsView()
|
||||||
|
} else {
|
||||||
|
ContactsListView(contactsStore: contactsStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $contactsStore.searchQuery, prompt: String.localized("Search contacts"))
|
||||||
|
.navigationTitle(String.localized("Contacts"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EmptyContactsView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Image(systemName: "person.2.slash")
|
||||||
|
.font(.system(size: Design.BaseFontSize.display))
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
Text("No contacts yet")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
Text("When you share your card and track the recipient, they'll appear here.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactsListView: View {
|
||||||
|
@Bindable var contactsStore: ContactsStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(contactsStore.visibleContacts) { contact in
|
||||||
|
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
for index in indexSet {
|
||||||
|
let contact = contactsStore.visibleContacts[index]
|
||||||
|
contactsStore.deleteContact(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Track who receives your card")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactRowView: View {
|
||||||
|
let contact: Contact
|
||||||
|
let relativeDate: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: contact.avatarSystemName)
|
||||||
|
.font(.title2)
|
||||||
|
.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(contact.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text("\(contact.role) · \(contact.company)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(relativeDate)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
Text(String.localized(contact.cardLabel))
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(contact.name)
|
||||||
|
.accessibilityValue("\(contact.role), \(contact.company)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContactsView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
171
BusinessCard/Views/CustomizeCardView.swift
Normal file
171
BusinessCard/Views/CustomizeCardView.swift
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct CustomizeCardView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@State private var showingEditCard = false
|
||||||
|
@State private var showingDeleteConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Text("Customize your card")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
BusinessCardView(card: card)
|
||||||
|
|
||||||
|
CardActionsView(
|
||||||
|
onEdit: { showingEditCard = true },
|
||||||
|
onDelete: { showingDeleteConfirmation = true },
|
||||||
|
canDelete: appState.cardStore.cards.count > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
CardStylePickerView(selectedTheme: card.theme) { theme in
|
||||||
|
appState.cardStore.setSelectedTheme(theme)
|
||||||
|
}
|
||||||
|
CardLayoutPickerView(selectedLayout: card.layoutStyle) { layout in
|
||||||
|
appState.cardStore.setSelectedLayout(layout)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyStateView(
|
||||||
|
title: String.localized("No card selected"),
|
||||||
|
message: String.localized("Select a card to start customizing.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.navigationTitle(String.localized("Edit your card"))
|
||||||
|
.sheet(isPresented: $showingEditCard) {
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
CardEditorView(card: card) { updatedCard in
|
||||||
|
appState.cardStore.updateCard(updatedCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(String.localized("Delete Card"), isPresented: $showingDeleteConfirmation) {
|
||||||
|
Button(String.localized("Cancel"), role: .cancel) { }
|
||||||
|
Button(String.localized("Delete"), role: .destructive) {
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
appState.cardStore.deleteCard(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Are you sure you want to delete this card? This action cannot be undone.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardActionsView: View {
|
||||||
|
let onEdit: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
let canDelete: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Button(String.localized("Edit Details"), systemImage: "pencil", action: onEdit)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color.Accent.ink)
|
||||||
|
.accessibilityHint(String.localized("Edit card name, email, and other details"))
|
||||||
|
|
||||||
|
if canDelete {
|
||||||
|
Button(String.localized("Delete"), systemImage: "trash", role: .destructive, action: onDelete)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.accessibilityHint(String.localized("Permanently delete this card"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardStylePickerView: View {
|
||||||
|
let selectedTheme: CardTheme
|
||||||
|
let onSelect: (CardTheme) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text("Card style")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: Design.Spacing.small) {
|
||||||
|
ForEach(CardTheme.all) { theme in
|
||||||
|
Button(action: { onSelect(theme) }) {
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(theme.primaryColor)
|
||||||
|
.frame(height: Design.Size.avatarSize)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.stroke(
|
||||||
|
selectedTheme.id == theme.id ? Color.Accent.red : Color.Text.inverted.opacity(Design.Opacity.medium),
|
||||||
|
lineWidth: Design.LineWidth.medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(theme.localizedName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(String.localized("Card style"))
|
||||||
|
.accessibilityValue(theme.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gridColumns: [GridItem] {
|
||||||
|
Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.small), count: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CardLayoutPickerView: View {
|
||||||
|
let selectedLayout: CardLayoutStyle
|
||||||
|
let onSelect: (CardLayoutStyle) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text("Images & layout")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
Picker(String.localized("Layout"), selection: Binding(
|
||||||
|
get: { selectedLayout },
|
||||||
|
set: { onSelect($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(CardLayoutStyle.allCases) { layout in
|
||||||
|
Text(layout.displayName)
|
||||||
|
.tag(layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Text("Change image layout")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CustomizeCardView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
25
BusinessCard/Views/EmptyStateView.swift
Normal file
25
BusinessCard/Views/EmptyStateView.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
EmptyStateView(title: "No card selected", message: "Choose a card to continue.")
|
||||||
|
}
|
||||||
95
BusinessCard/Views/HeroBannerView.swift
Normal file
95
BusinessCard/Views/HeroBannerView.swift
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HeroBannerView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Text("The #1 Digital Business Card App")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
StatBadgeView(
|
||||||
|
title: String.localized("4.9"),
|
||||||
|
subtitle: String.localized("App Rating"),
|
||||||
|
systemImage: "star.fill",
|
||||||
|
badgeColor: Color.Badge.star
|
||||||
|
)
|
||||||
|
|
||||||
|
StatBadgeView(
|
||||||
|
title: String.localized("100k+"),
|
||||||
|
subtitle: String.localized("Reviews"),
|
||||||
|
systemImage: "person.3.fill",
|
||||||
|
badgeColor: Color.Badge.neutral
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Used by Industry Leaders")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
BrandChipView(label: "Google")
|
||||||
|
BrandChipView(label: "Tesla")
|
||||||
|
BrandChipView(label: "Citi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusMedium,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatBadgeView: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let systemImage: String
|
||||||
|
let badgeColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
HStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(badgeColor)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BrandChipView: View {
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(Color.AppBackground.accent)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
HeroBannerView()
|
||||||
|
}
|
||||||
19
BusinessCard/Views/PrimaryActionButton.swift
Normal file
19
BusinessCard/Views/PrimaryActionButton.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PrimaryActionButton: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(title, systemImage: systemImage, action: action)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Color.Accent.red)
|
||||||
|
.controlSize(.large)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PrimaryActionButton(title: "Send my card", systemImage: "paperplane.fill") { }
|
||||||
|
}
|
||||||
34
BusinessCard/Views/QRCodeView.swift
Normal file
34
BusinessCard/Views/QRCodeView.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct QRCodeView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
let payload: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let image = appState.qrCodeService.qrCode(from: payload) {
|
||||||
|
Image(decorative: image, scale: 1)
|
||||||
|
.resizable()
|
||||||
|
.interpolation(.none)
|
||||||
|
.scaledToFit()
|
||||||
|
.accessibilityLabel(String.localized("QR code"))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let container = try! ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
BusinessCard.createSamples(in: context)
|
||||||
|
let cards = try! context.fetch(FetchDescriptor<BusinessCard>())
|
||||||
|
|
||||||
|
return QRCodeView(payload: cards.first?.vCardPayload ?? "")
|
||||||
|
.environment(AppState(modelContext: context))
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
36
BusinessCard/Views/RootTabView.swift
Normal file
36
BusinessCard/Views/RootTabView.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct RootTabView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var appState = appState
|
||||||
|
TabView(selection: $appState.selectedTab) {
|
||||||
|
Tab(String.localized("My Cards"), systemImage: "rectangle.stack", value: AppTab.cards) {
|
||||||
|
CardsHomeView()
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(String.localized("Share"), systemImage: "qrcode", value: AppTab.share) {
|
||||||
|
ShareCardView()
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(String.localized("Customize"), systemImage: "slider.horizontal.3", value: AppTab.customize) {
|
||||||
|
CustomizeCardView()
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(String.localized("Contacts"), systemImage: "person.2", value: AppTab.contacts) {
|
||||||
|
ContactsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab(String.localized("Widgets"), systemImage: "square.grid.2x2", value: AppTab.widgets) {
|
||||||
|
WidgetsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RootTabView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
341
BusinessCard/Views/ShareCardView.swift
Normal file
341
BusinessCard/Views/ShareCardView.swift
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ShareCardView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
@State private var showingWalletAlert = false
|
||||||
|
@State private var showingNfcAlert = false
|
||||||
|
@State private var showingContactSheet = false
|
||||||
|
@State private var recipientName = ""
|
||||||
|
@State private var recipientRole = ""
|
||||||
|
@State private var recipientCompany = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Text("Share with anyone")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
QRCodeCardView(card: card)
|
||||||
|
|
||||||
|
ShareOptionsView(
|
||||||
|
card: card,
|
||||||
|
shareLinkService: appState.shareLinkService,
|
||||||
|
showWallet: { showingWalletAlert = true },
|
||||||
|
showNfc: { showingNfcAlert = true },
|
||||||
|
onShareAction: { showingContactSheet = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
TrackContactButton {
|
||||||
|
showingContactSheet = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyStateView(
|
||||||
|
title: String.localized("No card selected"),
|
||||||
|
message: String.localized("Choose a card in the My Cards tab to start sharing.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.navigationTitle(String.localized("Send Work Card"))
|
||||||
|
.alert(String.localized("Apple Wallet"), isPresented: $showingWalletAlert) {
|
||||||
|
Button(String.localized("OK")) { }
|
||||||
|
} message: {
|
||||||
|
Text("Wallet export is coming soon. We'll let you know as soon as it's ready.")
|
||||||
|
}
|
||||||
|
.alert(String.localized("NFC Sharing"), isPresented: $showingNfcAlert) {
|
||||||
|
Button(String.localized("OK")) { }
|
||||||
|
} message: {
|
||||||
|
Text("Hold your phone near another device to share instantly. NFC setup is on the way.")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingContactSheet) {
|
||||||
|
RecordContactSheet(
|
||||||
|
recipientName: $recipientName,
|
||||||
|
recipientRole: $recipientRole,
|
||||||
|
recipientCompany: $recipientCompany
|
||||||
|
) {
|
||||||
|
if !recipientName.isEmpty, let card = appState.cardStore.selectedCard {
|
||||||
|
appState.contactsStore.recordShare(
|
||||||
|
for: recipientName,
|
||||||
|
role: recipientRole,
|
||||||
|
company: recipientCompany,
|
||||||
|
cardLabel: card.label
|
||||||
|
)
|
||||||
|
recipientName = ""
|
||||||
|
recipientRole = ""
|
||||||
|
recipientCompany = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrackContactButton: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "person.badge.plus")
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
||||||
|
.background(Color.AppBackground.accent)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text("Track this share")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text("Record who received your card")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(String.localized("Opens a form to record who you shared your card with"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecordContactSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var recipientName: String
|
||||||
|
@Binding var recipientRole: String
|
||||||
|
@Binding var recipientCompany: String
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
!recipientName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(String.localized("Recipient Details")) {
|
||||||
|
TextField(String.localized("Name"), text: $recipientName)
|
||||||
|
.textContentType(.name)
|
||||||
|
|
||||||
|
TextField(String.localized("Role (optional)"), text: $recipientRole)
|
||||||
|
.textContentType(.jobTitle)
|
||||||
|
|
||||||
|
TextField(String.localized("Company (optional)"), text: $recipientCompany)
|
||||||
|
.textContentType(.organizationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text("This person will appear in your Contacts tab so you can track who has your card.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(String.localized("Track Share"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String.localized("Cancel")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String.localized("Save")) {
|
||||||
|
onSave()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(!isValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct QRCodeCardView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
QRCodeView(payload: card.vCardPayload)
|
||||||
|
.frame(width: Design.Size.qrSize, height: Design.Size.qrSize)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
|
||||||
|
Text("Point your camera at the QR code to receive the card")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(card.theme.primaryColor)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShareOptionsView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
let shareLinkService: ShareLinkProviding
|
||||||
|
let showWallet: () -> Void
|
||||||
|
let showNfc: () -> Void
|
||||||
|
let onShareAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
ShareOptionShareRow(
|
||||||
|
title: String.localized("Copy link"),
|
||||||
|
systemImage: "link",
|
||||||
|
item: shareLinkService.shareURL(for: card)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionLinkRow(
|
||||||
|
title: String.localized("Text your card"),
|
||||||
|
systemImage: "message",
|
||||||
|
url: shareLinkService.smsURL(for: card)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionLinkRow(
|
||||||
|
title: String.localized("Email your card"),
|
||||||
|
systemImage: "envelope",
|
||||||
|
url: shareLinkService.emailURL(for: card)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionLinkRow(
|
||||||
|
title: String.localized("Send via WhatsApp"),
|
||||||
|
systemImage: "message.fill",
|
||||||
|
url: shareLinkService.whatsappURL(for: card)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionLinkRow(
|
||||||
|
title: String.localized("Send via LinkedIn"),
|
||||||
|
systemImage: "link.circle",
|
||||||
|
url: shareLinkService.linkedInURL(for: card)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionActionRow(
|
||||||
|
title: String.localized("Add to Apple Wallet"),
|
||||||
|
systemImage: "wallet.pass",
|
||||||
|
action: showWallet
|
||||||
|
)
|
||||||
|
|
||||||
|
ShareOptionActionRow(
|
||||||
|
title: String.localized("Share via NFC"),
|
||||||
|
systemImage: "dot.radiowaves.left.and.right",
|
||||||
|
action: showNfc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShareOptionLinkRow: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Link(destination: url) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
ShareRowIcon(systemImage: systemImage)
|
||||||
|
Text(title)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShareOptionShareRow: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let item: URL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ShareLink(item: item) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
ShareRowIcon(systemImage: systemImage)
|
||||||
|
Text(title)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShareOptionActionRow: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
ShareRowIcon(systemImage: systemImage)
|
||||||
|
Text(title)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ShareRowIcon: View {
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
.frame(width: Design.Size.avatarSize, height: Design.Size.avatarSize)
|
||||||
|
.background(Color.AppBackground.accent)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ShareCardView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
38
BusinessCard/Views/WidgetsCalloutView.swift
Normal file
38
BusinessCard/Views/WidgetsCalloutView.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WidgetsCalloutView: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text("Share using widgets on your phone or watch")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text("Add a QR widget so your card is always one tap away.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "applewatch")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(Color.Accent.red)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusMedium,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WidgetsCalloutView()
|
||||||
|
.padding()
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
}
|
||||||
106
BusinessCard/Views/WidgetsView.swift
Normal file
106
BusinessCard/Views/WidgetsView.swift
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct WidgetsView: View {
|
||||||
|
@Environment(AppState.self) private var appState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Text("Share using widgets on your phone or watch")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if let card = appState.cardStore.selectedCard {
|
||||||
|
WidgetPreviewCardView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.base)
|
||||||
|
.navigationTitle(String.localized("Widgets"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WidgetPreviewCardView: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
PhoneWidgetPreview(card: card)
|
||||||
|
WatchWidgetPreview(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PhoneWidgetPreview: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text("Phone Widget")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
QRCodeView(payload: card.vCardPayload)
|
||||||
|
.frame(width: Design.Size.widgetPhoneHeight, height: Design.Size.widgetPhoneHeight)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(card.displayName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(card.role)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
Text("Tap to share")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchWidgetPreview: View {
|
||||||
|
let card: BusinessCard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text("Watch Widget")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
QRCodeView(payload: card.vCardPayload)
|
||||||
|
.frame(width: Design.Size.widgetWatchSize, height: Design.Size.widgetWatchSize)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
|
Text("Ready to scan")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
Text("Open on Apple Watch")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WidgetsView()
|
||||||
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
}
|
||||||
@ -1,17 +1,188 @@
|
|||||||
//
|
|
||||||
// BusinessCardTests.swift
|
|
||||||
// BusinessCardTests
|
|
||||||
//
|
|
||||||
// Created by Matt Bruce on 1/8/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import SwiftData
|
||||||
@testable import BusinessCard
|
@testable import BusinessCard
|
||||||
|
|
||||||
struct BusinessCardTests {
|
struct BusinessCardTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func vCardPayloadIncludesFields() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let card = BusinessCard(
|
||||||
|
displayName: "Test User",
|
||||||
|
role: "Developer",
|
||||||
|
company: "Test Corp",
|
||||||
|
email: "test@example.com",
|
||||||
|
phone: "+1 555 123 4567",
|
||||||
|
website: "example.com",
|
||||||
|
location: "San Francisco, CA"
|
||||||
|
)
|
||||||
|
context.insert(card)
|
||||||
|
|
||||||
|
#expect(card.vCardPayload.contains("BEGIN:VCARD"))
|
||||||
|
#expect(card.vCardPayload.contains("FN:\(card.displayName)"))
|
||||||
|
#expect(card.vCardPayload.contains("ORG:\(card.company)"))
|
||||||
|
#expect(card.vCardPayload.contains("EMAIL;TYPE=work:\(card.email)"))
|
||||||
|
#expect(card.vCardPayload.contains("TEL;TYPE=work:\(card.phone)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func defaultCardSelectionUpdatesCards() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
BusinessCard.createSamples(in: context)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let store = CardStore(modelContext: context)
|
||||||
|
let newDefault = store.cards[1]
|
||||||
|
|
||||||
|
store.setDefaultCard(newDefault)
|
||||||
|
|
||||||
|
#expect(store.selectedCardID == newDefault.id)
|
||||||
|
#expect(store.cards.filter { $0.isDefault }.count == 1)
|
||||||
|
#expect(store.cards.first { $0.isDefault }?.id == newDefault.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func contactsSearchFiltersByNameOrCompany() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let contact1 = Contact(name: "John Doe", role: "Developer", company: "Global Bank")
|
||||||
|
let contact2 = Contact(name: "Jane Smith", role: "Designer", company: "Tech Corp")
|
||||||
|
context.insert(contact1)
|
||||||
|
context.insert(contact2)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let store = ContactsStore(modelContext: context)
|
||||||
|
store.searchQuery = "Global"
|
||||||
|
|
||||||
|
#expect(store.visibleContacts.count == 1)
|
||||||
|
#expect(store.visibleContacts.first?.company == "Global Bank")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func addCardIncreasesCardCount() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let store = CardStore(modelContext: context)
|
||||||
|
let initialCount = store.cards.count
|
||||||
|
|
||||||
|
let newCard = BusinessCard(
|
||||||
|
displayName: "New User",
|
||||||
|
role: "Manager",
|
||||||
|
company: "New Corp"
|
||||||
|
)
|
||||||
|
store.addCard(newCard)
|
||||||
|
|
||||||
|
#expect(store.cards.count == initialCount + 1)
|
||||||
|
#expect(store.selectedCardID == newCard.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func deleteCardRemovesFromStore() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
BusinessCard.createSamples(in: context)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let store = CardStore(modelContext: context)
|
||||||
|
let initialCount = store.cards.count
|
||||||
|
let cardToDelete = store.cards.last!
|
||||||
|
|
||||||
|
store.deleteCard(cardToDelete)
|
||||||
|
|
||||||
|
#expect(store.cards.count == initialCount - 1)
|
||||||
|
#expect(!store.cards.contains(where: { $0.id == cardToDelete.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func updateCardChangesProperties() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let card = BusinessCard(
|
||||||
|
displayName: "Original Name",
|
||||||
|
role: "Original Role",
|
||||||
|
company: "Original Company"
|
||||||
|
)
|
||||||
|
context.insert(card)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let store = CardStore(modelContext: context)
|
||||||
|
|
||||||
|
card.displayName = "Updated Name"
|
||||||
|
card.role = "Updated Role"
|
||||||
|
store.updateCard(card)
|
||||||
|
|
||||||
|
let updatedCard = store.cards.first(where: { $0.id == card.id })
|
||||||
|
#expect(updatedCard?.displayName == "Updated Name")
|
||||||
|
#expect(updatedCard?.role == "Updated Role")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func recordShareCreatesContact() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let store = ContactsStore(modelContext: context)
|
||||||
|
let initialCount = store.contacts.count
|
||||||
|
|
||||||
|
store.recordShare(
|
||||||
|
for: "New Contact",
|
||||||
|
role: "CEO",
|
||||||
|
company: "Partner Inc",
|
||||||
|
cardLabel: "Work"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.contacts.count == initialCount + 1)
|
||||||
|
#expect(store.contacts.first?.name == "New Contact")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func recordShareUpdatesExistingContact() async throws {
|
||||||
|
let container = try ModelContainer(for: BusinessCard.self, Contact.self)
|
||||||
|
let context = container.mainContext
|
||||||
|
|
||||||
|
let existingContact = Contact(
|
||||||
|
name: "Existing Contact",
|
||||||
|
role: "Manager",
|
||||||
|
company: "Partner Inc",
|
||||||
|
cardLabel: "Personal"
|
||||||
|
)
|
||||||
|
context.insert(existingContact)
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
let store = ContactsStore(modelContext: context)
|
||||||
|
let initialCount = store.contacts.count
|
||||||
|
|
||||||
|
store.recordShare(
|
||||||
|
for: "Existing Contact",
|
||||||
|
role: "Manager",
|
||||||
|
company: "Partner Inc",
|
||||||
|
cardLabel: "Work"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.contacts.count == initialCount)
|
||||||
|
let updated = store.contacts.first(where: { $0.name == "Existing Contact" })
|
||||||
|
#expect(updated?.cardLabel == "Work")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeAssignmentWorks() async throws {
|
||||||
|
let card = BusinessCard()
|
||||||
|
|
||||||
|
card.theme = .midnight
|
||||||
|
#expect(card.themeName == "Midnight")
|
||||||
|
#expect(card.theme.name == "Midnight")
|
||||||
|
|
||||||
|
card.theme = .ocean
|
||||||
|
#expect(card.themeName == "Ocean")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func layoutStyleAssignmentWorks() async throws {
|
||||||
|
let card = BusinessCard()
|
||||||
|
|
||||||
|
card.layoutStyle = .split
|
||||||
|
#expect(card.layoutStyleRawValue == "split")
|
||||||
|
#expect(card.layoutStyle == .split)
|
||||||
|
|
||||||
|
card.layoutStyle = .photo
|
||||||
|
#expect(card.layoutStyleRawValue == "photo")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "watch",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
BusinessCardWatch/Assets.xcassets/Contents.json
Normal file
6
BusinessCardWatch/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BusinessCardWatch/BusinessCardWatch.entitlements
Normal file
10
BusinessCardWatch/BusinessCardWatch.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.mbrucedogs.BusinessCard</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
42
BusinessCardWatch/BusinessCardWatchApp.swift
Normal file
42
BusinessCardWatch/BusinessCardWatchApp.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct BusinessCardWatchApp: App {
|
||||||
|
private let modelContainer: ModelContainer
|
||||||
|
@State private var cardStore: WatchCardStore
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let schema = Schema([WatchCard.self])
|
||||||
|
|
||||||
|
let appGroupURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: "group.com.mbrucedogs.BusinessCard"
|
||||||
|
)
|
||||||
|
|
||||||
|
let storeURL = appGroupURL?.appending(path: "BusinessCard.store")
|
||||||
|
?? URL.applicationSupportDirectory.appending(path: "BusinessCard.store")
|
||||||
|
|
||||||
|
let configuration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
url: storeURL,
|
||||||
|
cloudKitDatabase: .automatic
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let container = try ModelContainer(for: schema, configurations: [configuration])
|
||||||
|
self.modelContainer = container
|
||||||
|
let context = container.mainContext
|
||||||
|
self._cardStore = State(initialValue: WatchCardStore(modelContext: context))
|
||||||
|
} catch {
|
||||||
|
fatalError("Failed to create ModelContainer: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
WatchContentView()
|
||||||
|
.environment(cardStore)
|
||||||
|
}
|
||||||
|
.modelContainer(modelContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
BusinessCardWatch/Design/WatchDesignConstants.swift
Normal file
34
BusinessCardWatch/Design/WatchDesignConstants.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum WatchDesign {
|
||||||
|
enum Spacing {
|
||||||
|
static let small: CGFloat = 6
|
||||||
|
static let medium: CGFloat = 10
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CornerRadius {
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 18
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Size {
|
||||||
|
static let qrSize: CGFloat = 120
|
||||||
|
static let chipHeight: CGFloat = 28
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Opacity {
|
||||||
|
static let hint: Double = 0.2
|
||||||
|
static let strong: Double = 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
enum WatchPalette {
|
||||||
|
static let background = Color(red: 0.12, green: 0.12, blue: 0.14)
|
||||||
|
static let card = Color(red: 0.2, green: 0.2, blue: 0.24)
|
||||||
|
static let accent = Color(red: 0.95, green: 0.35, blue: 0.33)
|
||||||
|
static let text = Color(red: 0.95, green: 0.95, blue: 0.97)
|
||||||
|
static let muted = Color(red: 0.7, green: 0.7, blue: 0.74)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
BusinessCardWatch/Models/WatchCard.swift
Normal file
68
BusinessCardWatch/Models/WatchCard.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A simplified card structure synced from the iOS app via App Group UserDefaults
|
||||||
|
struct WatchCard: Codable, Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
var displayName: String
|
||||||
|
var role: String
|
||||||
|
var company: String
|
||||||
|
var email: String
|
||||||
|
var phone: String
|
||||||
|
var website: String
|
||||||
|
var location: String
|
||||||
|
var isDefault: Bool
|
||||||
|
|
||||||
|
var vCardPayload: String {
|
||||||
|
let lines = [
|
||||||
|
"BEGIN:VCARD",
|
||||||
|
"VERSION:3.0",
|
||||||
|
"FN:\(displayName)",
|
||||||
|
"ORG:\(company)",
|
||||||
|
"TITLE:\(role)",
|
||||||
|
"TEL;TYPE=work:\(phone)",
|
||||||
|
"EMAIL;TYPE=work:\(email)",
|
||||||
|
"URL:\(website)",
|
||||||
|
"ADR;TYPE=work:;;\(location)",
|
||||||
|
"END:VCARD"
|
||||||
|
]
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchCard {
|
||||||
|
static let samples: [WatchCard] = [
|
||||||
|
WatchCard(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "Daniel Sullivan",
|
||||||
|
role: "Property Developer",
|
||||||
|
company: "WR Construction",
|
||||||
|
email: "daniel@wrconstruction.co",
|
||||||
|
phone: "+1 (214) 987-7810",
|
||||||
|
website: "wrconstruction.co",
|
||||||
|
location: "Dallas, TX",
|
||||||
|
isDefault: true
|
||||||
|
),
|
||||||
|
WatchCard(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "Maya Chen",
|
||||||
|
role: "Creative Lead",
|
||||||
|
company: "Signal Studio",
|
||||||
|
email: "maya@signal.studio",
|
||||||
|
phone: "+1 (312) 404-2211",
|
||||||
|
website: "signal.studio",
|
||||||
|
location: "Chicago, IL",
|
||||||
|
isDefault: false
|
||||||
|
),
|
||||||
|
WatchCard(
|
||||||
|
id: UUID(),
|
||||||
|
displayName: "DJ Michaels",
|
||||||
|
role: "DJ",
|
||||||
|
company: "Live Sessions",
|
||||||
|
email: "dj@livesessions.fm",
|
||||||
|
phone: "+1 (646) 222-3300",
|
||||||
|
website: "livesessions.fm",
|
||||||
|
location: "New York, NY",
|
||||||
|
isDefault: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
33
BusinessCardWatch/Resources/Localizable.xcstrings
Normal file
33
BusinessCardWatch/Resources/Localizable.xcstrings
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"Choose default" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Choose default" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Elegir predeterminada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Choisir par défaut" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Default Card" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Default Card" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Tarjeta predeterminada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Carte par défaut" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Not selected" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Not selected" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "No seleccionada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Non sélectionnée" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Selected" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Selected" } },
|
||||||
|
"es-MX" : { "stringUnit" : { "state" : "translated", "value" : "Seleccionada" } },
|
||||||
|
"fr-CA" : { "stringUnit" : { "state" : "translated", "value" : "Sélectionnée" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
BusinessCardWatch/Services/WatchQRCodeService.swift
Normal file
17
BusinessCardWatch/Services/WatchQRCodeService.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import CoreImage
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
struct WatchQRCodeService {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
BusinessCardWatch/State/WatchCardStore.swift
Normal file
59
BusinessCardWatch/State/WatchCardStore.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class WatchCardStore {
|
||||||
|
private static let appGroupID = "group.com.mbrucedogs.BusinessCard"
|
||||||
|
private static let cardsKey = "SyncedCards"
|
||||||
|
private static let defaultCardIDKey = "WatchDefaultCardID"
|
||||||
|
|
||||||
|
private(set) var cards: [WatchCard] = []
|
||||||
|
var defaultCardID: UUID? {
|
||||||
|
didSet {
|
||||||
|
persistDefaultID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sharedDefaults: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: Self.appGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
loadCards()
|
||||||
|
loadDefaultID()
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultCard: WatchCard? {
|
||||||
|
guard let defaultCardID else { return cards.first(where: { $0.isDefault }) ?? cards.first }
|
||||||
|
return cards.first(where: { $0.id == defaultCardID }) ?? cards.first(where: { $0.isDefault }) ?? cards.first
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCards() {
|
||||||
|
guard let defaults = sharedDefaults,
|
||||||
|
let data = defaults.data(forKey: Self.cardsKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([WatchCard].self, from: data) else {
|
||||||
|
// Fall back to sample data if no synced data available
|
||||||
|
cards = WatchCard.samples
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cards = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefault(_ card: WatchCard) {
|
||||||
|
defaultCardID = card.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistDefaultID() {
|
||||||
|
UserDefaults.standard.set(defaultCardID?.uuidString ?? "", forKey: Self.defaultCardIDKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDefaultID() {
|
||||||
|
let storedValue = UserDefaults.standard.string(forKey: Self.defaultCardIDKey) ?? ""
|
||||||
|
if let id = UUID(uuidString: storedValue), cards.contains(where: { $0.id == id }) {
|
||||||
|
defaultCardID = id
|
||||||
|
} else {
|
||||||
|
defaultCardID = cards.first(where: { $0.isDefault })?.id ?? cards.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
BusinessCardWatch/Views/WatchContentView.swift
Normal file
127
BusinessCardWatch/Views/WatchContentView.swift
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WatchContentView: View {
|
||||||
|
@Environment(WatchCardStore.self) private var cardStore
|
||||||
|
private let qrService = WatchQRCodeService()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: WatchDesign.Spacing.large) {
|
||||||
|
if let card = cardStore.defaultCard {
|
||||||
|
WatchQRCodeCardView(card: card, qrService: qrService)
|
||||||
|
} else if cardStore.cards.isEmpty {
|
||||||
|
WatchEmptyStateView()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cardStore.cards.isEmpty {
|
||||||
|
WatchCardPickerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(WatchDesign.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color.WatchPalette.background)
|
||||||
|
.onAppear {
|
||||||
|
cardStore.loadCards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchEmptyStateView: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: WatchDesign.Spacing.medium) {
|
||||||
|
Image(systemName: "rectangle.stack")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(Color.WatchPalette.muted)
|
||||||
|
|
||||||
|
Text("No Cards")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.WatchPalette.text)
|
||||||
|
|
||||||
|
Text("Open the iPhone app to create cards")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.WatchPalette.muted)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(WatchDesign.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchQRCodeCardView: View {
|
||||||
|
let card: WatchCard
|
||||||
|
let qrService: WatchQRCodeService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: WatchDesign.Spacing.small) {
|
||||||
|
Text("Default Card")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.WatchPalette.text)
|
||||||
|
|
||||||
|
if let image = qrService.qrCode(from: card.vCardPayload) {
|
||||||
|
Image(decorative: image, scale: 1)
|
||||||
|
.resizable()
|
||||||
|
.interpolation(.none)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: WatchDesign.Size.qrSize, height: WatchDesign.Size.qrSize)
|
||||||
|
.padding(WatchDesign.Spacing.small)
|
||||||
|
.background(Color.WatchPalette.card)
|
||||||
|
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(card.displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.WatchPalette.text)
|
||||||
|
Text(card.role)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.WatchPalette.muted)
|
||||||
|
}
|
||||||
|
.padding(WatchDesign.Spacing.medium)
|
||||||
|
.background(Color.WatchPalette.card)
|
||||||
|
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "Default card QR code"))
|
||||||
|
.accessibilityValue("\(card.displayName), \(card.role)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchCardPickerView: View {
|
||||||
|
@Environment(WatchCardStore.self) private var cardStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: WatchDesign.Spacing.small) {
|
||||||
|
Text("Choose default")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.WatchPalette.text)
|
||||||
|
|
||||||
|
ForEach(cardStore.cards) { card in
|
||||||
|
Button {
|
||||||
|
cardStore.setDefault(card)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(card.displayName)
|
||||||
|
.foregroundStyle(Color.WatchPalette.text)
|
||||||
|
Spacer()
|
||||||
|
if card.id == cardStore.defaultCardID {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.WatchPalette.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, WatchDesign.Spacing.small)
|
||||||
|
.padding(.horizontal, WatchDesign.Spacing.medium)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(card.id == cardStore.defaultCardID ? Color.WatchPalette.accent.opacity(WatchDesign.Opacity.strong) : Color.WatchPalette.card)
|
||||||
|
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.medium))
|
||||||
|
.accessibilityValue(card.id == cardStore.defaultCardID ? String(localized: "Selected") : String(localized: "Not selected"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(WatchDesign.Spacing.medium)
|
||||||
|
.background(Color.WatchPalette.card.opacity(WatchDesign.Opacity.hint))
|
||||||
|
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WatchContentView()
|
||||||
|
.environment(WatchCardStore())
|
||||||
|
}
|
||||||
104
README.md
Normal file
104
README.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# BusinessCard
|
||||||
|
|
||||||
|
A SwiftUI iOS + watchOS app that creates and shares digital business cards with QR codes, quick share actions, customization, and contact tracking. Data syncs across devices via iCloud.
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
- iOS 26+
|
||||||
|
- watchOS 12+
|
||||||
|
- Swift 6.2
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### My Cards
|
||||||
|
- Create and browse multiple cards in a carousel
|
||||||
|
- Create new cards with the "New Card" button
|
||||||
|
- Set a default card for sharing
|
||||||
|
- Preview bold card styles inspired by modern design
|
||||||
|
|
||||||
|
### Share
|
||||||
|
- QR code display for vCard payloads
|
||||||
|
- Share options: copy link, SMS, email, WhatsApp, LinkedIn
|
||||||
|
- **Track shares**: Record who received your card and when
|
||||||
|
- Placeholder actions for Apple Wallet and NFC (alerts included)
|
||||||
|
|
||||||
|
### Customize
|
||||||
|
- Theme picker with multiple color palettes (Coral, Midnight, Ocean, Lime, Violet)
|
||||||
|
- Layout picker for stacked, split, or photo style
|
||||||
|
- **Edit all card details**: Name, role, company, email, phone, website, location
|
||||||
|
- **Delete cards** you no longer need
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
- Track who you've shared your card with
|
||||||
|
- Search contacts using localized matching
|
||||||
|
- Shows last shared time and the card label used
|
||||||
|
- Swipe to delete contacts
|
||||||
|
|
||||||
|
### Widgets (Preview Only)
|
||||||
|
- Phone widget preview mock
|
||||||
|
- Watch widget preview mock
|
||||||
|
|
||||||
|
### watchOS App
|
||||||
|
- Shows the default card QR code
|
||||||
|
- Pick which card is the default on watch
|
||||||
|
- **Syncs with iPhone** via App Groups
|
||||||
|
|
||||||
|
## Data Sync
|
||||||
|
|
||||||
|
### iCloud Sync (iOS)
|
||||||
|
Cards and contacts are stored using SwiftData with CloudKit sync enabled. Your data automatically syncs across all your iPhones and iPads signed into the same iCloud account.
|
||||||
|
|
||||||
|
### iPhone to Watch Sync
|
||||||
|
The iPhone app syncs card data to the paired Apple Watch via App Groups. When you create, edit, or delete cards on your iPhone, the changes appear on your watch.
|
||||||
|
|
||||||
|
**Note**: The watch reads data from the iPhone. To update cards on the watch, make changes on the iPhone first.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- SwiftUI views are presentation only
|
||||||
|
- Shared app state uses `@Observable` classes on `@MainActor`
|
||||||
|
- SwiftData for persistence with CloudKit sync
|
||||||
|
- Protocol-oriented design for card data, sharing, contact tracking, and QR generation
|
||||||
|
- String Catalogs (`.xcstrings`) for localization (en, es-MX, fr-CA)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- `BusinessCard/Models` — SwiftData card/contact models
|
||||||
|
- `BusinessCard/State` — observable app state (CardStore, ContactsStore)
|
||||||
|
- `BusinessCard/Services` — QR generation, share URLs, watch sync
|
||||||
|
- `BusinessCard/Views` — SwiftUI screens and components
|
||||||
|
- `BusinessCard/Design` — design constants and semantic colors
|
||||||
|
- `BusinessCard/Protocols` — protocol definitions
|
||||||
|
- `BusinessCardWatch/` — watchOS app target and assets
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Capabilities
|
||||||
|
|
||||||
|
**iOS Target:**
|
||||||
|
- iCloud (CloudKit enabled)
|
||||||
|
- App Groups (`group.com.mbrucedogs.BusinessCard`)
|
||||||
|
- Background Modes (Remote notifications)
|
||||||
|
|
||||||
|
**watchOS Target:**
|
||||||
|
- App Groups (`group.com.mbrucedogs.BusinessCard`)
|
||||||
|
|
||||||
|
### CloudKit Container
|
||||||
|
`iCloud.com.mbrucedogs.BusinessCard`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Share URLs are sample placeholders
|
||||||
|
- Wallet/NFC flows are stubs with alerts only
|
||||||
|
- Widget UI is a visual preview (not a WidgetKit extension)
|
||||||
|
- First launch creates sample cards for demonstration
|
||||||
|
|
||||||
|
## Running
|
||||||
|
Open `BusinessCard.xcodeproj` in Xcode and build the iOS and watch targets.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
Unit tests cover:
|
||||||
|
- vCard payload formatting
|
||||||
|
- Default card selection
|
||||||
|
- Contact search filtering
|
||||||
|
- Create, update, delete cards
|
||||||
|
- Contact tracking (new and existing contacts)
|
||||||
|
- Theme and layout assignment
|
||||||
|
|
||||||
|
Run tests with `Cmd+U` in Xcode.
|
||||||
BIN
_design/screenshots/image-1.png
Normal file
BIN
_design/screenshots/image-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
_design/screenshots/image-2.png
Normal file
BIN
_design/screenshots/image-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
_design/screenshots/image-3.png
Normal file
BIN
_design/screenshots/image-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
82
ai_implmentation.md
Normal file
82
ai_implmentation.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# AI Implementation Context
|
||||||
|
|
||||||
|
This file summarizes project-specific context, architecture, and conventions to speed up future AI work.
|
||||||
|
|
||||||
|
## Project Summary
|
||||||
|
BusinessCard is a SwiftUI app for building and sharing digital business cards with QR codes. It includes iOS screens for cards, sharing, customization, contact tracking, and widget previews, plus a watchOS companion to show a default card QR code.
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
- iOS 26+, watchOS 12+, Swift 6.2.
|
||||||
|
- SwiftUI with `@Observable` classes and `@MainActor`.
|
||||||
|
- Protocol‑oriented architecture is prioritized.
|
||||||
|
- No UIKit unless explicitly requested.
|
||||||
|
- String Catalogs only (`.xcstrings`).
|
||||||
|
- No magic numbers in views; use design constants.
|
||||||
|
|
||||||
|
## Core Data Flow
|
||||||
|
- `AppState` owns:
|
||||||
|
- `CardStore` (cards and selection)
|
||||||
|
- `ContactsStore` (contact list + search)
|
||||||
|
- `ShareLinkService` (share URLs)
|
||||||
|
- `QRCodeService` (QR generation)
|
||||||
|
- Views read state via environment and render UI only.
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- `BusinessCard/Models/BusinessCard.swift` — business card data + vCard payload
|
||||||
|
- `BusinessCard/Models/Contact.swift` — contact tracking model
|
||||||
|
- `BusinessCard/Models/CardTheme.swift` — card theme palette
|
||||||
|
- `BusinessCard/Models/CardLayoutStyle.swift` — stacked/split/photo
|
||||||
|
|
||||||
|
### Protocols (POP)
|
||||||
|
- `BusinessCard/Protocols/BusinessCardProviding.swift`
|
||||||
|
- `BusinessCard/Protocols/ContactTracking.swift`
|
||||||
|
- `BusinessCard/Protocols/QRCodeProviding.swift`
|
||||||
|
- `BusinessCard/Protocols/ShareLinkProviding.swift`
|
||||||
|
|
||||||
|
### State
|
||||||
|
- `BusinessCard/State/AppState.swift`
|
||||||
|
- `BusinessCard/State/CardStore.swift`
|
||||||
|
- `BusinessCard/State/ContactsStore.swift`
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `BusinessCard/Services/QRCodeService.swift` — CoreImage QR generation
|
||||||
|
- `BusinessCard/Services/ShareLinkService.swift` — share URL helpers
|
||||||
|
|
||||||
|
### Views
|
||||||
|
- `BusinessCard/Views/RootTabView.swift` — tabbed shell
|
||||||
|
- `BusinessCard/Views/CardsHomeView.swift` — hero + card carousel
|
||||||
|
- `BusinessCard/Views/ShareCardView.swift` — QR + share actions
|
||||||
|
- `BusinessCard/Views/CustomizeCardView.swift` — theme/layout controls
|
||||||
|
- `BusinessCard/Views/ContactsView.swift` — tracking list + search
|
||||||
|
- `BusinessCard/Views/WidgetsView.swift` — preview mockups
|
||||||
|
|
||||||
|
### Design + Localization
|
||||||
|
- `BusinessCard/Design/DesignConstants.swift`
|
||||||
|
- `BusinessCard/Resources/Localizable.xcstrings`
|
||||||
|
|
||||||
|
### watchOS
|
||||||
|
- `BusinessCardWatch/BusinessCardWatchApp.swift`
|
||||||
|
- `BusinessCardWatch/Views/WatchContentView.swift`
|
||||||
|
- `BusinessCardWatch/State/WatchCardStore.swift`
|
||||||
|
- `BusinessCardWatch/Resources/Localizable.xcstrings`
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
- All user-facing strings are in `.xcstrings`.
|
||||||
|
- Supported locales: en, es‑MX, fr‑CA.
|
||||||
|
- Use `String.localized("Key")` for non-Text strings.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- `BusinessCardTests/BusinessCardTests.swift` includes basic unit tests.
|
||||||
|
|
||||||
|
## Known Stubs / TODOs
|
||||||
|
- Apple Wallet and NFC flows are alert-only placeholders.
|
||||||
|
- Share URLs are sample placeholders.
|
||||||
|
- Widget previews are not WidgetKit extensions.
|
||||||
|
|
||||||
|
## If You Extend The App
|
||||||
|
- Add new strings to the String Catalogs.
|
||||||
|
- Add new constants to `DesignConstants.swift` instead of literals.
|
||||||
|
- Keep view logic UI-only; push business logic to state classes.
|
||||||
|
- Prefer protocols for new capabilities.
|
||||||
Loading…
Reference in New Issue
Block a user