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