From 2b679b0167f8393072080eccc2964e3759f0225f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sat, 14 Feb 2026 16:14:17 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- BusinessCard/Resources/Localizable.xcstrings | 4 - .../Models/WatchCard.swift | 13 ++- .../Resources/Localizable.xcstrings | 4 + .../Services/WatchConnectivityService.swift | 4 +- .../Views/WatchContentView.swift | 107 +++++++++++++----- 5 files changed, 99 insertions(+), 33 deletions(-) diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 00b8d73..31eeb53 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -817,10 +817,6 @@ } } }, - "Show first-run onboarding again on next app launch" : { - "comment" : "A description of the reset onboarding feature.", - "isCommentAutoGenerated" : true - }, "Social Media" : { }, diff --git a/BusinessCardWatch Watch App/Models/WatchCard.swift b/BusinessCardWatch Watch App/Models/WatchCard.swift index 37bca2d..8b0db4b 100644 --- a/BusinessCardWatch Watch App/Models/WatchCard.swift +++ b/BusinessCardWatch Watch App/Models/WatchCard.swift @@ -14,11 +14,20 @@ struct WatchCard: Codable, Identifiable, Hashable { var isDefault: Bool /// Pre-generated QR code PNG data from iOS (CoreImage not available on watchOS) var qrCodeImageData: Data? - - /// Returns a SwiftUI Image from the synced QR code data + /// Pre-generated App Clip URL QR code PNG data + var appClipQRCodeImageData: Data? + + /// Returns a SwiftUI Image from the synced vCard QR code data var qrCodeImage: Image? { guard let data = qrCodeImageData, let uiImage = UIImage(data: data) else { return nil } return Image(uiImage: uiImage) } + + /// Returns a SwiftUI Image from the synced App Clip QR code data + var appClipQRCodeImage: Image? { + guard let data = appClipQRCodeImageData, + let uiImage = UIImage(data: data) else { return nil } + return Image(uiImage: uiImage) + } } diff --git a/BusinessCardWatch Watch App/Resources/Localizable.xcstrings b/BusinessCardWatch Watch App/Resources/Localizable.xcstrings index 866fbfe..e1ab181 100644 --- a/BusinessCardWatch Watch App/Resources/Localizable.xcstrings +++ b/BusinessCardWatch Watch App/Resources/Localizable.xcstrings @@ -117,6 +117,10 @@ } } }, + "Swipe left or right to switch QR code type" : { + "comment" : "A hint text that appears when a user has an App Clip QR code and is viewing a vCard QR code. It instructs the user to swipe to switch between the two types of QR codes.", + "isCommentAutoGenerated" : true + }, "Sync from iPhone" : { "comment" : "A description of how to sync a watch code from the iPhone app.", "isCommentAutoGenerated" : true diff --git a/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift b/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift index 5e5b99f..4671063 100644 --- a/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift +++ b/BusinessCardWatch Watch App/Services/WatchConnectivityService.swift @@ -57,7 +57,8 @@ final class WatchConnectivityService: NSObject { website: syncable.website, location: syncable.location, isDefault: syncable.isDefault, - qrCodeImageData: syncable.qrCodeImageData + qrCodeImageData: syncable.qrCodeImageData, + appClipQRCodeImageData: syncable.appClipQRCodeImageData ) } WatchDesign.debugLog("WatchConnectivity: Decoded \(watchCards.count) cards") @@ -122,4 +123,5 @@ private struct SyncableCard: Codable, Identifiable { var twitter: String var instagram: String var qrCodeImageData: Data? + var appClipQRCodeImageData: Data? } diff --git a/BusinessCardWatch Watch App/Views/WatchContentView.swift b/BusinessCardWatch Watch App/Views/WatchContentView.swift index 064b11c..455bdda 100644 --- a/BusinessCardWatch Watch App/Views/WatchContentView.swift +++ b/BusinessCardWatch Watch App/Views/WatchContentView.swift @@ -2,12 +2,32 @@ import SwiftUI struct WatchContentView: View { @Environment(WatchCardStore.self) private var cardStore + @State private var selectedCardID: UUID? + + private var displayCards: [WatchCard] { + cardStore.cards + } + + private var defaultSelection: UUID? { + cardStore.defaultCardID ?? displayCards.first?.id + } var body: some View { - if let card = cardStore.defaultCard { - WatchQRCodeCardView(card: card) - } else { + if displayCards.isEmpty { WatchEmptyStateView() + } else if displayCards.count == 1 { + WatchQRCodeCardView(card: displayCards[0]) + } else { + TabView(selection: Binding( + get: { selectedCardID ?? defaultSelection ?? displayCards[0].id }, + set: { selectedCardID = $0 } + )) { + ForEach(displayCards) { card in + WatchQRCodeCardView(card: card) + .tag(card.id) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) } } } @@ -32,43 +52,51 @@ private struct WatchEmptyStateView: View { } } +private enum WatchQRMode: String, CaseIterable { + case vCard = "vCard" + case appClip = "App Clip" +} + private struct WatchQRCodeCardView: View { let card: WatchCard + @State private var selectedQRIndex = 0 + + private var hasAppClipQR: Bool { card.appClipQRCodeImage != nil } + private var qrPages: [(WatchQRMode, Image?)] { + var pages: [(WatchQRMode, Image?)] = [(.vCard, card.qrCodeImage)] + if hasAppClipQR { + pages.append((.appClip, card.appClipQRCodeImage)) + } + return pages + } var body: some View { - VStack(spacing: WatchDesign.Spacing.medium) { - if let image = card.qrCodeImage { - image - .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)) - } else { - // Fallback when no QR code synced yet - VStack(spacing: WatchDesign.Spacing.small) { - Image(systemName: "qrcode") - .font(.largeTitle) - .foregroundStyle(Color.WatchPalette.muted) - Text("Sync from iPhone") - .font(.caption2) - .foregroundStyle(Color.WatchPalette.muted) + VStack(spacing: WatchDesign.Spacing.small) { + if qrPages.count > 1 { + // Swipeable QR codes (vCard ↔ App Clip) - no buttons, just swipe + TabView(selection: $selectedQRIndex) { + ForEach(Array(qrPages.enumerated()), id: \.offset) { index, item in + qrCodeContent(image: item.1) + .tag(index) + } } - .frame(width: WatchDesign.Size.qrSize, height: WatchDesign.Size.qrSize) - .background(Color.WatchPalette.card) - .clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large)) + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: WatchDesign.Size.qrSize + WatchDesign.Spacing.medium * 2) + } else { + // Single QR code + qrCodeContent(image: card.qrCodeImage ?? card.appClipQRCodeImage) } VStack(spacing: WatchDesign.Spacing.extraSmall) { Text(card.fullName) .font(.headline) .foregroundStyle(Color.WatchPalette.text) - + .lineLimit(1) + Text(card.role) .font(.caption) .foregroundStyle(Color.WatchPalette.muted) + .lineLimit(1) } } .padding(WatchDesign.Spacing.medium) @@ -77,6 +105,33 @@ private struct WatchQRCodeCardView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(String(localized: "QR code")) .accessibilityValue("\(card.fullName), \(card.role)") + .accessibilityHint(hasAppClipQR ? String(localized: "Swipe left or right to switch QR code type") : "") + } + + @ViewBuilder + private func qrCodeContent(image: Image?) -> some View { + if let image { + image + .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)) + } else { + VStack(spacing: WatchDesign.Spacing.small) { + Image(systemName: "qrcode") + .font(.largeTitle) + .foregroundStyle(Color.WatchPalette.muted) + Text("Sync from iPhone") + .font(.caption2) + .foregroundStyle(Color.WatchPalette.muted) + } + .frame(width: WatchDesign.Size.qrSize, height: WatchDesign.Size.qrSize) + .background(Color.WatchPalette.card) + .clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.large)) + } } }