272 lines
10 KiB
Swift
272 lines
10 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
import CoreImage
|
|
import CoreImage.CIFilterBuiltins
|
|
import UIKit
|
|
import Bedrock
|
|
|
|
/// Manages WatchConnectivity session and sends card data to Apple Watch
|
|
@MainActor
|
|
final class WatchConnectivityService: NSObject {
|
|
static let shared = WatchConnectivityService()
|
|
|
|
private var session: WCSession?
|
|
private var isActivated = false
|
|
private var pendingCards: [BusinessCard]?
|
|
|
|
private override init() {
|
|
super.init()
|
|
if WCSession.isSupported() {
|
|
session = WCSession.default
|
|
session?.delegate = self
|
|
session?.activate()
|
|
Design.debugLog("WatchConnectivity: Session activating...")
|
|
} else {
|
|
Design.debugLog("WatchConnectivity: Not supported on this device")
|
|
}
|
|
}
|
|
|
|
/// Syncs the given cards to the paired Apple Watch
|
|
func syncCards(_ cards: [BusinessCard]) {
|
|
// If not yet activated, store cards to send after activation
|
|
guard isActivated else {
|
|
Design.debugLog("WatchConnectivity: Session not ready, queuing \(cards.count) cards")
|
|
pendingCards = cards
|
|
return
|
|
}
|
|
|
|
guard let session = session else {
|
|
Design.debugLog("WatchConnectivity: No session available")
|
|
return
|
|
}
|
|
|
|
// In DEBUG, isWatchAppInstalled may be false even when running from Xcode
|
|
// So we only require isPaired and isReachable or just try to send anyway
|
|
#if DEBUG
|
|
guard session.isPaired else {
|
|
Design.debugLog("WatchConnectivity: Watch not paired")
|
|
return
|
|
}
|
|
Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
|
#else
|
|
guard session.isPaired, session.isWatchAppInstalled else {
|
|
Design.debugLog("WatchConnectivity: Watch not available (paired: \(session.isPaired), installed: \(session.isWatchAppInstalled))")
|
|
return
|
|
}
|
|
#endif
|
|
|
|
sendCardsToWatch(cards)
|
|
}
|
|
|
|
private func sendCardsToWatch(_ cards: [BusinessCard]) {
|
|
guard let session = session else { return }
|
|
|
|
Design.debugLog("WatchConnectivity: Syncing \(cards.count) cards to Watch (reachable: \(session.isReachable))")
|
|
|
|
let syncableCards = cards.map { card in
|
|
createSyncableCard(from: card)
|
|
}
|
|
|
|
do {
|
|
let encoded = try JSONEncoder().encode(syncableCards)
|
|
let message: [String: Any] = ["cards": encoded]
|
|
|
|
// ALWAYS update application context - this persists and will be available
|
|
// when watch app launches, even if not currently reachable
|
|
do {
|
|
try session.updateApplicationContext(message)
|
|
Design.debugLog("WatchConnectivity: Updated application context with \(encoded.count) bytes")
|
|
} catch {
|
|
Design.debugLog("WatchConnectivity: updateApplicationContext failed: \(error)")
|
|
}
|
|
|
|
// Also try immediate delivery if reachable
|
|
if session.isReachable {
|
|
session.sendMessage(message, replyHandler: nil) { error in
|
|
Task { @MainActor in
|
|
Design.debugLog("WatchConnectivity: sendMessage failed: \(error)")
|
|
}
|
|
}
|
|
Design.debugLog("WatchConnectivity: Also sent via sendMessage (reachable)")
|
|
}
|
|
} catch {
|
|
Design.debugLog("WatchConnectivity: ERROR - Failed to encode: \(error)")
|
|
}
|
|
}
|
|
|
|
fileprivate func handleActivation() {
|
|
isActivated = true
|
|
|
|
Design.debugLog("WatchConnectivity: Activation complete - paired: \(session?.isPaired ?? false), installed: \(session?.isWatchAppInstalled ?? false), reachable: \(session?.isReachable ?? false)")
|
|
|
|
// Send any pending cards that were queued before activation
|
|
if let cards = pendingCards {
|
|
Design.debugLog("WatchConnectivity: Sending \(cards.count) queued cards after activation")
|
|
pendingCards = nil
|
|
syncCards(cards)
|
|
}
|
|
}
|
|
|
|
/// Force a retry - call this after watch app is confirmed running
|
|
func retrySyncIfNeeded() {
|
|
guard let session = session else { return }
|
|
Design.debugLog("WatchConnectivity: Manual retry - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
|
|
|
|
if let cards = pendingCards {
|
|
pendingCards = nil
|
|
syncCards(cards)
|
|
}
|
|
}
|
|
|
|
private func createSyncableCard(from card: BusinessCard) -> SyncableCard {
|
|
// Get first email, phone, website, location from contact fields
|
|
let email = card.firstContactField(ofType: "email")?.value ?? ""
|
|
let phone = card.firstContactField(ofType: "phone")?.value ?? ""
|
|
let website = card.firstContactField(ofType: "website")?.value ?? ""
|
|
let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
|
|
let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
|
|
let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
|
|
|
|
// Format address for display (decode from stored JSON/legacy format)
|
|
let addressValue = card.firstContactField(ofType: "address")?.value ?? ""
|
|
let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue
|
|
|
|
// Build vCard payload for QR code generation (use vCardName for compatibility)
|
|
let vCardPayload = buildVCardPayload(
|
|
displayName: card.vCardName,
|
|
company: card.company,
|
|
role: card.role,
|
|
phone: phone,
|
|
email: email,
|
|
website: website,
|
|
location: location,
|
|
bio: card.bio,
|
|
linkedIn: linkedIn,
|
|
twitter: twitter,
|
|
instagram: instagram
|
|
)
|
|
|
|
// Generate QR code image data on iOS (CoreImage not available on watchOS)
|
|
let qrImageData = generateQRCodePNGData(from: vCardPayload)
|
|
|
|
// Use fullName - the single source of truth for display names
|
|
let syncDisplayName = card.fullName
|
|
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
|
|
|
|
return SyncableCard(
|
|
id: card.id,
|
|
fullName: syncDisplayName,
|
|
role: card.role,
|
|
company: card.company,
|
|
email: email,
|
|
phone: phone,
|
|
website: website,
|
|
location: location,
|
|
isDefault: card.isDefault,
|
|
pronouns: card.pronouns,
|
|
bio: card.bio,
|
|
linkedIn: linkedIn,
|
|
twitter: twitter,
|
|
instagram: instagram,
|
|
qrCodeImageData: qrImageData
|
|
)
|
|
}
|
|
|
|
private func buildVCardPayload(
|
|
displayName: String,
|
|
company: String,
|
|
role: String,
|
|
phone: String,
|
|
email: String,
|
|
website: String,
|
|
location: String,
|
|
bio: String,
|
|
linkedIn: String,
|
|
twitter: String,
|
|
instagram: String
|
|
) -> String {
|
|
var lines = [
|
|
"BEGIN:VCARD",
|
|
"VERSION:3.0",
|
|
"FN:\(displayName)",
|
|
"ORG:\(company)",
|
|
"TITLE:\(role)"
|
|
]
|
|
|
|
if !phone.isEmpty {
|
|
lines.append("TEL;TYPE=work:\(phone)")
|
|
}
|
|
if !email.isEmpty {
|
|
lines.append("EMAIL;TYPE=work:\(email)")
|
|
}
|
|
if !website.isEmpty {
|
|
lines.append("URL:\(website)")
|
|
}
|
|
if !location.isEmpty {
|
|
lines.append("ADR;TYPE=work:;;\(location)")
|
|
}
|
|
if !bio.isEmpty {
|
|
lines.append("NOTE:\(bio)")
|
|
}
|
|
if !linkedIn.isEmpty {
|
|
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
|
|
}
|
|
if !twitter.isEmpty {
|
|
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
|
|
}
|
|
if !instagram.isEmpty {
|
|
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
|
|
}
|
|
|
|
lines.append("END:VCARD")
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func generateQRCodePNGData(from payload: String) -> Data? {
|
|
let context = CIContext()
|
|
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 }
|
|
|
|
// Scale up for better quality on watch display
|
|
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
|
|
|
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
|
|
|
|
// Convert to PNG data for syncing
|
|
let uiImage = UIImage(cgImage: cgImage)
|
|
return uiImage.pngData()
|
|
}
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
|
|
extension WatchConnectivityService: WCSessionDelegate {
|
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
Task { @MainActor in
|
|
if let error = error {
|
|
Design.debugLog("WatchConnectivity: Activation failed: \(error)")
|
|
} else {
|
|
Design.debugLog("WatchConnectivity: Activated with state: \(activationState.rawValue)")
|
|
WatchConnectivityService.shared.handleActivation()
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
|
|
Task { @MainActor in
|
|
Design.debugLog("WatchConnectivity: Session became inactive")
|
|
}
|
|
}
|
|
|
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
|
Task { @MainActor in
|
|
Design.debugLog("WatchConnectivity: Session deactivated, reactivating...")
|
|
WCSession.default.activate()
|
|
}
|
|
}
|
|
}
|