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

This commit is contained in:
Matt Bruce 2026-01-08 17:07:12 -06:00
parent e4f99b8beb
commit 31452ab287
55 changed files with 4422 additions and 32 deletions

513
Agents.md Normal file
View 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.

View File

@ -24,14 +24,33 @@
/* End PBXContainerItemProxy 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; };
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; };
/* 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 */
05CFDAD65474442D8E3E309E /* BusinessCardWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = BusinessCardWatch;
sourceTree = "<group>";
};
EA8379252F105F2600077F87 /* BusinessCard */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */,
);
path = BusinessCard;
sourceTree = "<group>";
};
@ -48,6 +67,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
93EDDE26B3EB4E32AF5B58FC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA8379202F105F2600077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -76,6 +102,7 @@
isa = PBXGroup;
children = (
EA8379252F105F2600077F87 /* BusinessCard */,
05CFDAD65474442D8E3E309E /* BusinessCardWatch */,
EA8379332F105F2800077F87 /* BusinessCardTests */,
EA83793D2F105F2800077F87 /* BusinessCardUITests */,
EA8379242F105F2600077F87 /* Products */,
@ -86,6 +113,7 @@
isa = PBXGroup;
children = (
EA8379232F105F2600077F87 /* BusinessCard.app */,
60186E73BC8040538616865B /* BusinessCardWatch.app */,
EA8379302F105F2800077F87 /* BusinessCardTests.xctest */,
EA83793A2F105F2800077F87 /* BusinessCardUITests.xctest */,
);
@ -95,6 +123,28 @@
/* End PBXGroup 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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA8379442F105F2800077F87 /* Build configuration list for PBXNativeTarget "BusinessCard" */;
@ -173,6 +223,9 @@
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
D007169724A44109B518B9E6 = {
CreatedOnToolsVersion = 26.0;
};
EA8379222F105F2600077F87 = {
CreatedOnToolsVersion = 26.0;
};
@ -191,6 +244,8 @@
hasScannedForEncodings = 0;
knownRegions = (
en,
"es-MX",
"fr-CA",
Base,
);
mainGroup = EA83791A2F105F2600077F87;
@ -201,6 +256,7 @@
projectRoot = "";
targets = (
EA8379222F105F2600077F87 /* BusinessCard */,
D007169724A44109B518B9E6 /* BusinessCardWatch */,
EA83792F2F105F2800077F87 /* BusinessCardTests */,
EA8379392F105F2800077F87 /* BusinessCardUITests */,
);
@ -208,6 +264,13 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
9F6436BCE5F34967B6A4509D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA8379212F105F2600077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -232,6 +295,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
7D1EBA94A23F41D5A441C5E4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA83791F2F105F2600077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -269,6 +339,56 @@
/* End PBXTargetDependency 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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -395,11 +515,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BusinessCard/BusinessCard.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -417,7 +539,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@ -427,11 +549,13 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BusinessCard/BusinessCard.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -449,7 +573,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@ -470,7 +594,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
};
@ -492,7 +616,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BusinessCard.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BusinessCard";
};
@ -512,7 +636,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = BusinessCard;
};
@ -532,7 +656,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.2;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = BusinessCard;
};
@ -541,6 +665,15 @@
/* End XCBuildConfiguration 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" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@ -5,6 +5,11 @@
<key>SchemeUserState</key>
<dict>
<key>BusinessCard.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>BusinessCardWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>

View 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>

View File

@ -1,17 +1,42 @@
//
// BusinessCardApp.swift
// BusinessCard
//
// Created by Matt Bruce on 1/8/26.
//
import SwiftUI
import SwiftData
@main
struct BusinessCardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
private let modelContainer: ModelContainer
@State private var appState: AppState
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)
}
}

View File

@ -9,13 +9,7 @@ import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
RootTabView()
}
}

View 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
View 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>

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

View 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 }
}

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

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

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

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

View 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
}
}

View File

@ -0,0 +1,6 @@
import Foundation
protocol ContactTracking {
var contacts: [Contact] { get }
func recordShare(for name: String, role: String, company: String, cardLabel: String)
}

View File

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

View 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
}

View 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 dun 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 dun 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" : "Lapp 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 lindustrie" } }
}
},
"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" : "Lexportation 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." } }
}
}
}
}

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

View 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
}
}

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

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

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

View 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
}
}
}

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

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

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

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

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

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

View 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.")
}

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

View 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") { }
}

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

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

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

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

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

View File

@ -1,17 +1,188 @@
//
// BusinessCardTests.swift
// BusinessCardTests
//
// Created by Matt Bruce on 1/8/26.
//
import Testing
import SwiftData
@testable import BusinessCard
struct BusinessCardTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
@Test func vCardPayloadIncludesFields() async throws {
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")
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "watch",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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>

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

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

View 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
)
]
}

View 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" } }
}
}
}
}

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

View 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
}
}
}

View 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
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

82
ai_implmentation.md Normal file
View 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`.
- Protocoloriented 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, esMX, frCA.
- 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.