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 0000000..3032a06 Binary files /dev/null and b/_design/screenshots/image-1.png differ diff --git a/_design/screenshots/image-2.png b/_design/screenshots/image-2.png new file mode 100644 index 0000000..3032a06 Binary files /dev/null and b/_design/screenshots/image-2.png differ diff --git a/_design/screenshots/image-3.png b/_design/screenshots/image-3.png new file mode 100644 index 0000000..3032a06 Binary files /dev/null and b/_design/screenshots/image-3.png differ diff --git a/ai_implmentation.md b/ai_implmentation.md new file mode 100644 index 0000000..20b4f18 --- /dev/null +++ b/ai_implmentation.md @@ -0,0 +1,82 @@ +# AI Implementation Context + +This file summarizes project-specific context, architecture, and conventions to speed up future AI work. + +## Project Summary +BusinessCard is a SwiftUI app for building and sharing digital business cards with QR codes. It includes iOS screens for cards, sharing, customization, contact tracking, and widget previews, plus a watchOS companion to show a default card QR code. + +## Key Constraints +- iOS 26+, watchOS 12+, Swift 6.2. +- SwiftUI with `@Observable` classes and `@MainActor`. +- Protocol‑oriented architecture is prioritized. +- No UIKit unless explicitly requested. +- String Catalogs only (`.xcstrings`). +- No magic numbers in views; use design constants. + +## Core Data Flow +- `AppState` owns: + - `CardStore` (cards and selection) + - `ContactsStore` (contact list + search) + - `ShareLinkService` (share URLs) + - `QRCodeService` (QR generation) +- Views read state via environment and render UI only. + +## Important Files + +### Models +- `BusinessCard/Models/BusinessCard.swift` — business card data + vCard payload +- `BusinessCard/Models/Contact.swift` — contact tracking model +- `BusinessCard/Models/CardTheme.swift` — card theme palette +- `BusinessCard/Models/CardLayoutStyle.swift` — stacked/split/photo + +### Protocols (POP) +- `BusinessCard/Protocols/BusinessCardProviding.swift` +- `BusinessCard/Protocols/ContactTracking.swift` +- `BusinessCard/Protocols/QRCodeProviding.swift` +- `BusinessCard/Protocols/ShareLinkProviding.swift` + +### State +- `BusinessCard/State/AppState.swift` +- `BusinessCard/State/CardStore.swift` +- `BusinessCard/State/ContactsStore.swift` + +### Services +- `BusinessCard/Services/QRCodeService.swift` — CoreImage QR generation +- `BusinessCard/Services/ShareLinkService.swift` — share URL helpers + +### Views +- `BusinessCard/Views/RootTabView.swift` — tabbed shell +- `BusinessCard/Views/CardsHomeView.swift` — hero + card carousel +- `BusinessCard/Views/ShareCardView.swift` — QR + share actions +- `BusinessCard/Views/CustomizeCardView.swift` — theme/layout controls +- `BusinessCard/Views/ContactsView.swift` — tracking list + search +- `BusinessCard/Views/WidgetsView.swift` — preview mockups + +### Design + Localization +- `BusinessCard/Design/DesignConstants.swift` +- `BusinessCard/Resources/Localizable.xcstrings` + +### watchOS +- `BusinessCardWatch/BusinessCardWatchApp.swift` +- `BusinessCardWatch/Views/WatchContentView.swift` +- `BusinessCardWatch/State/WatchCardStore.swift` +- `BusinessCardWatch/Resources/Localizable.xcstrings` + +## Localization +- All user-facing strings are in `.xcstrings`. +- Supported locales: en, es‑MX, fr‑CA. +- Use `String.localized("Key")` for non-Text strings. + +## Testing +- `BusinessCardTests/BusinessCardTests.swift` includes basic unit tests. + +## Known Stubs / TODOs +- Apple Wallet and NFC flows are alert-only placeholders. +- Share URLs are sample placeholders. +- Widget previews are not WidgetKit extensions. + +## If You Extend The App +- Add new strings to the String Catalogs. +- Add new constants to `DesignConstants.swift` instead of literals. +- Keep view logic UI-only; push business logic to state classes. +- Prefer protocols for new capabilities.