From 31452ab287572c0e5e23d823d1cc412b92dd41f4 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 8 Jan 2026 17:07:12 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Agents.md | 513 ++++++++++++++++++ BusinessCard.xcodeproj/project.pbxproj | 145 ++++- .../xcschemes/xcschememanagement.plist | 5 + BusinessCard/BusinessCard.entitlements | 20 + BusinessCard/BusinessCardApp.swift | 45 +- BusinessCard/ContentView.swift | 8 +- BusinessCard/Design/DesignConstants.swift | 108 ++++ BusinessCard/Info.plist | 10 + .../Localization/String+Localization.swift | 12 + BusinessCard/Models/AppTab.swift | 11 + BusinessCard/Models/BusinessCard.swift | 142 +++++ BusinessCard/Models/CardLayoutStyle.swift | 20 + BusinessCard/Models/CardTheme.swift | 67 +++ BusinessCard/Models/Contact.swift | 82 +++ .../Protocols/BusinessCardProviding.swift | 17 + BusinessCard/Protocols/ContactTracking.swift | 6 + BusinessCard/Protocols/QRCodeProviding.swift | 5 + .../Protocols/ShareLinkProviding.swift | 9 + BusinessCard/Resources/Localizable.xcstrings | 511 +++++++++++++++++ BusinessCard/Services/QRCodeService.swift | 17 + BusinessCard/Services/ShareLinkService.swift | 32 ++ BusinessCard/Services/WatchSyncService.swift | 63 +++ BusinessCard/State/AppState.swift | 20 + BusinessCard/State/CardStore.swift | 102 ++++ BusinessCard/State/ContactsStore.swift | 83 +++ BusinessCard/Views/BusinessCardView.swift | 204 +++++++ BusinessCard/Views/CardCarouselView.swift | 56 ++ BusinessCard/Views/CardEditorView.swift | 297 ++++++++++ BusinessCard/Views/CardsHomeView.swift | 80 +++ BusinessCard/Views/ContactsView.swift | 115 ++++ BusinessCard/Views/CustomizeCardView.swift | 171 ++++++ BusinessCard/Views/EmptyStateView.swift | 25 + BusinessCard/Views/HeroBannerView.swift | 95 ++++ BusinessCard/Views/PrimaryActionButton.swift | 19 + BusinessCard/Views/QRCodeView.swift | 34 ++ BusinessCard/Views/RootTabView.swift | 36 ++ BusinessCard/Views/ShareCardView.swift | 341 ++++++++++++ BusinessCard/Views/WidgetsCalloutView.swift | 38 ++ BusinessCard/Views/WidgetsView.swift | 106 ++++ BusinessCardTests/BusinessCardTests.swift | 189 ++++++- .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../BusinessCardWatch.entitlements | 10 + BusinessCardWatch/BusinessCardWatchApp.swift | 42 ++ .../Design/WatchDesignConstants.swift | 34 ++ BusinessCardWatch/Models/WatchCard.swift | 68 +++ .../Resources/Localizable.xcstrings | 33 ++ .../Services/WatchQRCodeService.swift | 17 + BusinessCardWatch/State/WatchCardStore.swift | 59 ++ .../Views/WatchContentView.swift | 127 +++++ README.md | 104 ++++ _design/screenshots/image-1.png | Bin 0 -> 92689 bytes _design/screenshots/image-2.png | Bin 0 -> 92689 bytes _design/screenshots/image-3.png | Bin 0 -> 92689 bytes ai_implmentation.md | 82 +++ 55 files changed, 4422 insertions(+), 32 deletions(-) create mode 100644 Agents.md create mode 100644 BusinessCard/BusinessCard.entitlements create mode 100644 BusinessCard/Design/DesignConstants.swift create mode 100644 BusinessCard/Info.plist create mode 100644 BusinessCard/Localization/String+Localization.swift create mode 100644 BusinessCard/Models/AppTab.swift create mode 100644 BusinessCard/Models/BusinessCard.swift create mode 100644 BusinessCard/Models/CardLayoutStyle.swift create mode 100644 BusinessCard/Models/CardTheme.swift create mode 100644 BusinessCard/Models/Contact.swift create mode 100644 BusinessCard/Protocols/BusinessCardProviding.swift create mode 100644 BusinessCard/Protocols/ContactTracking.swift create mode 100644 BusinessCard/Protocols/QRCodeProviding.swift create mode 100644 BusinessCard/Protocols/ShareLinkProviding.swift create mode 100644 BusinessCard/Resources/Localizable.xcstrings create mode 100644 BusinessCard/Services/QRCodeService.swift create mode 100644 BusinessCard/Services/ShareLinkService.swift create mode 100644 BusinessCard/Services/WatchSyncService.swift create mode 100644 BusinessCard/State/AppState.swift create mode 100644 BusinessCard/State/CardStore.swift create mode 100644 BusinessCard/State/ContactsStore.swift create mode 100644 BusinessCard/Views/BusinessCardView.swift create mode 100644 BusinessCard/Views/CardCarouselView.swift create mode 100644 BusinessCard/Views/CardEditorView.swift create mode 100644 BusinessCard/Views/CardsHomeView.swift create mode 100644 BusinessCard/Views/ContactsView.swift create mode 100644 BusinessCard/Views/CustomizeCardView.swift create mode 100644 BusinessCard/Views/EmptyStateView.swift create mode 100644 BusinessCard/Views/HeroBannerView.swift create mode 100644 BusinessCard/Views/PrimaryActionButton.swift create mode 100644 BusinessCard/Views/QRCodeView.swift create mode 100644 BusinessCard/Views/RootTabView.swift create mode 100644 BusinessCard/Views/ShareCardView.swift create mode 100644 BusinessCard/Views/WidgetsCalloutView.swift create mode 100644 BusinessCard/Views/WidgetsView.swift create mode 100644 BusinessCardWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 BusinessCardWatch/Assets.xcassets/Contents.json create mode 100644 BusinessCardWatch/BusinessCardWatch.entitlements create mode 100644 BusinessCardWatch/BusinessCardWatchApp.swift create mode 100644 BusinessCardWatch/Design/WatchDesignConstants.swift create mode 100644 BusinessCardWatch/Models/WatchCard.swift create mode 100644 BusinessCardWatch/Resources/Localizable.xcstrings create mode 100644 BusinessCardWatch/Services/WatchQRCodeService.swift create mode 100644 BusinessCardWatch/State/WatchCardStore.swift create mode 100644 BusinessCardWatch/Views/WatchContentView.swift create mode 100644 README.md create mode 100644 _design/screenshots/image-1.png create mode 100644 _design/screenshots/image-2.png create mode 100644 _design/screenshots/image-3.png create mode 100644 ai_implmentation.md diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..8c4b93a --- /dev/null +++ b/Agents.md @@ -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: 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. diff --git a/BusinessCard.xcodeproj/project.pbxproj b/BusinessCard.xcodeproj/project.pbxproj index 5e8f35c..2d6eeda 100644 --- a/BusinessCard.xcodeproj/project.pbxproj +++ b/BusinessCard.xcodeproj/project.pbxproj @@ -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 = ""; + }; EA8379252F105F2600077F87 /* BusinessCard */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EA837E5C2F106CB500077F87 /* Exceptions for "BusinessCard" folder in "BusinessCard" target */, + ); path = BusinessCard; sourceTree = ""; }; @@ -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 = ( diff --git a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 0f0a521..e1acda6 100644 --- a/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/BusinessCard.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,6 +5,11 @@ SchemeUserState BusinessCard.xcscheme_^#shared#^_ + + orderHint + 1 + + BusinessCardWatch.xcscheme_^#shared#^_ orderHint 0 diff --git a/BusinessCard/BusinessCard.entitlements b/BusinessCard/BusinessCard.entitlements new file mode 100644 index 0000000..046e211 --- /dev/null +++ b/BusinessCard/BusinessCard.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.com.mbrucedogs.BusinessCard + + com.apple.developer.icloud-services + + CloudKit + + com.apple.security.application-groups + + group.com.mbrucedogs.BusinessCard + + + diff --git a/BusinessCard/BusinessCardApp.swift b/BusinessCard/BusinessCardApp.swift index 749f3aa..743ab6c 100644 --- a/BusinessCard/BusinessCardApp.swift +++ b/BusinessCard/BusinessCardApp.swift @@ -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) + } } diff --git a/BusinessCard/ContentView.swift b/BusinessCard/ContentView.swift index 4e18624..a5f5522 100644 --- a/BusinessCard/ContentView.swift +++ b/BusinessCard/ContentView.swift @@ -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() } } diff --git a/BusinessCard/Design/DesignConstants.swift b/BusinessCard/Design/DesignConstants.swift new file mode 100644 index 0000000..fb20081 --- /dev/null +++ b/BusinessCard/Design/DesignConstants.swift @@ -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) + } +} diff --git a/BusinessCard/Info.plist b/BusinessCard/Info.plist new file mode 100644 index 0000000..ca9a074 --- /dev/null +++ b/BusinessCard/Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/BusinessCard/Localization/String+Localization.swift b/BusinessCard/Localization/String+Localization.swift new file mode 100644 index 0000000..3478597 --- /dev/null +++ b/BusinessCard/Localization/String+Localization.swift @@ -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) + } +} diff --git a/BusinessCard/Models/AppTab.swift b/BusinessCard/Models/AppTab.swift new file mode 100644 index 0000000..c4fcf66 --- /dev/null +++ b/BusinessCard/Models/AppTab.swift @@ -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 } +} diff --git a/BusinessCard/Models/BusinessCard.swift b/BusinessCard/Models/BusinessCard.swift new file mode 100644 index 0000000..d4b18be --- /dev/null +++ b/BusinessCard/Models/BusinessCard.swift @@ -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) + } + } +} diff --git a/BusinessCard/Models/CardLayoutStyle.swift b/BusinessCard/Models/CardLayoutStyle.swift new file mode 100644 index 0000000..1a29c2d --- /dev/null +++ b/BusinessCard/Models/CardLayoutStyle.swift @@ -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") + } + } +} diff --git a/BusinessCard/Models/CardTheme.swift b/BusinessCard/Models/CardTheme.swift new file mode 100644 index 0000000..34a1b98 --- /dev/null +++ b/BusinessCard/Models/CardTheme.swift @@ -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) + } +} diff --git a/BusinessCard/Models/Contact.swift b/BusinessCard/Models/Contact.swift new file mode 100644 index 0000000..0ae9fe9 --- /dev/null +++ b/BusinessCard/Models/Contact.swift @@ -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) + } + } +} diff --git a/BusinessCard/Protocols/BusinessCardProviding.swift b/BusinessCard/Protocols/BusinessCardProviding.swift new file mode 100644 index 0000000..e397b03 --- /dev/null +++ b/BusinessCard/Protocols/BusinessCardProviding.swift @@ -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 + } +} diff --git a/BusinessCard/Protocols/ContactTracking.swift b/BusinessCard/Protocols/ContactTracking.swift new file mode 100644 index 0000000..c83f8a5 --- /dev/null +++ b/BusinessCard/Protocols/ContactTracking.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol ContactTracking { + var contacts: [Contact] { get } + func recordShare(for name: String, role: String, company: String, cardLabel: String) +} diff --git a/BusinessCard/Protocols/QRCodeProviding.swift b/BusinessCard/Protocols/QRCodeProviding.swift new file mode 100644 index 0000000..8f59d29 --- /dev/null +++ b/BusinessCard/Protocols/QRCodeProviding.swift @@ -0,0 +1,5 @@ +import CoreGraphics + +protocol QRCodeProviding { + func qrCode(from payload: String) -> CGImage? +} diff --git a/BusinessCard/Protocols/ShareLinkProviding.swift b/BusinessCard/Protocols/ShareLinkProviding.swift new file mode 100644 index 0000000..106373d --- /dev/null +++ b/BusinessCard/Protocols/ShareLinkProviding.swift @@ -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 +} diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings new file mode 100644 index 0000000..0eec403 --- /dev/null +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -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." } } + } + } + } +} + diff --git a/BusinessCard/Services/QRCodeService.swift b/BusinessCard/Services/QRCodeService.swift new file mode 100644 index 0000000..8c8a40b --- /dev/null +++ b/BusinessCard/Services/QRCodeService.swift @@ -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) + } +} diff --git a/BusinessCard/Services/ShareLinkService.swift b/BusinessCard/Services/ShareLinkService.swift new file mode 100644 index 0000000..445506a --- /dev/null +++ b/BusinessCard/Services/ShareLinkService.swift @@ -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 + } +} diff --git a/BusinessCard/Services/WatchSyncService.swift b/BusinessCard/Services/WatchSyncService.swift new file mode 100644 index 0000000..b2cb872 --- /dev/null +++ b/BusinessCard/Services/WatchSyncService.swift @@ -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") + } +} diff --git a/BusinessCard/State/AppState.swift b/BusinessCard/State/AppState.swift new file mode 100644 index 0000000..96e85ad --- /dev/null +++ b/BusinessCard/State/AppState.swift @@ -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() + } +} diff --git a/BusinessCard/State/CardStore.swift b/BusinessCard/State/CardStore.swift new file mode 100644 index 0000000..40ace09 --- /dev/null +++ b/BusinessCard/State/CardStore.swift @@ -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( + 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) + } +} diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift new file mode 100644 index 0000000..1fec5f3 --- /dev/null +++ b/BusinessCard/State/ContactsStore.swift @@ -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( + 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 + } + } +} diff --git a/BusinessCard/Views/BusinessCardView.swift b/BusinessCard/Views/BusinessCardView.swift new file mode 100644 index 0000000..99d9295 --- /dev/null +++ b/BusinessCard/Views/BusinessCardView.swift @@ -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) +} diff --git a/BusinessCard/Views/CardCarouselView.swift b/BusinessCard/Views/CardCarouselView.swift new file mode 100644 index 0000000..cae77df --- /dev/null +++ b/BusinessCard/Views/CardCarouselView.swift @@ -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)) +} diff --git a/BusinessCard/Views/CardEditorView.swift b/BusinessCard/Views/CardEditorView.swift new file mode 100644 index 0000000..2235733 --- /dev/null +++ b/BusinessCard/Views/CardEditorView.swift @@ -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)) +} diff --git a/BusinessCard/Views/CardsHomeView.swift b/BusinessCard/Views/CardsHomeView.swift new file mode 100644 index 0000000..f09a29c --- /dev/null +++ b/BusinessCard/Views/CardsHomeView.swift @@ -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)) +} diff --git a/BusinessCard/Views/ContactsView.swift b/BusinessCard/Views/ContactsView.swift new file mode 100644 index 0000000..9dde92f --- /dev/null +++ b/BusinessCard/Views/ContactsView.swift @@ -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)) +} diff --git a/BusinessCard/Views/CustomizeCardView.swift b/BusinessCard/Views/CustomizeCardView.swift new file mode 100644 index 0000000..5c11fe8 --- /dev/null +++ b/BusinessCard/Views/CustomizeCardView.swift @@ -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)) +} diff --git a/BusinessCard/Views/EmptyStateView.swift b/BusinessCard/Views/EmptyStateView.swift new file mode 100644 index 0000000..2a1ad82 --- /dev/null +++ b/BusinessCard/Views/EmptyStateView.swift @@ -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.") +} diff --git a/BusinessCard/Views/HeroBannerView.swift b/BusinessCard/Views/HeroBannerView.swift new file mode 100644 index 0000000..950c613 --- /dev/null +++ b/BusinessCard/Views/HeroBannerView.swift @@ -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() +} diff --git a/BusinessCard/Views/PrimaryActionButton.swift b/BusinessCard/Views/PrimaryActionButton.swift new file mode 100644 index 0000000..b50f1e0 --- /dev/null +++ b/BusinessCard/Views/PrimaryActionButton.swift @@ -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") { } +} diff --git a/BusinessCard/Views/QRCodeView.swift b/BusinessCard/Views/QRCodeView.swift new file mode 100644 index 0000000..d26d77f --- /dev/null +++ b/BusinessCard/Views/QRCodeView.swift @@ -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()) + + return QRCodeView(payload: cards.first?.vCardPayload ?? "") + .environment(AppState(modelContext: context)) + .padding() +} diff --git a/BusinessCard/Views/RootTabView.swift b/BusinessCard/Views/RootTabView.swift new file mode 100644 index 0000000..914cfb1 --- /dev/null +++ b/BusinessCard/Views/RootTabView.swift @@ -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)) +} diff --git a/BusinessCard/Views/ShareCardView.swift b/BusinessCard/Views/ShareCardView.swift new file mode 100644 index 0000000..c6dc3c7 --- /dev/null +++ b/BusinessCard/Views/ShareCardView.swift @@ -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)) +} diff --git a/BusinessCard/Views/WidgetsCalloutView.swift b/BusinessCard/Views/WidgetsCalloutView.swift new file mode 100644 index 0000000..84988e0 --- /dev/null +++ b/BusinessCard/Views/WidgetsCalloutView.swift @@ -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) +} diff --git a/BusinessCard/Views/WidgetsView.swift b/BusinessCard/Views/WidgetsView.swift new file mode 100644 index 0000000..b6ddc6b --- /dev/null +++ b/BusinessCard/Views/WidgetsView.swift @@ -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)) +} diff --git a/BusinessCardTests/BusinessCardTests.swift b/BusinessCardTests/BusinessCardTests.swift index 4f480ff..d7fb347 100644 --- a/BusinessCardTests/BusinessCardTests.swift +++ b/BusinessCardTests/BusinessCardTests.swift @@ -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") + } } diff --git a/BusinessCardWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/BusinessCardWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bb0321f --- /dev/null +++ b/BusinessCardWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "watch", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BusinessCardWatch/Assets.xcassets/Contents.json b/BusinessCardWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BusinessCardWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BusinessCardWatch/BusinessCardWatch.entitlements b/BusinessCardWatch/BusinessCardWatch.entitlements new file mode 100644 index 0000000..901895b --- /dev/null +++ b/BusinessCardWatch/BusinessCardWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.mbrucedogs.BusinessCard + + + diff --git a/BusinessCardWatch/BusinessCardWatchApp.swift b/BusinessCardWatch/BusinessCardWatchApp.swift new file mode 100644 index 0000000..cc2a27e --- /dev/null +++ b/BusinessCardWatch/BusinessCardWatchApp.swift @@ -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) + } +} diff --git a/BusinessCardWatch/Design/WatchDesignConstants.swift b/BusinessCardWatch/Design/WatchDesignConstants.swift new file mode 100644 index 0000000..c706eed --- /dev/null +++ b/BusinessCardWatch/Design/WatchDesignConstants.swift @@ -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) + } +} diff --git a/BusinessCardWatch/Models/WatchCard.swift b/BusinessCardWatch/Models/WatchCard.swift new file mode 100644 index 0000000..749bb1a --- /dev/null +++ b/BusinessCardWatch/Models/WatchCard.swift @@ -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 + ) + ] +} diff --git a/BusinessCardWatch/Resources/Localizable.xcstrings b/BusinessCardWatch/Resources/Localizable.xcstrings new file mode 100644 index 0000000..473bffb --- /dev/null +++ b/BusinessCardWatch/Resources/Localizable.xcstrings @@ -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" } } + } + } + } +} diff --git a/BusinessCardWatch/Services/WatchQRCodeService.swift b/BusinessCardWatch/Services/WatchQRCodeService.swift new file mode 100644 index 0000000..a3779c0 --- /dev/null +++ b/BusinessCardWatch/Services/WatchQRCodeService.swift @@ -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) + } +} diff --git a/BusinessCardWatch/State/WatchCardStore.swift b/BusinessCardWatch/State/WatchCardStore.swift new file mode 100644 index 0000000..15f3947 --- /dev/null +++ b/BusinessCardWatch/State/WatchCardStore.swift @@ -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 + } + } +} diff --git a/BusinessCardWatch/Views/WatchContentView.swift b/BusinessCardWatch/Views/WatchContentView.swift new file mode 100644 index 0000000..e2a7aac --- /dev/null +++ b/BusinessCardWatch/Views/WatchContentView.swift @@ -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()) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b13a7e1 --- /dev/null +++ b/README.md @@ -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. diff --git a/_design/screenshots/image-1.png b/_design/screenshots/image-1.png new file mode 100644 index 0000000000000000000000000000000000000000..3032a06d7d4469829a44a8fe2a32fc1155433d69 GIT binary patch literal 92689 zcmZ^K19)BC(r|FnsA<$#C$??dw$<3S(bz^CMCfXIUsr=yqdg+oMyDj^%{I)1@p!Jd~ruF?7V z_~e&Nr5`r{2?i(58Ie0MCh&l1dG3y4!3HKld&I7lMT_-KE@{5-Ljc9t5Qk~tMzq5W zhcV1IMnz=nBo{ECLbHwq9N2rOxvAl;!aNTOL5kIg2O`GN778y@6PoSMYk{?#*u;V! zH2lnmZ9J5IW9bFMBfCWu?ndPaTj;+o`ygm?XJs@gV=hS?k7mjS|^odL} zZkK>_WJVM@(j~FbdV$2LP?>dj<`bH4zW?XpT=J;M>>XUiacfZ(hIk5=2%){kRKsP0-i(`@Zm-k?DbRwxc z@((ijka^b7-OEBbG8xQUeNjFJ;II^sE_qPb{>Ti_3*F$&V2GQL&w>mvkcvQj8ZaIp z080Q4JqV&2BFUer8sGYjssZ^Df@P1OJ%A-Bag(MQ1{8R_$>t8TlZV(25Z>g)3;@C) zDiUHBKphH`#i8whF%r@b6TpQK<+)QJO9k}Dy`}P36EaocRDn7TJj&~vhCQHh#ySc; z6Xu-;yMFWdmY4xbEzHGGc?sRDmxcon6S&_;X-}2~jnQYdCDr_f4~)IXe5?GsjmEpK z;4ozWIAks(e+^=p;OJmNSP3Q~(G(gQ~ZmDOh&@7UgD2I@x9_NW=$8O0jY8`v2? zRi8U>;NqEwIt@T=Z99}Ud#_=yx#|#u5Ei442N1qpxYM?yt)miz?r+y!klt6`L*6sr ze*Yz&hYLAl4+iM=y!v z5|$>6OXL(#C{bBJl_tyyvBawrh!RSJj-v_EkYv-ygc51V@W=>c zCS;6d)zjHBZ!;}29L6ulk0$Wbl_xqTm?x~p?KC%O($Fo?S7|}oy(yC^LSvR=R%+GB zACreI+f?t~p1f_s+QwpKKGRaJ&|KhLxLM$3bIw%AB+4|)RBs$?gl)vJ=Czhk3#y<%(+Gb-#)Z zM5mPcCKX)Wcu{202IQ80G;Y6#y+_Jkky)jkzbe@ze@1tBjw5}j`lrPyJx4o8JCIxSoiYJ2LFbfS?mi8?8r3n_adK65)!be4 zIdmhp7iHT7;syLQ_0936`c>?0)ukVb0cISE0s+JO1Kc!%CG=nfIM@{==hV+*TMkDY z!ablK>7c1F1tB8gGNGJMtxzdpZS$VGKy#a{n-7g6))yyx3`m2ptC+VOYcxE}&x@SR zoLf$1`#c}3V=2N9!V5&{qSgo=_(s_+b?5p%Tz$}V_KI>GZ%=DYL#*+w*{!+cSZywJ zQ2*BOP4t2m_C7c*xC6G&>1E$GXO(@*LvN&=`)Xouf3mAxz)SEkP#HcM!w~~twx4W} z>`eZ-7vbB2eOMxgsWzTX9GB@{ErAIyryl1#CjrI~4PQoi2KD5rj!}!rO~SqhxJTEH@ukPZ zDbyP%YB?LQ{poFg>smhF1%cHo%XSg-Uisk9z`-3!U2SLaYYSrb|zS|zG_TWP&) z+3xbPK(E?cwmRQAAHC|_Sh;>{$nEzOjmO80?Rs=Zv-py=W-={<;n%*;_~EjTU8!*YR{yO~R$(@wDBsMmuAQ{A@JZx*#I0KF z!ndxK=hpLro{=ME?Ba!NvTQm&UQe>~ar?{yoA1`6%j>rxr-)Mn*=;VqlMf(xLJT_H zx6SULpm~SAg#E(A)UWip&Ash2-t)Vb%EeCm7aLiA-0QnD^Uf1DT4OFv$CY21+vGRC zcRJh_ZA^S@;%EY`=lHZe$z5lAx$S>V-uSk0+?DJ5^Cl<@niw&azn727r{c1CJ7O|1 zV}sV$E~#x;{^tJ1nJ?P=u{w3}=`yy0zw;W6|1)2?JLqRSXe0mUjzD+!4Th8cbw^S= zE*8y$^39Y@)wn+1)7XC1WPWpg=i=dC#`K(uJ*pVOpI-c!QOQF26S|+yx8%yz$u^?Bt-NdIaCSU7tX? zgcU47!Zjy7<-gMmRNgn~i8{-V6zB6$$MTLI#EkiTIFreBDH%EFS8ufNL1j;5xzP9N-? zP|rE|Bj|J4PI$DQjHv@vxy1iIT;+d6T%^Ai0F zgXyZ)`||8`Y%GIbQTvw5A< zneQ*r{HOEZ8~@XhhyEAoe`CeJ`TVb^uZ-q{mJfkoU;<#0B7!RJ;3wJ8 z1}dtzgHtj>C_uuvX>P)6I;nHrj2V>*jcT{MDo|U>e8XAm?YbAZBH&O8HEx|d7~K*k zTR?*8ihm=!eZ(XUHqitH|J-4kCbt?^z=d1v?4Q(4fEgg@XPuI^0#uTKJJw>Cy2XzyT0bQSl= zz)^tzdj${)0Ohw~0LaqsYsTeHjBL;X6l>r~1R(z5LIMWxL=YF9)zu?u#Q=u0mj7X1 ze?TNs!1UVfKrQBlDjge}7_C&Tqx%r-#kx3NW5p)Pa@n;Tj~AInwMj$jDX!Z;jvxU9 zgJ?k)H(=P7kCBk$+p%SmzWScMnyRo(cVbN3^x&h?Bm57`dtI|VdVo#@v2FS{npEt$ zOb4}Ho_{1n075id0Av(%KPR$AOtYCr%*lRs2F3pWSqwYCku+vUrA8Q+GkcHopO^@a zLXq$bsrjqr8ViBt84g#|ANZ@R6r0BEbT1zgj|| zi+P)}40&f$KzugC_$4dXHeDfMp?-WLj7&Ky8;osU8$>j(iFl*(B~6m{b_oqmWNlcV zi#nWcTBYY&4%KScqKGU`ngmaUEa<&R<9#=xnCoqJ^xHJRBsX|*T5OW?;_;Zhgr`_2 z2d+-bdAmNk>+daoDbZcUvCYYPxi)Fc{Xpf~xjLo3ui31D0;8x{AQFKB5wH^|%ZBEA z^p(0XgP3lK(9u8-0Q6k$6(OQZPo-^*Sn4w#s5zuXlVxgWa!LhA0L>xTJ`=Bg*R>3b z!zZ$^ut>-!f<^r;GJy+XgFAHm_Paw&fpD|&j_mg)3D}kdzTbuhAo~%8H))`JXQ+&1hEJkY7W2G0pkpml z$G};8KMi91+LY67(pa$TN=i05cgVbR@Z~c&`%n}K5)DK(cF%F3yrK*JJ)xsNI7)Yi zy@{!RdU`ZKqMHExcmq)@_MW)^+kZBYf-`J0frI-;px%mDr#P6Q$$6&_lW(txv7_b6 z74jk&Kg~*m4e`t4bqc{?)P!4Gg+IVix|?)mw^8L{%nqJX^_gn|9|Hz_4gLq<62gMP zUrIXJ@oO$6B_;TdSxP$!aKAm_O8?C0fxVv}Jcsb#+T<6Fb10%TjQv-<`ogtTlNAM8 z^JQX~Sf0CRN|m0h%XMC{Q4bbtnzEY~WBTwIBf@tZZVR0Fz<^$0TqZ%Ip`oD?8P({0 zIsvz@PS90$7bRIm9hG_+g?KDp>dVtzE>5y8WYwEEA=VSA7Kzl5#e7sb1TG3CE}a4g zm&`0K;##&ZXh{tIWow$3+UvUULvQLAYPoXX&Du5ek!L&Z;WCO%G)t}a=P#aomyDLw z0!szil4d5&ArWZba_`pH)+X%V-{HDS%{v~{No3r%$ce^7TCz0JQhJ4LQ>_#TVZ{SF z>LrKja_f#JuDty3gn&2Su7b{=nz>v|?s9l*SqTZ_j65d4J$N8oUt!>&63@>^GCnBO z7Gz5%9vpn&wBPi{y{J{IP^~Kp4htKJ$!;TIWo6YS_(I3$bpMXS^<+tNzs~fX!BCxi zL_`}Tg<`YjB)NIi+|$ZJg{Cl3Z=a7|W-qCet5r_LB;OeEA6+4u+^v~HoKj@i0lnf- z%)H;7%oc46vGrN2p{z*i_^bNMaM-g9iGm8Oh=_>9)Y;eF(Vn0Jp^(eHb-h;WmMA=S zIX0VhN4-{EX>#}uubU)RtA){efqvv~Ybj3ZtMhdu2l#|DpHd=;Gth*F#r-%)G({7R zxkDtj35!p7(g=s-c6DO!mbG?m9;nWB2>{{ocqfj+`$&|ZAx0`(-aUMG3$tExY7F=cJetJyM~Bh+7S9S;F#F4c-ff1XNp_laQWeF=xr&Eci5&&2d($uv0QUg|~H=qFO? zXAtg;z?1txUI3F{kG|t}zof;NPwCGWLo}K-mdQBC+v3hgOM;`R^z<7)zkfbazv!1@ zwVI1F6b_RpzC9(a7b)SqyQ1$JC8#rdK$HBQ{iW8!X~h1(r_nLl;p^4f-HEays`wk= zd*RY$&z#M-E%)ip)i=}B1BJpF!kucV#aJB6-h*GW%OS}7dc>RfQIO_vwKx`+&s!wn z9X`uTZ4S_r|7N^&_Q$9@!QJ{)>12D>$LROWSVKei5$DX#O5~0%UTxPi5zk?M9J{5A zhl4BI?nMj`V+aKTQRZwVAGUPyO<(4>&N)Vbg4Q2YCdl~dU5C{gW)2p6>13^zyd#NE zkCt~=Vq7Kp=%}tuBe~JzbI7J`+)j$P{JZ1k}-$Z+Mgcw!J7k0fA&O z?(d>o!^shB^*;bck5I4g?^|nao?IUsPUHiN+X=^nqa=NGPPtHY*;Xo?XVt&$IWH1i zRR?FVn3j#-e{*XoFqLjNJ0NA`3ZT(#t+!sU)f_xT-ETNvkbLpHG5OvNKJ&?gSyGOV zg#Q?ICgdafSoe=q9O`5S*7KnP%d<89LXWzlnFeW$?CO%_{;U=zhna#r;}|;A#DfH) z%v&zg3$-wZr==>jw;Xz6vHGiwBDZDI!*}qwGupHywns*(qoy{F3c7r^c*s~?{e+|} zpwr~kYOu``4+FLORAMDGg=D{S}@U^XPB%}K={HOd(^Bd-&mX1ykE>i zX_ijPh(&qH=t@7^QY#(_6@b{-KWwbbHI$Qv#n^QU`)Z#td^=cO1KDza7&1%qE>y#< zE;i0@GOT5xO(;1Ruur-#v(W2&c7NDzzckNKv1s zuY}qCa30?C(X`R9+1>9*XtldJ{i>b8VrfD{ZlKO$y28Tgy%Vmp-c&Ug8=ESIfFogI zVi6#UW+dUF`~|`yB@$D)NOsCBB0je>kEVZk-fA(@xS>zOQw5|V`JMB0r6o}_0Vjpz zz8wL3TL1QxAE@1En;e6;GWLjoQ_G6rmIwam5oF+axg$voQ?b#^sEyzJMMO+2gKZI{ zU6s2v&{%IEcP7-#L>CqT6gr)f4*)FOCF~T(`O;XGM58Iy#f^@ozHrLzYI5x7 z>k601{Ft8NDt239wXo7~Ok%joMr_u@^=^Sjf;NMd`OctJiPZEVGWqiA{=(DeF7td0 zJXIfLE3a5 zWk-ux7Mq=ID3isO1UfO%^tlSvbaJgFq3jd(A2r$?BAzGE9}ka+DlP({2{2;O6Ktt9 zy%~CRec9?%H=rXZMXhq-Qq(Fndnhe6XA+Is&a0oV+Wrvpd?uMPKWh|637X&3nunR6E`hMn9rMsN3VIsDt zw(Kcr8`L{ZdWW7B;2A3N%n$=z5QUCO2~CKjTFZ6F8B^5RGea@9@A8R($In_ipTCL&hjh_sO;<)>*aR?)9=$i1WRu8SaLmkv`b{8RXQ8!W zZ(Cs@p+fPZJmcLSZll-}mGY$5<9mb{k=KAmG^G@*-t~pcn2}lMtBqF4lT72(k3;3l z{pSZNUlr8muqeE{I^QDQpU*~qYxLjdd8-0h78<{hHy_QCx&Xx53 z+FCSqAM;_ZI=|lfi;mf34l%denbIC@fn4?qii|@617}c!Io#qs5t1l_>zcmAyD(w1 zsdVDvxw3+~b@k>cpoZ$=6EdBgxJt!we&G8~<9G~o(N)#CA3uf&q7`)8Txt8T1J^r! zo}xc2Dncc7`gmdyzUkQ#zWKQS^^TvR7Y(Ej)gVV@X)B+T)hzov4iM)C;qhw9TyU?l zk`8+nYa{?7nZRW%zt3UFvt9(XWop@X(|1nq4xS^|S5TtjafMX^B6Zv@_7vo{(7=a=b_IBZDS#EShJ%bkQ@p(@-%80Q zL_ndVW;|4;3a-K!47ER1KjakP7%Tj}k9IRee;Hv&JPr`pZ^@0Mi6nsz>#_-jLCAW* zN!{~l=Z+P$M1X{rQAR4x*&rqeR4EuZx-@DzOH#ThHnmY7Pk^OLt5>dpc-o9FNWtTX zt(T$IH4v@Sqcs8g)_?KFRF{Jlv_mW}d|P2^K$uvu9dpy5Ef`X2x!bQX)NeKZ9!7QE zSm*RiEgptZ)xHvV-Hl+Lt=0HmMXSZLAzh(2q11XRM~p&5VfORUEf{sAf(u@0NuqE6 zrT@))l)$uYOp!r<2x#bO5ASz`B=|DiBsGjRd(8E6{Q51DyDIe}&v|H%nvXV@@0np^ z)xZmr;BwbtiAVwAjW%?N3#mGv(2aDXb|J`i`1qf=O7)9S-2pbCKmawMdEWg@*#1VQ zvy=S0CVl?y_m_(>txb{x5q>tcpp_3oZ!x|741{7^FMMh|#?+>$t~zv=vZRw>m>u*p z%0pn*%nS%2z-?bbqaS@Vb=j;}DC%ys1XuzEpnKkZVqV(J2~L)kX~Be52gmV|S5z!) z(b^*{!m7A5h=;g%_W;ZgFzIpWzVq_fg)3^XnpXoaDlV`>!)0XXy)^D{g)a*J5ZN2gUi zg?DW z!8+K*w#=|{#?b*_Rfp+Xao9gF*8XSYBlPhn5(hXtIwJfOdA*~HCUp#R7$Xdn{R1_r4fg_KwR{0m=h#k zt&v>4Y~Jt&oFmK;AS2tZBM$*mtR$?QKl$Aci#r~YR;t#w%xM(%WScw(M}&IVuH2?J zF>q@8EVa4izA?zQ>*P258gQk*1EQWdi40s`X z_bza=(QZ-ftlg&`_9XHNV{!2DEMywuBWm+Sg+|Ma_f(_Rs#+)_j?r44eJ(T&D~U?lE>T_CZi7vxFEsLAKTdT;S51($jLW{^3X@AKGB*&rYQvTD993wssv zjP}<06a1(^!O|x>vafS4(1M!6rd#1h z3%jwjWj$pu$e@W%B&%xDO1%(&62-!u9INL2`HZi^^2g|0Z?5u5(QK) zZkEH+0`G;xWtC(H&@_Ory6>7peWk-I7B@Zb;$t~64htQeh>AuH z&}?Mt8#(48Ulf)tMM|1I=yA%WlY#Ryef4Az5~I92TA&89L}cV#;k+u1X1#mOgPw>r zI!S+!WhqFkd5}K_&m6+?8@ET_plm3XNNRK@+u_U1aQJ#MwK|i7#rNzecP?>Nh91_~ zNF`;azhaH>2?Fc=$cs9|^l=rr7n+(`T+}_NJkQPLY8@FKQ(T!o@DFjh5r9Y{a30ev zR`bTziQsgCC7ADFlJCg86N_XRYa|SI!2?XLE7>zgMm`NlN5S%tnGYWiw|6nd3;9HF z^<-QO$q2Rw`tjVBcu?`$44w}{LHE#B7B3@dEmUaJ%Vx60|Ma>SG&W2~aFil9L87mx zyTK4#Hz4UR6{v{PZd=|>T)in5;$PCfSt`a>H%aX5;{uoMvH6y5fic62XBGMC ztaBmVXu*4cin5)fQwVNE%z6f!vY1^^quh9c>XPV?0W9fTZFy{V#sLX0+#@C34$^@N z)=k@825s*A{fUF`pT>+rNdqtG3doRk?2O%TiXyK+xfoHa0L& z^0|s;Piyx(VyfUkkIG9?%WZW&DnTd4i-rM{hHU=ceF>+g2UF%TrR!FDJYeBQfc!)* zY`E!rZI;%f7Ultf9@rgQiadq@@%jn$7>FX$|y1`kHetEpu z!_JX$(Cu@Hxgi5%s1SZtomAnnkXD^F{L##3OvN|rn0zMT4mAPXOB@I%a9>DTW$JZU zz0;zmk6jMJkFlXwRS!=bE?|Dg<+~q<49c<87jKT)nt1I~l~65cwL5IvPZag_NtkIz zAB|%OQFwgY4HLo%CdnQc%0n3-au1K@%hCyAht12FaL#Czv;2Mq&;jf+Qlpu69Aud8 zPzeSjidrvgQzeq@I))6A*D@tTXt^X4yz$pO=N-}Q6hQ>foU)eRld$B2zu=GeUL~A3 zuz##Pv)QMK#KfzX65#UH(v@N2?*tKpnsGD~-_TJ@qhyigQB}xQ8^Na(O;`ms^huz- zSt(@&Ks59c8!1?|Ip{uY-n}(u+|p$4l&jz%KvHt4I1ka&i=s1)WaJHeKq2Zz3I(^Hx@Z;E330r)dJ9*NnDbIst8Md% z9y2WY8Lw&5tBbqImnJ57svQy9g+Ndze$y3QLg*QW#2a*(==h}BV~&(d3pP<&HLTw1 zOc{NSOh(=iYd`c|<_F(XN=W#P?pDXSX4oBD3RS1$G?18$R%HyPpG8Q(tbgm?=F%kb ztA5+Mi{_JPcHR8cT6+-X89T#mXJ|@h@~)im{P8(o&82o=UNV2No44Ek^-H$0W)AW1 zKp2M~fXQ!izY7w-Ys^P2w0OJ2(>c}ayj9tni#lLDfFL!F@B7}$f^H>@*{H@*d<0+; z8XN{SfJ`R5om@1>)aeNl%O)4rAYJ150FfluTz_)KRpUZ#k}L5PjO-eT@0nDC26#ZR zrhf8VQgUUSR-5osRHdud3>CqX4I?8$QSSXq{ge($U%sp0W z6Id`e#2GhbDIGj|^Nc&W{-^Ndp?;bX;+U-d_kK2y+_pFS6?b6gA$6m>+x~YXtQewK z$*fJe$xg6|YUXk0{jh5SopwZixjwI1)uW5`rkE$sFjdIF)pyF&&>G=cJn2Rd0>TtE z30-agZAAU|-!37#k$f1&E_)f@SKq2c9K{2>j;!B$m#zYr^C?c2QvE~ z{m4z65}iZca1y`+K>N#=pmhP{l4%d;Xb!g+N(f#w@fMP#)Dx5MpHrM{)nyrvJjA^{{IaMHA z7wzqwNsa1B#qpP;m1@IX=Wy&OHi1<3j~E*tVZqbxI9Lrv)BA`@p$9w+Kpd{V#X|=W z3Nj#In`Qw_w9Far>y|ckm&a|rO5fU1QCqSEX4eamaIAMwR>p+vMgU) zI1I1)hiy+ZslK;Ks5B$oCSCmLdx!d^PoA!?r`U>PVBj<4_t${F~q<=dI ze`h6<%BwH~BNLPVFFYz-$PAwA-zuS`|H{T7|9g!g`gy*q~JzjEaxjI zX*1YS+r@*!q|-XBUE6l&KV+zx!D6aypRzSX5rJ@yQuhIDDf zn|%2M1sT~;?@Kg}^sZF0>0~=UDWRcub*8aT4~aotgseWHmbrvP_>#+$jFuLIwt?;+ zcKrhE(ZO5ivqFNQU-vXzkiaZiG`tAr#p2!45~hVhS7+BOkDdr@a0&%se*VM4#m9q# zCECAjpMXz7uM$;kK{4MLD{jRcRlXqH0We~+Z77`EZ>c6&v zfGY$Pd(hbWyE}@g`PWwGk%d&cY;H+wnF`LmmTcO@MY%kiUwLR>tf$XUH)ol4!@5neDfYUu`#Ohq zi2M{T$Aj6_+W9$m_*09yepF2xuI0!xjz<#lSkiQW?5x2)r@tieDwWMxf$2|coFQBv z`Gn#ghm`qsnq^@W8ipZ z&$0Hj@Fr)^_(4gZvY2VuE?K+5v%z|`Q#b~fGhxEf@aX!G3)WTBLPgb~z@=QR*8Yo; zzAV~^vCB6dA>nB3`DoHG*7d-X3e)+^$4tAik)*_#c1>oi>8jlR{{CTj`mSxW7(Rff z55!?rS1SZ%rZT=2&rh#!vQB=^)*E_nbk?WVGX2K0cImJso_<^cj+@(<*ZxcG1PGBJ zu{abUJ&hMhOeN4i67&ogW+@ zoYuVqzB>%$30~f|otu7}EfbZVjb*i3EWHsmhgIC#|KW9(vOy-#h`Mv`i);L-P^u)_ z;(2fXa27@oU6NRtaMcXyV6s1{3vmps(MfW)uKS@wv&pyPgx_AEY79kEW{uYEE8|MI z8Mo_MQozd-j#WOB=_iws8Pm%>?(@;Z*<#rZ5Ei2l{?jV4#=K<|{>btOaj(bi{dasQY8&%%HZ zWWK9@@;&g;dov&=3ETkK^Fx0ajqUzqXh>=@mO=xM3`5QT@?3{ZyBYbV-T{?3=b7V{ zJR&A0WlXDy4IR2rI+f|^72#vW0<6`Yt!|;`jpd+EpE{P6jv^P~PmgDJ&06d9U-bh( zVA3?HJTbAfw&vRT7IrEmqZC8Fexr9tL6!_M4AH8ab?Vy&Ba4~l_>qs8q_0Itjtrd3 zy0_kTDhp^fQmJ&b)aq4C5HY5rj(m@-P75>+CL9HwaF|GNVjG@yGC5qTdJpG6$3=b) z9)n!ms2ja2+MhSnO}7gqnJ$mYWoXh8kV)q+3$nQ7O-u?L0(t|)@jVs36^jfrPw^Jt zjVP5WcC=zOaXZDajaqe4m5!}M}_r{^u`a}2=X%VJk< zs;bq(@P_kAj*bl3kN;E-{oCRQ5F)e3rxnLEs*0;(&E$p-P-I`<%&FKuqbXIxzdJ^? zr9PdB*qZ8!YI6O0o}W)T&Q`(UT{t(V5?}?NGqbqZU4q2GV;nl3{#4xBXU$H}BU`0d z*;=DCTsq$V0RNhRbiEStSCc7nBRjjCZ+}|CYLMY{eQvH{3uu5t0i+kLkUdUo9BY<> zI0LlCo2lu#qX(n0GggRuiAi?nC`(n#i-Kw2Du0Y(hFU5+FPpu7G~-~3LNXh$DOJc1 z+*DPAssewZPetT^n0x=ja9o2}L@?@Tt@JZ&Oq-SZr>RJX+=2O0oBpKaay588U2eDA zboMT2efKj>H?-=|^rlVv7DDB@3wHGP;ft752jn{DT&^GN2H~ZVVR&-=9+I()$q__W zb!bZ}oPmdVkv_@0*bQ--Etbj8cMXe4x@qfnvQbv@&@QKx`QTB={ms7Kybo4ih{`E8 zB>&3{nT7?(;&O6F3gyR!AS4KM!g$|IG@YrM8H6|-Pq2?Y5%?aIDbq^MFf(~CjXg!b-|dm5m*aKVEapC-i}Obhm8dmD8;qr01jt z%%(Dz!pZOOxav^>Hqk255aFbvq=qv(x}pRHjW(;zLHP@%y3%a6D>?^Xt(QNw)J!hd zdwgu+JLm>?X%P&>nU_PnfJ7%#>vfd~r{06_2d8F(4hARDI2O?ONQY{38(*wanqbDg zDj(q@xQoSG>##1}0D?r6e(&M$%ryhE z(=aYx92J5zjFb^7sJ_hF!_MUrixKcSjoPr%1q)UB`s9!!d(gZ&wVx{;cbO)3T{c)( z?PWP*@VLuRb1NdMRS`En;)AadFZdYgiuFfvsEoCCe)(P+0p`C4@cUyva*qAYE!-Mp6Q%ZTd8veIFt z{-U!;`+BrB!ZE`1Mi#~yg4YOEF;{qxrnqQEW=)@y;o`j3`z==lgz9i=xRRbJeT?HU zJ`Locqx78EGMX-BVAb5MkOp#Ok9dEX1mR+A)|Ell7G5s+b~5<9ma zw9!@liStv9qu5(K%TDShTUiEts}C4=gfxnmRA=d-_hJ8KnFk60-M7PU?ipdS%P0v0fFLsu;Dlxy(i9n zdvT3lgWvgZ=5=BwjkxYtrAg#`&$=kp#pfeqAXqGb^`+pHoIobU$`02wCyVcLGw8L} zMjTB?XNjT4>Yx_S;DXX6M=>b4wOO^m-T{5A_aPtb`)(GcLgDP}zxtDkX0J3%@P>JA{V?2hvh;=-n=!HKRnzq;?2zlP zVy6R!ArhbiEJdr8D_oV6P#){`p>P492Bp{d;3L+FwUL2;0P*b6pwsGTX0F?JVbARv z@d<7KbvT5+_e(S~ED{C)0)8{%PD?y(Br_gKIpN84@XCDl0cqPO;B>4E0Dh*1+HMgqk^F{SnuZJ-} z-GNpSaXY#FHdp;Eb#pA?678R&kbgOUS@IB#84fOWW)>}n5pC}Q;PI9YH>T&H)1o&g zI4<2M*)RJ#`#2@0gXL8v9G@&6qp%)LYJ&J`XlTE=gv25`lLlY#M>FzI`sH%Y-e30l z5R&c&zwO%NLiGX{f|6O>pUACBOk_SntfF=}zF1kDA#)`C4F_EVU`Xh0_kpNG~8c8z&(ElI; zb?9rNNI4HpA`=sft?1qkvKj!9*FM+#Wx{Q609<~Lb_~tWwBnn)xHb}0` zJqjpM;D;1qLG9Z?UZrBjAu-%=M*iqTc|Lc2V4w!2!<6mA{p{5E8Mj@#Z9iPvhbFtl z1)1l;7#wl}pUYCtE=hIdmTwE%h2^ch?jMO2T%#((e%!la&x_T6W!x(*o=0Ejw)`$o zLNL|wJ&-eOq}$7`!$Qr|e|6l-u9-~7M(PTLTm8{BP3+jRWZ zbZUfQ($ENo9XjcPwNwQEoQcDXG-*uQ5tfS1#VJpVdQRaEMx%45p|kxO+|*#gAJ)Z? zYzo$gVMWxZGZ=XeAyq(sWYIw*f6ZwT@A5=hezbJ9SVbqKL2~O2#e@Y-&bRNZ>sf$z z`VWe^U0&;Tv{tJwORwV0@*7dYN>Z0ArM$+>GZ_gm8V zAI+Dir-7AP5;{ug0vf`GBDQF4~yJ{b)BYUzB*%`XT}SmvS`S&8GN!$hi`O@ zLUkNOi!iq@8*;gPLg&gcs(N7V+C))h-kkY-C;YY^lt!Tm3nyJPn&1+r{maW=6W-Sr z=|k!5L4P}mU6Kp=s$}9LEkl&jMk2!$(`??%EO_d|Vf08D)+A|5=NV{317?P<()Yc; ze%^N)x6b^V*3Pqs1<2#p2phr31upaNgp`ZFWf!EYF)84>IG#PFECR0&E+p;xvF%hB z%b)9go9=;PQF$4+O{gKDB4`~76OCZ67D$wkULBHRrB)S>g*0nA8eP1~0M73-+Q1Z& zOi;AzZYr;NW6D3ti}G5aIk{hedLaU*jmwDyxJwLdk8D8`gR7+%Ewy!|lu{4!`OAO} zfKL?Y9EcCBiT8gArEaEFJLbs05K#l^kRG#JJL^K(yr;Hq|8cu|nfB(kyx@#QI+d1v zA1STY^kd45nEKVd@g9v+h3Xjk5h?+dMqphM97$@UE$yn?8H0<6NHYLcqr8O7ctx}W zChH)*k<5BFncPwuN*9ZSa%6WScAgrEVDqMFeLAe$47>|~1cMH;4>wf8 zgdLl(U8^r;$S}eYB=JYiyy8k)0(O7^^E<1d*RDL zj)tT**ZmQwwSof)Q!EC1sIh!hZtHwn_+HxttxV(ISWNEKYkpH-0}IJ6OuVQ4!5`09 zj3n#n;vZwX`03hQPZJJXM}@}FC!qFTN~Rq555H7q&V8)1((1cR2_Is3^&bTC_~V#JEdc-CgV#rOh!u#Ev9qPX4}{6VFVdu)A&Sj*T=J~8gZLIwOxOXK>vvN zctxfU27Yz#$;tG?QNPbn|70|+Z?$)aU;r?fFzdmW|4iN-?hzwPY_w$N}hr|>Y{9C~7Pw>s& z5V@Kq+BK)^xv=}5UJk3g0L|TH)D$ug~A&`LpA(QUgI~?YtPBolVcmEA>Ju}@1d8> z9!ObQooV9r_m|?|@Iaivw6~#_O%>6IV?Dx8UrEyTiv(l3cSd8kFkybbWPLmD~2UAQGZj;SYbqgx?%kC{zd^=cd)n)xWADv-tt6giGBVo(QqWp{@?pLn7GM_CZ z9UmXBw4J6rR$TgDKQ|=)d2L}4;^mO@x4?0H(;?{Dmwa{3zRAi-6prF2A*^hy4^!TtYS?RRe!ackET~pO;p}vA! z5JWQiMm_TX1j~;O;z&AB7pf?|VfdV60%pp0|6n3|RG)6}0Cr=Vr)fy4)>V3J{@6!} zx@Kh(X2EmzE}g<=6V)L{utprggJ^f?=H{o}nUat<_053ZyPO|`PThQD>jFlFJYB%} zxOqoMTvB#WrHf0pHip;ne(Rc72LKse)+?T3aJifYus^rb0OGE*v4`tH)CfMZYo1?` zO~%KzN2rbZb&C$K2vu?3Iq?c7>B3`;vi{99{cnH*a|cKt_aNeQ6#~|pBUlYzdj0wA zHd2w7p?bdfDA;s_;|`l$6W2jh?j_A37(`WGRBF@%mP{KZm z@vOb+^I+%Gb>hK%ld49IB?aP&Q^b<}zG;C@N%H++Cd@PPETVLC5kj{akyN#r33C*K zUZgUZ-LJTL9>EvA>~8K{_R_#L7vfPJN^3Sbz=@sS;SII>=Cf7$O74=*;om%s1OQ zg-jkdBQj=?FEaXVDYN!TS=d~t#lZMC(Hyf$GF!P<;8*aN5OMyyyz4YX5e>5e3d$DKK6;)|8D88$Z6DlksB^63?BomwWcv&3!j#bLK zWfz(_Jhb_ia(5x8s`>U7Tg1CT^-HwB)gnpOCx83>3E{`%W3Pl@bgF@JsI2zTpF}QJU0jfAPCltNd2D&2 z7y!ms+$yyg-XZ8TXf>I_m_7Ntv{|Ey{rE9*Zr1E?Gav4m;u@%U`SMZ`jf=niv zy2gAadTdONI5t)RBF88MLm>}<*ezuDXG%yYcE4750_BUef;@Tds6`qKBAJDzxN+rc zpU&wSs@z+8^v?<`utlvG<9QiaAyL+pU2S_g*2|4#RIYWNogx5!@7uU}5N#}-+!wm~ z6gh9VJrK%w!n>#&$DNJ{SuS!G*C7#$)C?)^u(7_>(YKx>NqTcaSfEuA{mkltgK`gS zba*&V@-ixV?JFTu;9q-~1cM<0YzCNGtD0StVN*coIM$)nEn|)M-)EAR8dPxZ7C1`i zhs?JC;@v#vxxLBgcy`@^w~rEn5~_^8LH=smfz0b%(aL^dB#P3Grp4A6K>yeNnb`8oBQ0%TA@W z8oKM|k~HGh&08vOTN;(25wT2{Kzc0RO5!v!fC`t2$z(X%X{vksN4sCoYvY7Q`00H4 z9T#ia2}j9Lt0QpS&PuYIug0;X#eVUo&x{ql-lL&dD{G`^Qd*cpk_ zqw%j6^xJyeD9FnJWVgNqZRD4a!OD_3I~g7#fWVVzl>7R#LuX;03Yk;U2MiM^&U#-C zocBF62jSy&;;Ih&3punLVc2ZZz>KNiMAx07bu^V?R@bS-{&+!H$9;=$FI`#YsQG~v z^+%m+&EBm#Y|QGE(ZM%aUKXMsX&SP?G?7&3IK#^P!QbrwP+AU@p^~RteBhS-cd^tR z&OZ{7Zuc&{PAyVshC8*gNG&tEHwle{`v#Ar$|LtwH;MA;h59Ld#AMm(l9> zjf-T)N}zv0qMhk}vr}izgDD62Wzc_50CQJ~E*| zEuhcq$O&y+*W1EX^G}uGp$SB2iobEV?AXiEh$>$F0MMCcSvACgF`qJ9ri?my13DUH zljAXKmRKNb!mfwP;uM@k6oM5U)8amx73=ZNZIwr_OiYf^`L=Ofjy@Ltr}fV9pe94y*b4MnC+cR90#7|uA0bLG+W9rBmMyflm5Rv z(*M+u^+<4P+nB-@maKial-}8RF`chgp20m|j|e<%d9sOXwp(d*Ru_mL>+|w9 z#XZBKd|EM2CW-u~b1(uGvVD7HM_W^k0-|pjk=Wk(=!2x-bfr;_1_bp)97tmSBU;RI z#HyDpqy4KS4!fr*@<*!at#)I`T!figg&JAdgV&O=R*Q9mz$BO2+4bp2^K&ArQ(?+b z)stttTBy{FcPWylb@L1Q^wLux-&b78y{Q9+CRJN?F zRG_6!BW|#zcP4PNxDinVR*~?$UE{iTvDo;I$frd0ZT@U|Ru>ByDl4dr*=Ubh;2@bC z&uyx!+vnYEwV7hfWy~lq(+mmHX+WV`$3M|7;F!jw^x+O6reEThLZZ?aPSPXo-9^=i zQ#_F8M8;vz5tN9X;ECfW@c)VnWmy9}x*P=ZtDCAk+*^{CV{d_ZMAriW@Gp&J4W!TQ zljyH}WXrHzz7sgYencKL+Rl7(o6=sWT48C#l4wN^*3eUUod_); zJOl>tdamkY6+P+6nVMN~iW(G-oFgU`&HVkcFCjxkKr@QlK(k14SHrf#Of}_v{jf30 zD=P2gJ9hSSHG|&Aq@tMawpWQB3ghQx@WHq=t+M1-CafJYwhmZVC}jBSx4t)I1%pb! zAazS$yQM*LXJnlsoPSQJ<7{MY4H>0?C{z+}=<$Zj^Ps-pNov{LVEVRS*B2cf#TP@f z-<()NwO=53g1_~jEDoTSv>`<8t72D(lvAjCq9vtdj@bxVnKC8-^Fz0zjm4c<=lYr- z%2z%Ta8w*T3^6shRegM|n^T-f)H0H`|L!BKt{_ygkb4dqy8e9*v1lb(NxF;eUXuy0 zrDlxw@x~G&9)u*4S^g=%^9%pa=E z&W$%z{77>M^P#wC*4i1QDs}dSeZt_B;wW#CwT4i(%;7?%gN1KTR>MTSDWR-L zu0td`c-EHAZ$HlA4eh7&Qne2-gG3~ofzuPMy6)ltFT+x)&uY<0EYR_=Qk^wv=w9gG z=^k*A&>XIBoIgCZW1vqUf2*bS(Y$LmVcB`0WHHIheP)x>>hYHQ*kddWHHE|cECaG6%H{ApiGOp|$P7esz!5VVIQuaOts@oaAwhl`-zoL_p!f7m3mZ9Mm zyRU4rU%E-<>=EbTll#>Yo|PQVsYvGo4lE95A_)UJ<#yyZ({#bMr41~Yu3*)TqJ$=g zFR5hGSo^k}-;*^{2Vy zq#4gTB34Aq@7%K&aA+2g0u?wAKFOC@g%iufA+)LyCGJXGs3ZDP^`SWMNP8n9UNXJ6|tHFN$ z5RvTjhw41Q%SRq0U_-jLLccjl72*H&lyYx?pM!IXX8}MI%~3WP|9*>}A%w5*kN`M* zy__Ss-DY0+pUd%|1Fug8fQ7)5Ywy!p+=!R{KM$WJ@jN_?i6<7mB&yQ=ck}c|@A?P_ zC6AN21@;=^{y(q9^M3#=InY|zJK{`&_rAAz{)6rHXLz&;i~oVSjI7yA*luPu`>~t; zk0<`H4RuBv?o3JS9Vr0HPF#gQ_&>!mus2$zCG=9@^ z=(ZYjU*YJ>zhO82QxYVN(x6yt*E;`?Y$%#y6rcr3@EqfC*rh=7a1_}{C|W;zqmE3e zi|Pbo8BO|2qq0A-A6j-umpYtN$D)QGV>bN1FOm<6H@!mc;UJ1arXOGo1PbskqW8^6 z{xzOYx6ImmfpbPcH$eZ_lJLtDL^^@G21`WzYl(%|d@B+E{w5n<*fxWY63Xz+X2TW( zn{pk->S>#iJMI~j_}El_&_k$RJR>siX-C%-*I+8fJh>7wB1;zc$|t+b{Q7kkFQf7vyR$vf8d{JBfF{Mz_LrHQEtCFYB@)EZ`=KYJA$Zn>wJkG9(b>{(N+2R$#cKD0;)aS z$s?XL^cv*U&TZ{{#lW^vo_tRI8Mo8TI$}qOEYJ10`I=W;Qu2Igz0OM4YBLv51{qnT zasm5-N&4&!CbEBxA%as%{(?iE+bKL%;vDC$F(oxd`B`50e5p^ro?D1t9v3-o2y|rV1Fyz?TCd&8))APR7#SEl9*Jj z8+OUy;NfHNTW&O-M^7J6&-#QA#U{z5x5n0l&IW(!8UZ5f=ce~;6E$M+3YRNee4__I z^vvhKyVYInPD{$8%6`L4L9Le z)lD{@R+F~eb9bSkYS#{ko=+>jug>@m70t~HE85<(egZ$kDA;V)(Fsp?g~(p?v^J#* zIhDx%0Q;2W$$8Igz8v&T3#d%LO4-}L2ZUuo&9@8twtE+5PF@xA-(6X57KISSJE1R0 zIc_4|Z*MTq?uL?{;kjK0HKWpqR#qY+q9KRHN)~}+7Aqo)nbH`i6*pagx3^2nXEuO# zbFdNdsVuxo=ezUd&hxN(o9>q+5mATqqSc}n8M#b)EFfdw^#G}*yRCIwdTb&9V}fo; zf@5nIB~IFvrU2sKu+-0xoCTv4+N=Gvr*T%5rXh+wbp6$vCyKek=i3Se2EsSDi=T}S zZ!agB@&<#mKlMkWU}JAslTk7%0HoO{bk2%&db4O84p*he$5F4nF0Of4j0!Bo%>u6n z$y}KfTBmS48h|kv8O_-r9vm7C_@WF}jkfBMEet2K#}erR+O>cJ#QjT**|o-=f+N+U#joP_Ir z3xEGso~wEsF^T9!>xTI$)Pe)^nPNh|$BUTe$CXQSiyNErxp92+y~9QrWu-RJlglN`&99izY@gT6MuQ?Lc7GOo1d-%}$bKV> zwoov3k8JV=g9&WH+Fte?Y^d!^8%V4bLn+C8$xaV2ld4QAa3A(f$YX7Adv%|-s?Lmx zgcfJRUrl+|+8(79yUu&IpUO^nEhYUo;RTcVMByHyyf39?_WG2FFf;XzxXvCiDpo0K z$E&|@?K;VVTd1tL!&0yEO%Wp0l0W?#9gt)t$UDS)_6PyKpP|5n+_I(4<7iQ(<<0AJ zU(<1bT5G?j=Pd4m1_Uao_uNuxor&2Y~7!d<#yY6?(h-85D z6D~V3(biTW;!Xl^a*0XE@X`T%%t&||9az>Q@)pCD$s$33TT>p{Pm5Vv?d-U#Q6{T} zX7${BO{9tPOx0x2$j!N|%f_M8Ccx9iF2aT7yIkK0s}Y5(3H8~6qc*!bm?s8!-YiC3 z)pp-il?33+YzKCO1^lMWw_n<=+`a_mI!#mBcGk=eFrvHZINzY}$GQXx2eq;mhh})( z{Fl*kH3!?i*Ru$(#ZWIO$mnzf-~*({ZgrGu>xJj#wKJ zZ~&9+B(*Ag)n0$P9Jb%Z_<*Cv$l(!8;1g#;`&&`sBS;W=;|<5RSlT>ATSGNR!qZ8S zF$(J2*popmZRONWk0rbdcjj&sd|w8&AL(edkofI-)h+^b*oHuM4Zzy(*E)l-efV=* zqP0DoPLOCi*HBC%$%%P7>+=(>YG5l2wAvtvXlRy|Hf)T$$z0Y?MU~O=$pN-+R9DyX zgw(R#NZeR%r=ip{>1~F*GeFCXn!@;=L2+M#Ta|^gLf5D(f%m(cxEng2p4B>4dnIWe z$a|FQa|Q*v=5~hlEI0~MuUS0FQR^eM z`+isCU3)E`KHLsLJfXl+B}y&ff?B`bB4>{iQ0RuFcqg0wr{wvw{^Ngyox>^hP9B4P zyb%h>PDOrJ|J_?SgR4soW4hsFZaA1`z|+ynJqx{VCSev3A>e&$EK;_J7VQ9S9t8|) ztq&f`AH31U{TnNQnY1Q-P@s(-Y1aTaD2PD?p`3cI(x?cu!QTP^Fp`HFHA^ubMN%@y zfFgj2mIT!+WwX-!sxNeoL^_2zY9dF{4F_Gl$~f#o2_C(Es4e)@kke}6t8V@c3_B%l z&mQw~wNW^?+73J}o023}j{q$`+id>;|KvF;yu6nyU1x1Jgo0YJ09F&LwB^f=&5={6 zT$DU%*c%q(u+P?aib@VeG%8ZV=uvIRsh zt<(%J<;qShxiW!|w%96$BXMl=N!5|t`(Wqz3eH9Om*qra zlq!A|6UXMD@vk}_^F;#83ADPjh^97^vr~-_5OO7Prby+NeNn_`+ZJVoO&^tAc3YoV zj?g}Tze?_be^0vl@ar4gUUKOS20}#K=kmk8YI+}spc+a915ma3yaMmT$x9zeXp|9R z>2yYA;m9M~GH4@V^}+RLvO1Fr{*&u1h0DzsuV-JUP^#X~F?sLm4guI}6qdEzRDh3h z3I#F?vI}X{s+s|iU^_FN$`IT;=kfPI)MZdi=D|bGdY+6dq7;;hA5|n@RZ$z!TdQSJ z>r^TMUK^%MuIV1P;)Y4S@9=(hev9DK_s8lYz1f=8UDm#}SR=Xt4awTxS=9{$9=8xn zCo{7?I07rL2JqOSeGW-hJ#W6i-3IFwl&djVQN^S#oZ!*j?u}taE3Xth*dI1g`*0@- zZn|zwKP5RcAQckSGd!=nH~Ak(fXFLH~V>1=}tAs6W zEZw$~C)AiI>#jYfK@{mba?l>92l!WOjQ)*d*QqE2*UI~ecgHQ7NX_^1&qr6(3jmoUg5j(}!lJeOxaO2p?RpXOvy7nJZM~=oY+5!CP4yWi()_ zJO@&2$+^n2R}%qoY<0)8S}Yw<4G)`>qomZ_Qb!Hv2-?!7EkR|`i;amu)B2->1PMi( zb~|YaVK_kvxx_P&JREnr&^^te<_8LA`5|8fmC20m^AefFZO(K{{TOQ}2`Z2~H4tg5 z_zPD6!n=>XMW}u3?`onwekck+i~-2_1g(!(DeBYRckb5L+8Zas zgSy)9>c}Mzgbv;V*ykHOX8r;5@+Dc`rzPrki)jcvpHO5M_X_yzw?!pOEuDL?5N3gh z*o8iy78B8ZR-R6XGdfhQ_l-S)+z0Q+KSnKDI2)Gmy~%M)jFe7LRA*pw4lcd<1MC>k z`PL8Nko?g=@FNo=X~=BYFn`w+%)$<$$&O_|#G+TGA(NU2VloJAizD0k>B`Zttu4;i zTni5SYHHv6nq8CG)y|PlFKt8eo`cI|B?3(eyWASYeh;tda?BtJv|MO_r;%<+kHsb{ zd1rz%;o5Hm2rVTj0>{m6>Fq3Ev^G5+8dk_YouBHjHQu#*zb<^MYc2N$A@c3>SBOKp zC@^yihN1NfR4D%4pZ?ZdlOTO~ZjESF6Zli~cz-)H|ARXK=*WjgsIw!L4dPOj@_-<* zX;MiIi*%D)zzPT!(gE;uo0LjoG|H|PXt zxg!-!z_>e`jJV{{0!n|j@BozGQr&Z&hjQ8fRvYyYGk&2W;Ate_6ijz z+HMUoRye>%EtXHS8?thZs|^#wN`c&1oh+iQQ36m)IzX5>@r&HoPnA8zRsS?ZSu(#PlRE9!&Nr>&Aphpr&iyA6U zu0T;^+VfdTDqmkZ6y4-Cr~cVuvwD@Ub&s|e;PRB_+(fo`!rMwVRK+$AV4hC*NRoNvuO?>~o)W zZLELYN^q3Yf%kH;i5+%x*|y6_ElRlm$b(>C$-0i<>`_XE-Mz*$e#DKMTg0Mx$A&<=3qR(19L*I=Ehy`2NdTu~xD-d>g;;*y~<^ z_imla?hWM$3vqztU_8yI6zj!Q)}%T;ANSMg+3n`bo#)YLO5F;#gn54SeYdLauA(oL zT`@2y70LJUW^!B_Wi%U5YS_OTxBYpdU&Q_oosO-*kMhTGP8$O8^}Q*K`}IQkZvmF7 z7H`zT_`&(wMe(3cUWdKv$ACrS@Qngb0E;34ENF3fT!+*?{}6Wp=m$ffre!ifAvh29 zcIu_YCMvm1VK+#S)godbj?zq%G;ErmCC#+s%y}bsIF}a|6|hd^(Q3ED!->xgQv|_u zNV&QO98AGjmj^#1TN-_H4(qtI*sRvkupXkCgtr%b0C3PBm}~*4juU?wM5o)Ie60u$ zk0>iL63Pb9mP!r$kuQOw?`R);Hj2`L%G~%&>zfA41MVU73@1#Dr?S7>2uHoVYT9yi;%Bf4Y7x1!&6Hc;)WiublmE z-*%O{15igcq7>@X?V7)BC=-sN;%K5pYF6n*I-FE{f&H$+w#M;NT>c?GOY7QTVZ9IN zaJgxe`$f&sx7b)k*R;AM#_8(Sa;1*T-ui*qAe9h!5B|NVVz;prmOVfAfGhp9PwHiE zXbzvV!W+%QUh>*Z_Wqhswy~J04yuTRbSlziKmM$B^xDG`=#rWBVQpS#gf)U3E?YE> zw?EF*NJ~nphA)QJ1VvB|5gy;swO)s{Iy$F9jgb3ri!YdX(O`@rNeRVou9*{aHij0a z(-?IIgfU+2WL)C6qD)j6!q`o0O{_CN*~F=K*BRb9ZiiR_c2wK$e$a0(mMMVJ-15p| zkFPQWgDyT0-$M%H{xX3hzzc1m){OXi#UamSnbSO$!}>uW;l!w5)#=FJi-c)n^3&$D zYX8&Yd8L-!G%V1upluXxjC8&I?HuyaLiYCd7B}WWhd_FXko7{LS>vbir)(g&OW`=k zrV{l!DmTZXIz@)sK8vV93)y>XeynV4vRtiD?C9)R_Gs%uqn$~N?XYTUlnLgUG)s## zSdZ|pcPz*9eg~mXaw%0l7g~z+Js^#psVK*3S()r zoS#3#m}p9jxUiJ@&X2gb{?GE+A%JS><&QS}i-h zFH|WpSYjA6fu(gOa3&izBHZ9n0kg;NLyu&>RdZ8wz>}9ZSiHhjkl|2KilWThmv3<| z=1&xG17E|e*vJa{8nO9D0k$wj1j-XyOltN&uw2P6AP&Fcf#H!L+8X`C(RbQd)sM+X z?_hWKr%J6p;?#@a@_9-vPQm1DW5v-Qryu@mbQ&uIm`I5hZ}g!sP&r7oG%r4%Z_|)z zlA;^7Adk3CTN zoszyly-wp_0p=Ku$>ed}MF^`+w{TZt&-RscVMy(mG-!I+j~;geQ)ZJ)cJrT30N-lo z6M|`hRNp+fRrl-f9GLInP$g32gFrw0o=lFpUwq3?X6H5YmxF}+e!Y^CbwTtsi%6Ig z7ECY^XCRP+39&1!^bw*d!$9Db(#LY|3p`hN8YBtVn{7Tfw|N{VLO4uVeY{P_&)3td zI&M#CK$=T;w=sNLJZ{OW+4tJwxiWeCCL3UK{V31htTc3K!+gj4Vb~vW4*8pIoMTZ% zj$}?SdI2#!$uX^mEVcNMd!sU>1V7vd2-2@mvEC?Rybx+0&P0zmc_$|}^Ma{uA9=wg z+eGA)RFH1P#-KPdfc z#}`;WhVis&r~-ozio0+C=a7;W%Px%h}P0U3J{%w=XN{Lq_L zkUj^56XxzY$G6ehGTnqT;Q)dieE0GmKW}G7NH9`}vK+DQY>XMX5~!ZnWK4d(6Gg zJ}kjWsbN$N49-KdDdf6_7r2IgW6)G3+0-k?7yCUpMb>d`X=IU5-i-vF{0V8U*gi~Z-`%RL7|cC2^T(T^ayBTGXR7&Z*1uh}=6Z&0o71(fI`#JRY| zh3^r2H;nPlMKH-a61zGLAlRmh;ZUumO5rO=UqQW9qum^iNjcuW17C`c-~=cK)r1y# zR0noLM$$t|H4mc=jz*2Cl3c*R#rzsPaPJ{y8YLz|y_RP@6SD1zQQ|?0k8JB* z3RJEw8DuBkN#o&96%@oTE@cCO;Ed$xMhru@S@_@E;X}e$(+duDT*^@D8z73*AA?hhKja=>h6{#uyE-BT9D_3WGVZNbD<*kGda){8 z$*8pmWc1zyInO(9)X(D$q--$MZP(-MJ6>^fwT4Gpm*>MGz-;(-F%~$Vt_Nv-!x_w- z-tVcv^(cipV)dz3U|!;+RmAHb&5Z(IgU+VJ_m)by9QGE%cyA8u!chgM>h)i`rR>aq zIw)&>LaxJbXXlx0NlU#06#rX7J2{y`U6(nJorsEg;ka8u+p9F1?1-u6U;v{0h`Ufz z^R!m#n`DCrhksyP*u@)J7KTUa6;_WAj`Ot-Mfa5c?Y)(Qq#*ZhI%59w;>Pni69qtsjDPLXOqp)L@)X+AuQYs zMT-{n5fiy=TGqR8M15IqyPiaO6WgARN<6nOK~D5RBfZu{`K>R`9plIFZN3a583d>) z=1TFRWOQ$apQ@L|xZoJ+b$ibdm508t07K3|6``I$V!{&H!~}oxfwLyh)iWOce1%Ob zh=hw&)tM}%1FIH1i@g2*XNtCcxv}FM+aPC)wB+p|+LW@=_nk zLx6F)e&}p2w|l=wqxFc&=R)wu_Gj;l38tgKrB~%v<0UZo`JIJOyz2*UhU@ zFAF;$KlP4irw$4Y5I%|GMAoQJk`aXy$WclylF6hERD9I=sxx}!jnPr~ih^(W@(tb*j-r|K57mn$%wiHLLw;WCLYeug(W^+Z>_Vuv1JOe zj`K$O;}!iQS1a(pfP{nsPnxioLTjqvY|No<4JO2hZ%2I-B`GKItkTbVa|#vz&PMpd z0Em&kUxa^@$z){USkMyL0zu_15qOSlMRblO53ToGU*`-#;tBLC+?ImCOjRLLXc$j7 zgR?hLkn6B0)%lXG>vbt;b0%N_)qN^7f$iN%Iz2Z{KG>@H`qU5Rb)##UG!OM=n5 zW3J7Wi%8T`Z8XbMz2*El>mKXEtXc5DTqAn|58(z-;e@&39GV%@l9;`xK9V0lvTJ%M zMOx@j8NCom6Ml_}0s%8ioTzPYBb}y=I2G|o*z)FG?>UR8iXi#Zknh4Gp)6>I+>x+h$_yIBc8_S`suB`((S8*xr9W6$?@rmJU1-fF^t;QE>TkGdGli z{e{Cj{89r)vpXV50nqTC2&BN>mE7*@xFxl3Dml+*@}GH`4RXXlx~=hr*Y>BmgQ}tp z_6S${amU%uf>qKci<{M7S_Jz*)iOTlkUd*-vh_7;y>3OUTGJcFt?b*ORfamftop)` zv(Z3zeX53pO0w2NG|Aw~+^1a;bilA5RT&4NBQ_d4rE;~h(y z6pS)#i-*Rt=M-sg#StBEL#sufmDW`C_Ct$Fn)}a&s<$PQ|E)QI-`7U);{)E04`id; zPzwY7)1Xa|N<>XD*LBP!#w`}MMJuY==M{N9dfCJnoAaBJ1smlE^%am?(QV`^1EMEp zCvMj}NdtYT8*OdGUe`!WxSNuGW!_C6B#4l(&2XlI#$qeLv{hy1)U~VYoeK8I0N1u~ zhQ5*Cnt?Exg~V=sv61$n<{e@tw6$b-@o;7TR#GG%C{pXZ-a3xomDFZcBRqQ+KI_32 z5uyeMgeY1f{QIN-Q9If2A=zm1tJEpm^U2LJQ2td+{r$H7v5UI_-fqlqK@j$`%DVxf zjvxQexBO9o)ziV&OOKzdf0aLyIVu}p=9#+vPYnF=;!kivKs8Y7mQouH#EL`u7&Iss zJNxdarFG6T7VUaSE%5z$nA^}nT!V>m===O;-?bVk)NI6qnf-ptAF*wRsBH>uq>^X} z!AN>2k}(D$=p3&xsQDAh{3XU@pb)}BnmF`+mbmJbJgK-`HvbicUXVxXB>}>S_@mj~ z$kW0TPay(s`Tq^l4?Y(kzlue5#8S~G*zc})hu8yyZe+G#Oe&Eirn|F361JZwQN)#$ z;;(dk(FUVCBuLnQ;h=wz<1hZ`?(WV$Dt~r8DzKu{ZXn?M8vg7iG7{XWH+ed>aR_E18H4_(~c>SOroqj0ac_@`1`|45zy0npY06F=E~vqhoh0lfaBzx5eEaK!iSC3VBngSCZg2N%hL6erZKRD`iCqU+B z^rkw1M#aXaGI-e)mC9ikdv} zU|>q%r~P7{DF~|Ar3PDhetvJY5(1`M{Sy406UDi~!4SC!^4|6H(IZ*aGVQop{T$n* zFQ{Phznv*j|J2u^?u$kMQ_2#24r9y8%2R&gK?sO68<0NsCan# z#+hmuJPIkPHGz!-@f5_RoJeeAY4!E~yh&C`*RbQ{*^!ZhIFp;NzXSwKwAT|<5K-tf z*TZrO1R|z%+ZX-0SG!>EMYOlFwwlVjun~^`co~0KBh`C8G=kwC=c@+I86C`LF{ncG z^G$~eEL*fgU6F;AfFcAr4+d<~2s!k!?dR()g_VHEl8AMdIhzzQQM$(TP^xB2K z(NlqeI2uV9MBEO-{*6S$ZGv*xhAv2O)zwHE7oCBK&;3nzoCxJQjKu>`$>zk6%gR?| z3HQfrB=A!=^sFN+xxB`t>|+o+lhd-qf%(MKL(7&P8(|pTW#c^q5L6PUcY&e*b7neX zgopqxTFQ}_J}FNKCjW?Cp!ALGaIva)%Z7wVyu)wMeFhb7-e}ZS;yR8$8p`+TFmnQ( zk~B$F_V2{DoAIIxilhs!f(%OjOUR(KK_DrrmQiH%fMGvv)5wCI>wk; zu4J6My`LS1!$o(0R;)Hou-PNFU9_Pj_MEI_Tl<~lu`@jXTsIpg2vPa2p|ubEiM=I@ zv0x%&4wFL;u7LaS%MBOHgJHf{+`B!s5lhXwzrOMsww^{#%)UC9*y)(jeDEKo@qhop zZw^;4jT02al4X}yORORD&$#_#asG&HCqMqZC3A)4$<1zrcC6FsBIR$~4R#fDd3jK4 zjBnB8U*0oUq-u_>5nnkRDlU`K{FVWKPyI`?P)o$&;O!{(lh`=(Gmb`b3cfxxGh6Qv7;rvi-vC#_Req*8oDO-o z8=tXS+&z5Hq^QBHd}A?NDDDUShJYGdw3&IVT}Xf#bf(0Kjgwkxtx<7x7MjHPg0s%_Z;xAS8X7_8VX zz|iSjz3u2@#$W<;efgkoA{Wj@D%AjA`_^etCC{iB1~)-%pU(yfP?>2~f!!jQLeP3E zjWaj#Mga?p?~Jx2Y3wf1#=~h;hB&Ow^xrvM(cWTl4i%3W-Sz2N4K{xMAbCY;FZ#a% zpv^;;V0v9lLfc3|cwt5iqm>#MmKzr;SBdyegVi&w9$e4OI^yBapLpD^6iyoK?13xF z%%`(ny(({uCKg4|;P#?@i_Z`|lFB0qTql^9=?ASa$~!8*Civy&)|ya()_kRXkZ{~} zf`{0?*F)GSVlHqk)&wG7X(nPqi)3DdR?yL#3-(&^ixyu!+c-|V((0Do(l$7WzwOGD4+y9OjKYqLFnFoEjXQ!Rc z>^boX*^xdssmfS!6mL2Esl3=_?PjOIW=(M7$ps!`!pm}cB1ciwe5M-i#`ug>ZQ%_L zXRHn$tJK1G#|_TqdQU&WG979DY`6dJh1W>{WzS+Q-aeAfQto6Wf zyZV}j!dHF>WY}!&6zBCyM6Jhe%-awGxf}&y3j)>-cV~) z#?~Ir$pBVixsB9)Qag{o%2`HE2!)hz7<~5r%Bue;(qg^C`1j(^9Ed`lyH*7Y>buQM zR5yFVunpdy_GWk~06fBcy-eR~>xVM?a2kQEZ&tLla%RzD;|$}L44^cbbWUj>A0MUT z%s{m%0TUF?P5lr_U-OYPJ~~LLI@+{ubHj;PGleEYstnIZQUJ?{QSrKdiN5uVY-MF- z%I8ZowR)x8WubBbaQ!9}VU9`(mi=0Xa4B)RE8k+w*>=BB{y9*aafhE18T0?}Mq&i* zDVf2S3Igs3mB$Bb%hxNIw1B5(q_T%ZJf+!at=AL8t?{LKMQnjW1LH%bHSR;p8lpy% zHP1t>z8_D<(cgLVA0YzmGZjRvH6u@LLnW7DU18OWkTkbrAaA%|YlVi5Dg;}6%a?wi z!R;WC{E<5>dm`Ft_1hQ8m7LjC_mD^9q9_#_{IwSBCwC8zocuaj!KG;QBHm1Zog!~) z`r-0mCJbXJjhHB~2E7`t4n4d4#HKSe;M!2FT%hF~lXlDnL!Cg(^4=~6iXWHzo^+wc zEu#KAbvj?fb17kNG;Ji^@Y&}Zm+iZ7B0K-9*EmxLwJessL z2kigKfi%LQ29*V?vpy$fGaM8W!jhq&W!cg4@b!QH@cL1>?2!_(l1u>?{kTk(;p77e zJMb;{UGX?k2nrbP><-^cPP}%=TX2J#ps>cRI|LKeB7DM9TfRYo6eQi?c*xmZDK}PV z1pnFuv{9i#69#=#@pSGX{N^SRIA~Q%88!D2qN^NSMMZ%Dasc6W61U8;=NQGoJ_ z@Ie%59%DEqBxD4r2{~B%Sd%$HL|gjsDAn-$C}5JATBKH}Bc8HwEP+7|FRwsI-Q}Pk$Ox$nIjP4>!h(>@*iJp-Nvvb`iuo3){Kf){A+MQ|K zf1cKV6+;r<3h(Xw`_;+Uj-cQ7#v|!a`x8W(?S$jz(1;&oXEFZmGqI78Fj??J(# z!P!QGe9@Kp01hp)5E2rB3VJ2o(}UGp{zM4>{!4=wYH!$o>@k&t@yL)o+|GMO+}>vy zTo>s#n*3C9A6Ie_MIPSaROkD769B=W|t0jkWi!jZuIw6W4z_xPCJ4U z*f!;Npi!ji71WuQUz6ETv27kw1nE0M+zIP0=*36GE;_m5aM_rUc*2tL67(Ko)@7EY zp=EW>H|bOwg*a)Ppdquj(R6F=W_bJU>B~*xmIydijl*--@|f4{os&XZsp<0UQm3HIQy4d_^?Q7S@S%7$$OY7%|id|z1G-kfES{jt|n zkWt9UXd_<9vHY$Ck$Z{6X7e{0E(Mxhhi(2V%D$JqE8p+f(|c)E)vaA{1hjEqy{SSn z2+kIHC*T4`8rl28xc4EHz)QhOdu@(9yaRUNE1rqfk5og*qC4?-Ae>fS)&EW*j;JA) z66N(bM;K>kV%xrpy~seV_bCQe3C&vx&w+;rSs* z0=+$TNCKm`8&IJpmTWg=-Uu%KR$sL}NVVZ53_}Q-Yy0CCik~WW*-KgruBKg1;>1+h z^_MI%;NKd}m{{YU|Fi=h3-~p%Wt(0)OY`Yw7T4Gib%1$7EHvHT)UVB@&#H30fAuuL z-(9+fQt_xxg?tHVy-x2jLoG;M$CVzvi=mw@rti9QXm$a->0>*(o<_%HA44M&zRQBO zCt4IS??An2I5_o>{WB4h3A!HA`Z!{%rwuHv^YJbRz=>0RQ`Wls-3Wi6&~qE~9H%yJ zPz&5Q{?w(XkAZ_R@2};^J?ccYB%4CK7rT%VBbbpymsli9ey@z)C$?#BkVlr4(Xm*{ zWdN5ael!G#x#M`i-N3a46(ipK`ftOJe=Xs%f9L+5oebQ52t3Er=!#Al(Cofz@O*I1 zGRC=kA@H++Tw_wU^`2-QykTddUscj@j;YYMH8}NkrE{uTUGesAm8KfFaMMktm-T== z^`L%Wf3H4<@*)zYPY%k}eqRDG9QuaM*6~5LREzl`O`Ve0IG-xD0%n61)<1k!65|jJ9B|j7+emh)wt}Fgk!RVL&gpo&UZn0 z=L-(|KkMFl$r?Ov21 zKtvJjt_-B;1|e8RP3BKxeZ;uSZx<7xRqI^J@}S5%TxET7M%poo1mt~mM2yj;tFTUu z7ZrynyCVOs!0TW6(57)J08$y#p5pn~&eaTP1ZSUc93F|;vemEAq>_Ew%R?6OI_lwy&UqPfiB*4L_=xUE5%q0RQUf3M?tzj< z{TA;nE_dYikS_n-)nOj^1pIL{?Q*?A=ljpSnj)nNcb5u+`AQ=yLV{2H{4&ToWYQif zzP`Ts7CSPVI|;&t-lVW2ox^z&ITBNV<0j}Or}IhV!S&v_-?lBj$oG3jtD9rK+elUI zgSpI?-(7ZBanG*FVm_iP)#V+ z{6ndv>znm0epuc_S66T}W}@R+z89u3^<=Fkk=61wZ0`9vZ0GuJA|r>Fgq-K8;Qm&O_V!Sf3KrC2W17lszw`K$7kpVRdyu5p%~mu*C&fsv6>|H}Ju zR4ruYia(w6c1FkJPkcD`rG#ew@X9MDT^Kv%veQYHN`I?BSRx(T<=_N;4Kyv{Y}v@A z%yZvIQVrIx8@5WBcq*WnfgiRYle?k&9WJ+o-S8Pb?|BNvVOC(lSI8nsn&K*gKZn@z zR}N-!{j9#m_;Em0D)JefAZ~+ejRnk z^E3T?Y{;boB2ga6)z@Re0N7+*QA1I&7N>`vEEq=Ya&BG0uGbUM{q8guuoB51i{hiz zJZGxXFsj5}US4LK#gf^D|8%psv`c?7hbAhv^js9hpMt`n_(aE~QU5ABwounE8Ew(@ zU8>z<8v!63#`dCAttRzU*`UjpYqyE4dH6#eTG%jXHQM;HT0VvuWS7=m$HoFDo{7P? zA0GP~fwFzD+Vk@Pur+xBQc?9v^9cHu^3u0cXZOIyXvpKRG4HprD!JFKB&ukK-ET!{QQ=Iz;ec)x z%D#EOq8(xcb;+;mI-O@r{7W^PJAM0}YJ3`P?|K6a5oxPYR~UyG@Gi;R6Cy~2Z1tH9 z2V8Ce^Wq1Qkp(Apg6SBm7!_-6^bRZ3lpDSg5Tm$cNn2vw?P169d3z0~kitf|{y}pK z@UI~<^dlp+p+{;gOCmVCH5pVlljsIA2m5(M#dN_ zIyxESNBswO9N#x5~EO&c@nzS!P|kEiV%ArnDV4E%MZq$q{SEeAUz2 zblBJcd32ej3XR3K<p>6?hq8Ft{> z<5a`qauX!!xCQ*eD@iQg+vBRzfX819ykQqOYv`44PuycPkF3zAvi2zq4$#yF4 zOz4_!KMmjEceO%QhkZ;e@;u9y^4k0~7!fDt7fn{{>A!k<(b3JvNf)Heg0#NHXu6I^ zrIkHRYjaYt(o?+aDr<^c$U$*KiY+!soBAW(*NPwLv$Xa z79)$NnP{ zD0K&qJ^H|1Q*=1;z;anvOte}Jh3^+)8;u0wxr)|>`+yeIh9gU|S3CcaaFbTj#~{61 z!W`&0IA~-L4zMDT2rR`sG33iXz2C@87AG8*Z{JkAItJwfvQ=Gl`^RAHLaXY-2^Dnv zu)kJRhM+$y+Fi}K)T=TsHKf~5^A3OWvMNAG4bf@@9_jczR`&CZa~+J}xI>N#Px7B< zQ~%`Uyl`8*VxeIQ&$It(Rt#*;n!HerxcoXPuKg5vKaT7Xc4z;^nL-Ay8{aXTlecq) z@LTe;@Rskdo^3X{(7{;0+ZyAcZ}IHEo;u!MM_vL`QOV1h@}H-8L2tkRwX6TDNg*#y zz{#0n%|uEEMF@h9Wfo2F(WQAY4$r_QezcKd&vjsb?=sGc5y7Mz`lyQo_&nZ3;^Bz$J+W*+>x>ieqxx zH6t%YdsYNiEXF!aXF9xOQJX8MPM#_R%CS~dK@6l~SH?lr>D$RaE{h1fO_ z!5P;R#7B4Xf8l$}N5iksN10N!ih2sawf)4xUK`~G-+(8FTm(z6)!;mVr~dfrzJf6b zxsoFlXEVq9%ZMHiGwh2mBqJOptN`)hEw#!oNg!#R&kZO{ZvewwBn+J{2R4x|ulc3f z5kF5pe?<0T(f*4;ze5U(2`ezm+`sj~Z8V)LYRuv-|8C7&=kfX3-m{O~6^ni~Xxz?u z7whvLcmKUcgI5fIw3a59$!HGg;CLkp44b^3xXZO%elYb$tVG+cBa3em?;iP4>W z&(A#C>X!YZhqV#&Y1Y597?ny@waK0a4}Wlij-hT!3486zGrcE*t2oYWZgu8uUMuY@!JFotxuNPrFrHZs) zLpJ0Md`_&XK*D8%>8xL35%q8rZhur&&L>QgJD&y-#8#KE{g8nPW0H{msGNg_c)m#2 zYL3c(Rp0M5Il&8x*zhp%%boVS;@X)`f)pE|w2*!hRG$xSHDX6nch6uF84FV*=P%jL zh`kqJ54f=zOTjM!qD=mbP;H4U*PmLTSZ=$FrLdTdrDVEpS+UOy2B=G-GoZ1R;v(RE z)OSw0c1Qqonl+#Fd491EHXqX1?w|fWB;r%{F`i0x0*}Y^=3pw0Plxwtwva8JMlQy4 zy?ioJ$4lJX*6=2S$8Gu*?CJ1s-Mf%df$ZAOWxrVUQs3cF5Zd$Q55~=UJun^pe5nq5 zwp<}dKR5TFVV!M~ZQbK*@`6g;e9eS>-O(&*<)R1z`|XfPro6AmLor)}QmYWm$GW;d zz*i;8QpjvTkgjte!t`W>S5?F^!z<<4H9j-v#Q1lyy~*%^XItl)FtxImkURV5#ci3| zf@Z`yqc;^D)@!(2+ih~$%r(!()$gd|#x-mg0|HXfPN$1fMrt1fektb1*aUgoAV>Z} zz+dD?H*m7g;C8)|tqwwc=jW2kNq6rA*nHy|H4B zH-SM89Eaewecmufc%4|nuRr7gpwa;my=6ljL<#Xg5R1W**qeLg5F7mw*CWtY(6}s9FBqRNa2i>@-%#9iz zN2*GgbVifobV3a|v)%7&UeCwLqN!X}(?-eV2J6HuEsmO}<+;+RMAB`BY|-z3?ziK3%$=}C z=r~eeSA_l?&nC9t?+;3Q;Y8zfHP}^l`W5Zj?~ek5R=yq_)}J;Ra`U{pn7jETdOX=I zGCi)dtxuzrWoD_h%|DoY{CT?@?}g5cy;eWAZ87 z>rYlxTZOdlb~nnWTKn5nDT2F+rh_*jyIqlbS<({af$1jPO2)7uSTxEfi*>0J3uGHe z3ilJHnG`$S!o%0HT5d-}0Hk15kVTeGY zBhtmWXka7Ina3VQ~MHsgsz3d`+U~_x;@VzKr0El{@n@%7URS8 z_wyA_=vF-%eL})WV`1+`7f@VzIl2%KBp`5;<^B2^)V|BIXPdkEbl#DxPbx842*Cfa zS)b|j#o(ZnBSQC#rhlfYWHNrTj!>&pd|oWftR5MlN@01zUFzx*#r~ozfu!-&-ujGn z)Nf~PoP6G{BL$02V+b2zPNQC%-LU2n)`uJ(v5PXgmA!M}rJ7WQiuYR@rh51^mk$Y& zP8&{dlQ@92W_M_=48Xph1caa*e9&ChqKR)DQtKVk6Bjsf9Fm+)7P`t=jTXv^v08qgbTJg` zbiDpfq1DyBv`v6O?gbHWw56&4>=##6TTLFtr|n01sdmPk)1cGhwo2xKRMNevIlRzc z(+L(X_-Sz$F2gnkUU_RX0BZT3)cT{gK1KOI1{m{C3cdmd%vi0(R;<+?PqJ z6589yEtM+_nd$6~f|0s?39YT_5QkgI1sqqd}(p5G+Vlq5vz#eP$^`607n1d19C$^kwKxfVzj}} zQtcPom*!U$wW>Z0iNVl7u>GIE000|!W|&R4E#gw+C8D<79Vd`i2uHwU7X$I-Ank`) z`awEO7Rger-}mvh>(8dW@9$y*as)s1==SY;U?vT@uj$lWy`{z?XkX&vB^5oAcqkyn zfY8fIMDBw~B$qB$ha3QIe?C-P%rQafB0|M1@=l-2>+xt}0iyn_2%xl`2CMs?Tf?pB z|ArSy`RViLPU^W6!%Y(9Qt?ja7H{RH8utd9u}nU`sD1Vq0P(Q{0hv>4gUkJDie3N^ z7ewD{{Z3y3HNv6?;;~|AjhxokrQ;h=sGosJL|TH#m0v?^LhwOn*bUoWMc(w5dti!O z{cT+@z6<@^CiI_d!Cz0i##A5dmG9wnuGO&Ob|@C`+zZp-Gdih}T@x2LHTy10O$He8 zd!!~chGT+`$8)>f{cxBGAERnbyCfdnByhrkg<>gHNZcLKMG!%U6&ef3;lyxXm~o+^ z3h8*g=F?EN{4g-8wU~_wj21Z&vFoF&J~2*AUBl_E72ht{TJrNLjow7wqQ=G^;TZr= zhXA$bvFhChAVqLpDuiTk*u3F^;Pjj}7avab*|7LJXr|9EQOIm~F2Uu%=r|5+GPPHf zW67rH=7B!@ro|MX6`HBHy1)!v9Ech8hl7xE+TX&B8H6SL97#Nkg7=<8p{WNf3vu9v z!a5AEjrXddVVkcPjmQqW*pcq?jbd_Gz(m|~zHff*-#RR_oNQO33o|UfE-&UlJ|k1Q zv4s&qz@inv;;^H=JiR`h7mGHWeX*Su;>7UK7W(+xm)CN`t1})tflXq~Gf}yQ6z=Hy zI4*fLX}oH7f-Nq2Ad+!_319W3vzdgBSEM|oqTf zAcJ~jL-%u~n*?eYUJT7Cag^h8vP-fyG^)*07pDEQh``~a)Hd6)XsY&__Ri@pt>>5J z#A|NWa1<>j2o&x3B!dFO7&L3EKE9^YT(1jYR7~YTOd4vfHDY|ZEG35;OVod_I{)@X ztn7oY7;|iG>T(eI1;REmTDWd(k8u#NFOaqO2WV#m2ZHFEdGsZq&I0?+oW8K?V-e*H zo1mHE*h2QALus`wbPx@AD0sS{9Q|nhlO7`BqkvfuSTG`x3rbbu&m+BV!TCaP!?t`l z%ZZ;ckyslkXz*lHg1yr!NG*ks9Xy+xji5))wVGt&^d46u5s)@>xo z!OWYOBf@L$&zfzKW_;VaHBcpB>bJ9hn#MgVUGze2S+grum4OE8zaT@7KZMW%68hn#!G{2cP%h%IK7W2N1~$MTA>lAM4j87$1lndzj(J-q@O^FVyum4~WGT2GoWKecf@35> z;1+1JAneNVJAfPfp!6Xm3~LP&`eTaF)8I&8{%5|8Ow@!PB$7`{1DNdfrcaRhwOja@ z#ZzYw`GCCoFHb@zk3eTwnFLQ;YwxgPsn4$SJkONlX&!LW%)~wx6q)-XdT4}Y+2LX|2VrHNxprwAJI$WCI!j*a*Fn^iEZXU! ze~YU?0sIQ%QbPfXq1)^8b%hyb@yELIo^{;~ANXlD@bb>cblu;=REMvr>FH&zHv7nQ z^Q}rB56>X$cu9XUfy$D|CtT#TkD-py5d!MS#<{3ual}Vq!_IQE%`Ow6!;MC)D_*}3 z-9ZGVBo4lJLJL2OT?H#FFm+k{W{RdqeGRq{do-MDt)aP$PwVMSpFq3QpDR`y$9)|l z_{&*`o8|ryW2Xs0RKxi{hlE5%pIi0v)V#UHu3H{f&Mc7vO{?h)pVJq5W#ZB#k!urh zUUF_8c-WfSbD#Pw@EEe6cD5vkk1?LCBsW-4_d+yqUsaK&1moa>(P3cpg9(Ex!$Dz@ zo+u6X_?AJSaqpu7R0)hfji3$<=!^_Ta!-G@&M&RE7WKn5j_LedbjJG@94yxCu=~qZ z-0?q>X`TV7O}=lRSSfgl%mD6eIVB)(I>TJ7fMuenj!lR0v3;TRZIS9EBF<~2PLfE4 z2}{T$5nzOCcL*sDg@8$n!)&gKcYEM2IQ-$IS2MbSVqBkk&QCIW)W<9mnv3)80$gXs z)I}xc_prMNtqAt>Z)FbFPc(|l9WBofEJ$vrkPtephK)*_&A<-+?@q@w3rMM9VYgUw zyU-E`>Cf`_HivSjq)OZL_(~M`3c7voGV$(reDIi`)Y%RZ5HS{NH9zT}(p;~rt7H)_ zA$>cWD80yYR#!4!U##lb}SBsl)0;M5$fj00fG zB>K|b9Jj_wBruI$>LH$V3{rxLN>A2~)c=9GV_ zpHHy*d+A>x2Dkb8ajyMekLw{qp9xqGE8q<|r@jlq_o4Z$dY~R0U%D&>*RWs^i_4Tp zfTR4k;R8^u^+1UtKi3yJHyT+2eo9~WXrGlAyRtL=T2}*|_wme>mKZqX%o#CY> z{^JxflkTh(F%n(=(#Wiq<6;XGa4F&004zITpHjU1F%Iz2eP5~6`}XS_ll6n0QSn+2 z;^Otj!**1C&bw8x=kZUmMKaT^z11QkPtvNq+LF?Aquf`}X<=d&W}e9OY+ z=iARvhxI{ARZju@!+ppq`ay>&c9}jidjIiw9AEb^AKt-et4j;oy^dxd1C{BKhwxgrH_ph^JP;ApbE ztZl8{a%s}X>t%-{AbOG6M0UAwS+?Fn&5Zxe>*hv5X6d@wXkhkSxIyu({afe4X}2s@ z^PNT1+3zAff5kXSFJRALN_*~`2^0wT8ciOUo2uXCyZj(bH+)`i0BNk4GUFFhdWP!{ zUY)v_HLY5o*SkKGtJjN>G!7-l=g9H|8QfI>({B88mbV>M>3tmR#~ zx?wsR9BMFcT2+^D4VOpemKt!O4JXEOJsG#z@6l(0_y-NnOw+Um1O_Uszk(h7E}r@A zy+M%DU=y9JR3G^#nLPF=Gnu?eL&dU@Gt#YWzw}AG&TPzNwIM9e9-n>iV6nYlW1<;>Dc zcxA4o}r5X(w*@}%Lux`Ht328yVz+K1ZTuQ8>O912nal4oRkMweVhJ9&| zoAe!U=o+uVt*H0J^a+Uxuavj8&oC8}LuZS|d!!CqCit>SvQ<_Tisy3vi7ptL0%#4n zT9XOwCCjH0&Eik9l}%k6bk@r4BV6ul9(oLB&+JRRu@wMxZwku7*6wyxjY^^53q&TD zD85Hy+PfmlWHc;9`~xu;FqQrU8`jz0*kn66nhj_)Ba>VS@p8SF{UzV@F%dE03J9AT zW;iti{bH2$A#Xo&qvV5ws_k3uq~8c$wjX35k5T%sds0|xpSRJr#+mnKr%)a4p&Jy$ z``I&vBmE#g%ARNNt-zPyL;EG5scIQhTPS* zvM2Za(_sx=F7WbrOV|i?%4hS_csxTY30ek+Ex~U0V_vK4)pWCtH)(C`5;%2y;WD?d zZmr2VZ^}^4?Ba&*_mP36izZFqCeypK)pyH?!{(MSD2D2R56`f!UK zdhUhS@;F=&98HxV(vxjS`QJbO_P*CcC*X#ecU|iaL`6mxx6JVH16Uy=_)I-znH)M*FbE4kIXJG*&xhS?z~%R)3oDXeFl)t+sWOHYm-&Y04K(b(9! zzQn8Kwf$0}8acLE^9u~%Zq#<^P~6;Z?03QUMI461XHs1g4dwy5p&gSsr01NLu-h)R zMiPLb0jZ7I1fj3Lf5@Df>khlZEN_X$d>Zy8vydq8BMSIb6D&HkOsmxtRIBzy^Gk}( zc6p=q%Q>Id3-*@SY03`@(1vWl-ytjH0^UIfW(M$`q?fzhZTf2|rxPLU_b1aHu8q>h zwJ~Bs@X5kG!}>n&bysWx^Y=!QM(y9z>lgnk$eX>eq!e=4b=e<+|@Wdbjz%sr8g$+xHX<;7^E*d|-h_)=&Y z5H|~a(m-yOu&t*7#_1x7cb;VVC!=caVyzPNciU?F%NZ9IpINBn!RQRY5I{|Ym4xHd zZi=~R^=b=(p@UegF)B3RZu&vW|N8uJAnp{qFfKT7gzuF_ot=F%d|WX@+D@Ipqrnjc&(i=tP5Dr@2mc zqfv(hq1Xko1cpaX7!O7KkY-A44UmO&vu6VC@ZE3ZkW)6QZ*XE zM=s~xo?T+FmPM;kf7PyM`C+7>$~rt6=5Vij?@59x$nU>9o5G|sxSMv{820K^Ob0tLgfIN>_Y)Uodg+V9@h^A> z&BFS&O087C^3z-xN$UnyZ8+ZVHD1Q?WnOX$Y2_}WK~&&gdwyUei}U{p&X%L{@KU8a zrH^c4gPT?t=%#ymGtr?T^tsEPBv{ot9GCnlig!s~2s7Aoe>}6W-P3KvR+G8as<7i4 z!Sm|SWG9r1X}b-i3Zh%;bgAy%e#_f|k|f}J)gS0mk?x)zGvGnCQ%Vb+SF<-B%Sz~w zRZ;%ebxwfZ_PVF4jmaFT;~YY(JIejnbw2({tZ5h80ilF1Lh3F!DoQFg1A{^{EyLT4 zrsV3LDYWo8Fm!4|rEs!W?!~1^+g#MjYVG?k=aN>oL}?H30{5Dh{={w#Y z3`s*n60LT-r#Yqz%YF>-GX!7wBvs7p{f3dDw%qxpe>RACAlL4+oSf>VNOXakIRp6a zap#H^ZAN{7`5Xckn~MTgCSeWhnv+oE!&z;Q-DU$kcAke+drw*-dD>h6i~|enM9>G{ zbwrB&o;ir!;rRHWB9@A#D0T{RnN0mlgLs^G695*kOXUf%6Es{4yEeF$7}%gUlny$b=T0$i_yw{iufs?SXM**{o#q3m zf}_o48sTOD5%=cUx?@DI)tHDlw+ITBRxFs(zXRB&iiIOWW9#w+mNDtl`gIsfrHZZD z8I1&AFgd>pHg|Wt=z3l*4oATZlvK{n&>zpu{D{CNr7z{t_4RXjeTG1m?sfr190})R zYiXDMJORXhuH5`pAx47pVrrUkz4>xlA2?TfEx$H)J$*<(PNdh-W30e4+PVWNwXi`z z7ZCGw`-4iT*S$k&H>w8o*)cF$jkWTf6Wb>Qq_L|ure#aLN*}zm&4FbZUu7j4e~#lb zMgX?zX(#7LP?CTVBy@2CyZ*}fu$uY{4lr2iJOIO7xvZYls*~PEM{q;X6$&|je+R$2 zYY(L2-s6hS@$b833^yT*(lC?;Ms90X;;~-*9~s&!Qi$dFbCl0~C?(6<&zo2y__BLZ zcmu}ov!Vp%(J%CJbsBpr^|mvwfRJB(Tmgn!^qcjvliaa_IxEYAt%)u$#?lT0dKVD_ zVybsYLD)!I%9FyM{`ngkb>&B|kTng9lpZG8mZe<*o`qU;-ozoP`yA^x2MzVhA@VB& znx@?^ng4Fm_;LvlknB`TYGQP>D5mtJZ$8wR$mM@T+u;y`V&;Gp>rK}ukz^kOZ-7ya zOM>YT#)&N?Z`XJjXGp!v+-|uNOCk|KwTZt-C1)ku6j1;&0A3CbaACx8MRbxrQ|n0! zpOHmm&*3wEu1a_JqQO`ZC#?v4=$4BAY=uwhw<=)8Ca}bJ9f8X8s!pJnC6Z{cwH9Cbu2VXp^1$%D+5PRbb%lbX&ea;gzy#%GOSYx~dxH}mc>f15WW+U!- z<^rlZBcO*oa8u^b;5srSA&=+YsE9yIW0APP1%+zJq8wmS^o zH7$vRy7>qu_x&|((IFZl5infQ5@|$t19$Q5df`D7_1U0@zgE01rr!eDe7P2iFWjZ- zn#T)^D<939OTLZxFIxB9*6kxly}d4XxS^Glf%kI1bCr1Gu)IJk1hu%VLvWV!i|Z7| z&0s99*=$U*#gbVtjtZStmdsk}SL;!iOzrgg-uy85c0A zu3Q;={gVk0g9QzqmkXyQmq2ApSYc7I@fGJfgH$n$29KqoF)>eaS^trMO>J6Y8coD>=`d=}yFT)#yZbt1E=s|*{uSuSAmm%2#pjU=l4;&QpjQYGgeup->>rwz#R_psDu1P#M`#tYC0Z>s*N zy=~Jb@KWi-?=2bjNtDc{q{|(I8oi5$+I)`2WBZgwgF+|as!W}mXdln5sQiY!xOwcd zWeMxtzlVm^HPjJK-*$!i?nPEt3;y z?=3oAG~4J#>)*4;{Ex&x?M9F>3~UNDq+BBzr;E>#QdnL|zyAglY577t+Eli{?*O2o z3&r5T{5Xp;1BjJ)S?xnu2(!nC(vpQ^Jbas zKtUb-K&0`j-Gntndiuh)L0^7QWZLxgLycV04m>tfFyA2l4i^&Rvv8%dqI=S>99j=T z;%~Q4BfLBHUb&DbqTyT$KuPe)7aZ&>|CglbtpVBM7}8#y2AeLyt*}t&>$gh`hW4Ec ze_E9FS?GO=8?YdR-Nzug)n>$L`1-UaCtQJT!CeWb{c^4D* zZ%3d=)5qcZ>=LSf2d-*B#Ge;UpFqn2XYNCZ06a50$QuUk1gfdv8hLu!7K6oht z67(&0B-?a_48E-X(H_S^#+f^yjctKCXMNXMoOAWU*MyrlygU0GSl-eE2)0 zF9`ESvjZbfryw8nO0`UJ(pyzLz-|Q`e8VGKN9X&?f`L9rWs>)`?uxC)8Zfot>$kfp z>t1+Z?s7>lJ2D0~9=>0US9g_U#_^xhUJrJ+o6ixM+-ko>TgX(N>NZ~qy6gr{RIc}= zV7Ac6riW+^!fB=1H`$hs&b@!G^CShQ&ZC`ne%!sL@AyOr)(x#Mmo$G)>@-3-T2W(b z-6pVRaSA5~enwv@Qp~EO=ix=6(wT_+v{rPzpR=lhxtD2IuceyfS2IMF)vEGK3QV*Y|Z;M)$BUn ztRnIaKYe(ye!H2eb+{BXGwH#{CfyS)mM2Y*+#bmE(wAdM43gz{0#RltoT3CUB?YIjsvcO?GR9m+J}ire(i_8-_p+uTK6eqna+X5x$)lkS&aRB$<1i zZG48ZWo{{Yc--!Cax|FsDELL0&tB2c-&o!E9A0gnquTF}%+)k-ovoBB`54YvHxS0J zff=Rv!>9NDcwNJGmEGYR);56xlXTFK#EbG^Q`O4=cyNy3UQaf8Rg=sH@1BHMpJS<; z^T^kN3T6bsu6d4FCLI2zfpqLsaj= zZ6zWtXi_qaW7i!(@xZi$)`$A-ECgrW`I-%kamYGrdlbv6MW(3@-=Jp~`eps(fbAAb ztKNAOFGozGi%T@JEwQ*mZa}4yA=2^Mytsn940*czHJ@6s+2GXd>%Y3?Hs_yw4m}JF zg>r^2%0&EIZju}}xxN$!pouUHwJY(~AgtK-kD;r2v6FcuZtuaI`H+)$g#zA6I*K7m zgFO(G#wk_uguNyWOG5AsGI-L9pRPr0QR+MI`v{`N3;_X|g)ohT*`E zO4H+*K%j0&NewC^XJ^|>?tGcg#{2c=U`tR`J31YQ9Nd{FNF6Olx%XIsCr3tJy?9;V z9fJ_*bxO0F7KzfEr*q_fqL%gVcd#rC6*rd-M&8`D4-6a+z1MPlTCjdJ6Ml6*8FnbrHnj9M3TnqsXi)! zU!M%K^|aJ;Rd(u|M3#oM+05_zOys~Xm-)pg1;_dM2b*85?H3p26+Or>L_ug$wfl&O zPR+&=@D918Il=?fB|l2twH4Q$a*$`DO>Z@yb7O(SA7&Mc=pMYpxb3Qz=|AB?T?N{H z$dD~DfpO_cUc^8Dl>_!AxEXnLyaAG|pMASYT+;lTjf&Y7$ndK^`|?qH@L%dh+Q#p5 zDO|`2i(`U8p`{Kw^D@xU*ap$BZIefa$<)W=pJ__&7@dFlTS*X`0B10(Q->ibtU#iV zA$H*(6qDwyk%Gn?4?s>68oS!3qB=9n$S>zDb-RzdNolpw z#r%3}=OWLK|los4gbZhzj{Q#!Q!(g z6-+^fXe7=iY3p|dL?;8^Lp&Pt-q*1~qL|PJQj-^(zMxEcjgTaP!ntw{^4BN^N1QJn zjjH-z%!dUEn;nS2(*-qxn#!Oil^d*nM|;Y+-<%QaIH`srqLUk6)BxC-$*+#b-|}uD z?2V@&oO%E+iloElQ)mF8s<$k^Yr|_Lqok$vYn^_m5FXA6EYV~#v&kX1Xtk(%OC*>{ zRi(ox-x?RVPwW!?lgR@NLQAz?AV~(ldJK*h+o&Bq3V8nLdd+!>_5D|L!%z(LUdv}4 z-9~tNhZnTbjO}Q4zSoT+(%@3f+qRPyVdtK^x{<+OB&1(U5PIDaLnr6?v-&G_j zmjw6F-3|!6;YuzKJon^84Co$;$o?cW+~MkV-}@Xtzj$vxw0~m%P6~^m!cOqLFmfVT zOfN&!AY{G8g$vtg)Bj$qrr;9;EjPDcnaFo}{_zitzP+D}EAKn)EN94MkYf4oOS`#| z{6G_qPDl4*kc29q)dvtx``RF#!F7{C-uiGjp3P7O-Uj=93)>&}G%iER6j~B!qW9oH zOT~b7W}IkPM;~OmQh*2HpbumVCx!zLnpyydAHla~!#M@Wj37H8Sz`a`lrkQgzH=$A zIsIak!}tSeRvSInegD0~PSO!<9)*#J;g!IctRM4r=27vVDBYkgxDd|f6Yb?$i*-U5 z^y-?8u39#bkstwWi%hC9J$8|j>a{~?+0!FF9uqB9YFt`xFo0Du z^d%wMf;`>zY&G-JxMqR(KE{329VcN$b3EOL)!`fbT!}8wU9Inacand{en2@2L0Y8z zRLHU90t=&Q1Zm}`1heH&pzeVh1_50k8p3mg@+QE#-(wp77-3oJ@npY2iq3>|Z@I;e zkkqe?&E<5KhBOffKZvcgRv8iT^^O*70P)7N&m|kO