From c9782aec3124e46614210a54bd853a17053508a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:47:19 +0100 Subject: [PATCH 01/16] Move to approach where app request complication one by one --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../Extensions/Watch/ExtensionDelegate.swift | 45 +- .../Watch/Home/COMPLICATION_SYNC_PROTOCOL.md | 412 ++++++++++++++++++ .../Watch/Home/WatchHomeViewModel.swift | 114 +++++ Sources/Watch/WatchCommunicatorService.swift | 93 +++- 5 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 095cf09f80..a1b913008d 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -924,6 +924,7 @@ 42C3737F2BC415AC00898990 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3737E2BC415AC00898990 /* UIViewController+Extensions.swift */; }; 42CB330D2DAE4FD800491DCE /* ServerSelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */; }; 42CB330F2DAE530400491DCE /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330E2DAE530400491DCE /* SettingsButton.swift */; }; + 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */ = {isa = PBXBuildFile; fileRef = 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */; }; 42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; @@ -2607,6 +2608,7 @@ 42CA28B52B1022680093B31A /* HAButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAButton.swift; sourceTree = ""; }; 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectView.swift; sourceTree = ""; }; 42CB330E2DAE530400491DCE /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; + 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = COMPLICATION_SYNC_PROTOCOL.md; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; @@ -5438,6 +5440,7 @@ 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */, 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */, 429764F52E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift */, + 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */, ); path = Home; sourceTree = ""; @@ -7635,6 +7638,7 @@ 426490732C0F1F36002155CC /* Colors.xcassets in Resources */, B6CC5D9A2159D10F00833E5D /* Assets.xcassets in Resources */, FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */, + 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */, 421BBC702E93A64B00745EC8 /* Interface.storyboard in Resources */, 426266422C11A6700081A818 /* SharedAssets.xcassets in Resources */, ); diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index e71109fa23..23af66a99c 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -184,7 +184,7 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } Context.observations.store[.init(queue: .main)] = { [weak self] context in - Current.Log.verbose("Received context: \(context)") + Current.Log.info("Received context with \(context.content.count) keys: \(Array(context.content.keys))") self?.updateContext(context.content) } @@ -245,26 +245,65 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { private func updateContext(_ content: Content) { let realm = Current.realm() + + // Enhanced logging to diagnose sync issues + Current.Log.info("Received context update with keys: \(content.keys)") if let servers = content["servers"] as? Data { Current.servers.restoreState(servers) + Current.Log.info("Updated servers from context") + } else { + Current.Log.verbose("No servers data in context") } - if let complicationsDictionary = content["complications"] as? [[String: Any]] { + // Check for new serialized format (complicationsData as Data) + if let complicationsData = content["complicationsData"] as? Data { + Current.Log.info("Received complicationsData (\(complicationsData.count) bytes), deserializing...") + + do { + // Deserialize JSON data to array of dictionaries + guard let complicationsDictionary = try JSONSerialization.jsonObject(with: complicationsData, options: []) as? [[String: Any]] else { + Current.Log.error("Failed to deserialize complications data to array of dictionaries") + return + } + + let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } + + Current.Log.info("Deserialized \(complications.count) complications from data: \(complications.map(\.identifier))") + + realm.reentrantWrite { + realm.delete(realm.objects(WatchComplication.self)) + realm.add(complications, update: .all) + } + + Current.Log.info("Successfully saved \(complications.count) complications to watch database") + } catch { + Current.Log.error("Failed to deserialize complications: \(error.localizedDescription)") + } + } + // Fallback: check for old format (complications as [[String: Any]]) + else if let complicationsDictionary = content["complications"] as? [[String: Any]] { let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } - Current.Log.verbose("Updating complications from context \(complications)") + Current.Log.info("Updating \(complications.count) complications from context (legacy format): \(complications.map(\.identifier))") realm.reentrantWrite { realm.delete(realm.objects(WatchComplication.self)) realm.add(complications, update: .all) } + + Current.Log.info("Successfully saved \(complications.count) complications to watch database") + } else { + Current.Log.warning("No complications data in context - context may not be syncing from phone") + let existingCount = realm.objects(WatchComplication.self).count + Current.Log.info("Watch database currently has \(existingCount) complications") } updateComplications() } private var isUpdatingComplications = false + private func updateComplications() { // avoid double-updating due to e.g. complication info update request guard !isUpdatingComplications else { return } diff --git a/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md b/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md new file mode 100644 index 0000000000..7c491967fc --- /dev/null +++ b/Sources/Extensions/Watch/Home/COMPLICATION_SYNC_PROTOCOL.md @@ -0,0 +1,412 @@ +# Complication Sync Protocol Documentation + +## Overview + +This document describes the paginated complication sync protocol between the iPhone and Apple Watch. Complications are synced one at a time to avoid WatchConnectivity payload size limits. + +## Architecture + +### Key Components + +**iPhone (iOS App):** +- `WatchCommunicatorService.swift` - Handles sync requests from watch +- Realm database - Stores `WatchComplication` objects + +**Apple Watch (watchOS App):** +- `WatchHomeViewModel.swift` - Initiates sync requests +- `ExtensionDelegate.swift` - Receives and processes individual complications +- `ComplicationController.swift` - Provides complications to watchOS + +## Protocol Flow + +``` +┌─────────┐ ┌─────────┐ +│ Watch │ │ Phone │ +└────┬────┘ └────┬────┘ + │ │ + │ 1. Request complication at index 0 │ + │ Message: "syncComplication" │ + │ Content: {"index": 0} │ + ├──────────────────────────────────────────────>│ + │ │ + │ 2. Fetch complication from Realm + │ 3. Serialize to JSON Data + │ │ + │ 4. Response with complication data │ + │ Message: "syncComplicationResponse" │ + │ Content: { │ + │ "complicationData": Data, │ + │ "hasMore": true, │ + │ "index": 0, │ + │ "total": 5 │ + │ } │ + │<──────────────────────────────────────────────┤ + │ │ +5. Save to Realm │ +6. Check hasMore flag │ + │ │ + │ 7. Request complication at index 1 │ + │ Message: "syncComplication" │ + │ Content: {"index": 1} │ + ├──────────────────────────────────────────────>│ + │ │ + │ ... Repeat for each complication ... + │ │ + │ N. Final complication │ + │ Content: { │ + │ "complicationData": Data, │ + │ "hasMore": false, ← Last one │ + │ "index": 4, │ + │ "total": 5 │ + │ } │ + │<──────────────────────────────────────────────┤ + │ │ +N+1. Complete sync │ +N+2. Reload complication timeline │ + │ │ +``` + +## Message Specifications + +### 1. Sync Request (Watch → Phone) + +**Message Type:** `ImmediateMessage` + +**Identifier:** `"syncComplication"` + +**Content:** +```swift +{ + "index": Int // 0-based index of complication to fetch +} +``` + +**Example:** +```swift +ImmediateMessage( + identifier: "syncComplication", + content: ["index": 0] +) +``` + +### 2. Sync Response (Phone → Watch) + +**Message Type:** `ImmediateMessage` + +**Identifier:** `"syncComplicationResponse"` + +**Content (Success):** +```swift +{ + "complicationData": Data, // JSON-serialized WatchComplication + "hasMore": Bool, // true if more complications are pending + "index": Int, // The index that was requested + "total": Int // Total number of complications +} +``` + +**Content (Error):** +```swift +{ + "error": String, // Error description + "hasMore": false, + "index": -1, + "total": 0 +} +``` + +**Example (Success):** +```swift +ImmediateMessage( + identifier: "syncComplicationResponse", + content: [ + "complicationData": complicationData, // Data object + "hasMore": true, + "index": 2, + "total": 5 + ] +) +``` + +## Implementation Details + +### Phone Side (WatchCommunicatorService.swift) + +#### Key Methods + +**`syncSingleComplication(message:)`** +- Receives sync request with index +- Fetches complication from Realm at that index +- Serializes to JSON string using ObjectMapper's `toJSONString()` +- Converts JSON string to `Data` +- Sends response with `hasMore` flag + +**Protocol:** +```swift +/// Syncs a single complication to the watch by index (paginated approach) +/// This avoids payload size limits by sending complications one at a time. +/// +/// Protocol: +/// 1. Watch sends "syncComplication" with {"index": N} +/// 2. Phone responds with "syncComplicationResponse" containing: +/// - "complicationData": Data (JSON of the complication) +/// - "hasMore": Bool (true if index+1 < total) +/// - "index": Int (the index sent by watch) +/// - "total": Int (total number of complications) +/// 3. Watch saves the complication and requests next if hasMore is true +private func syncSingleComplication(message: ImmediateMessage) +``` + +**Message Observation:** +```swift +ImmediateMessage.observations.store[.init(queue: .main)] = { [weak self] message in + guard let self else { return } + + if message.identifier == "syncComplication" { + self.syncSingleComplication(message: message) + } +} +``` + +### Watch Side + +#### WatchHomeViewModel.swift + +**`requestConfig()`** +- Initiates paginated sync by requesting index 0 +- Called when watch app launches or config is refreshed + +**`requestNextComplication(index:)`** +- Helper method to send sync request for a specific index +- Called recursively as responses are received + +**Protocol:** +```swift +/// Requests a single complication from the phone by index +/// - Parameter index: The index of the complication to request (0-based) +/// +/// This implements a paginated sync protocol: +/// 1. Watch sends request with index +/// 2. Phone responds with complication at that index + "hasMore" flag +/// 3. If hasMore is true, watch requests next index +/// 4. Continues until hasMore is false or error occurs +private func requestNextComplication(index: Int) +``` + +#### ExtensionDelegate.swift + +**`handleSyncComplicationResponse(_:)`** +- Receives individual complication responses +- Deserializes JSON data to `WatchComplication` object +- Saves to Realm database +- Clears existing complications on index 0 (first complication) +- Requests next complication if `hasMore` is true +- Triggers complication reload when complete + +**Key Features:** +- Tracks sync progress (count, timing) +- Comprehensive logging at each step +- Automatic chaining of requests +- Error handling + +**Protocol:** +```swift +/// Handles individual complication sync responses from the phone +/// This is part of the paginated complication sync protocol where complications +/// are sent one at a time to avoid payload size limits. +/// +/// Expected message content: +/// - "complicationData": Data - JSON string of the complication +/// - "hasMore": Bool - true if more complications are pending +/// - "index": Int - index of this complication +/// - "total": Int - total number of complications +/// - "error": String? - optional error message +private func handleSyncComplicationResponse(_ message: ImmediateMessage) +``` + +## Data Flow + +### Complication Serialization (Phone) + +``` +WatchComplication (Realm Object) + ↓ +toJSONString() (ObjectMapper) + ↓ +JSON String + ↓ +.data(using: .utf8) + ↓ +Data (suitable for WatchConnectivity) +``` + +### Complication Deserialization (Watch) + +``` +Data (from WatchConnectivity) + ↓ +JSONSerialization.jsonObject() + ↓ +[String: Any] Dictionary + ↓ +WatchComplication(JSON:) (ObjectMapper) + ↓ +WatchComplication (Realm Object) + ↓ +Save to Realm Database +``` + +## Advantages of Paginated Approach + +1. **No Payload Size Limits** + - Each message only contains one complication + - Avoids "Payload contains unsupported type" errors + - Works with any number of complications + +2. **Better Error Recovery** + - If one complication fails, others can still sync + - Clear indication of which complication caused error + +3. **Progress Tracking** + - Watch knows exact progress (N of M) + - Can display sync progress to user if needed + +4. **Efficient Memory Usage** + - Only one complication in memory at a time + - No large JSON arrays to allocate + +5. **Incremental Sync** + - Could be extended to only sync changed complications + - Could support cancellation mid-sync + +## Logging + +### Watch Logs (Expected Output) + +``` +Requesting complications sync from phone (paginated approach) +Requesting complication at index 0 +Starting complication sync +Received complication 1 of 3 +Deserialized complication: ABC123-UUID +Clearing existing complications from watch database +Saved complication 1 to watch database +More complications pending, requesting index 1 +Received complication 2 of 3 +Deserialized complication: DEF456-UUID +Saved complication 2 to watch database +More complications pending, requesting index 2 +Received complication 3 of 3 +Deserialized complication: GHI789-UUID +Saved complication 3 to watch database +Complication sync complete! Received 3 of 3 complications in 0.45s +Providing complication descriptors: - Configured complications from database: 3 +``` + +### Phone Logs (Expected Output) + +``` +Watch requested complication at index 0 +Sending complication 1 of 3 (hasMore: true) +Watch requested complication at index 1 +Sending complication 2 of 3 (hasMore: true) +Watch requested complication at index 2 +Sending complication 3 of 3 (hasMore: false) +``` + +## Error Handling + +### Invalid Index +``` +Phone: "Invalid complication index 5 (total: 3)" +Watch: "Received error during complication sync: Invalid index 5, total is 3" +``` + +### Serialization Failure +``` +Phone: "Failed to serialize complication at index 2" +Watch: "Received error during complication sync: Failed to serialize complication" +``` + +### Deserialization Failure +``` +Watch: "Failed to create WatchComplication from JSON at index 1" +(Continues with next complication if available) +``` + +## Backward Compatibility + +The legacy `syncComplications` method remains available but is not recommended due to payload size limits. It attempts to send all complications via WatchConnectivity Context in one message. + +**Legacy Method:** `syncComplications(message: InteractiveImmediateMessage)` +- Sends all complications via Context +- May fail with "Payload contains unsupported type" error +- Kept for reference but deprecated + +## Testing + +### Test Scenarios + +1. **Zero Complications** + - Phone has no complications + - Watch should receive total=0 and not request anything + +2. **Single Complication** + - Phone has 1 complication + - Watch receives hasMore=false on first response + +3. **Multiple Complications** + - Phone has N complications + - Watch requests N times, last one has hasMore=false + +4. **Large Complication** + - Test with complex complication data + - Verify individual message stays under size limits + +5. **Network Interruption** + - Start sync, lose connectivity mid-way + - Verify partial sync doesn't corrupt database + +## Performance Considerations + +- **Message Frequency:** Each complication requires one round-trip +- **Typical Timing:** ~0.1s per complication over local connection +- **Database:** Realm write for each complication (fast) +- **UI Impact:** Sync happens in background, doesn't block UI + +## Future Enhancements + +Potential improvements to consider: + +1. **Batch Requests** + - Request multiple indices at once + - Balance between payload size and round-trips + +2. **Delta Sync** + - Only sync changed complications + - Use modification timestamps or checksums + +3. **Compression** + - Compress JSON data before sending + - Significant savings for text-heavy complications + +4. **Cancellation** + - Allow user to cancel sync mid-process + - Add cancellation token to protocol + +5. **Progress UI** + - Show sync progress indicator in watch app + - Display "Syncing complications 3 of 5..." + +## Related Files + +- `WatchCommunicatorService.swift` - Phone sync logic +- `WatchHomeViewModel.swift` - Watch sync initiation +- `ExtensionDelegate.swift` - Watch sync handling +- `ComplicationController.swift` - Provides complications to watchOS +- `WatchComplication.swift` - Data model + +## Version History + +- **v1.0** (Current) - Paginated sync protocol implemented +- **v0.x** (Legacy) - Single-message Context sync (deprecated) diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index d99e5afff0..3283750c13 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -1,3 +1,4 @@ +import ClockKit import Communicator import Foundation import NetworkExtension @@ -51,12 +52,125 @@ final class WatchHomeViewModel: ObservableObject { return } isLoading = true + + // Request watch config via interactive message Communicator.shared.send(.init( identifier: InteractiveImmediateMessages.watchConfig.rawValue, reply: { [weak self] message in self?.handleMessageResponse(message) } )) + + // Request complications context sync from phone + // This initiates a paginated sync where complications are sent one at a time + // The phone will respond with each complication and a flag indicating if more are pending + Current.Log.info("Requesting complications sync from phone (paginated approach)") + requestNextComplication(index: 0) + } + + /// Requests a single complication from the phone by index + /// - Parameter index: The index of the complication to request (0-based) + /// + /// This implements a paginated sync protocol: + /// 1. Watch sends interactive request with index + /// 2. Phone responds with complication at that index + "hasMore" flag in reply + /// 3. If hasMore is true, watch requests next index + /// 4. Continues until hasMore is false or error occurs + private func requestNextComplication(index: Int) { + Current.Log.info("Requesting complication at index \(index)") + + Communicator.shared.send(.init( + identifier: "syncComplication", + content: ["index": index], + reply: { [weak self] replyMessage in + self?.handleComplicationResponse(replyMessage, requestedIndex: index) + } + ), errorHandler: { error in + Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") + }) + } + + /// Handles the response for a single complication request + /// - Parameters: + /// - message: The reply message from the phone + /// - requestedIndex: The index that was requested + private func handleComplicationResponse(_ message: ImmediateMessage, requestedIndex: Int) { + // Check for error + if let error = message.content["error"] as? String { + Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") + return + } + + guard let complicationData = message.content["complicationData"] as? Data, + let hasMore = message.content["hasMore"] as? Bool, + let index = message.content["index"] as? Int, + let total = message.content["total"] as? Int else { + Current.Log.error("Invalid syncComplication response format") + return + } + + Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") + + // Save the complication + saveComplicationToDatabase(complicationData, index: index, total: total) + + // Request next complication if more are pending + if hasMore { + Current.Log.verbose("More complications pending, requesting index \(index + 1)") + requestNextComplication(index: index + 1) + } else { + Current.Log.info("Complication sync complete! Received \(total) complications") + // Trigger complication reload + reloadComplications() + } + } + + /// Saves a single complication to the watch database + /// - Parameters: + /// - complicationData: JSON data of the complication + /// - index: The index of this complication + /// - total: Total number of complications being synced + private func saveComplicationToDatabase(_ complicationData: Data, index: Int, total: Int) { + do { + guard let json = try JSONSerialization.jsonObject(with: complicationData, options: []) as? [String: Any] else { + Current.Log.error("Failed to deserialize complication JSON at index \(index)") + return + } + + guard let complication = try? WatchComplication(JSON: json) else { + Current.Log.error("Failed to create WatchComplication from JSON at index \(index)") + return + } + + Current.Log.verbose("Deserialized complication: \(complication.identifier)") + + // Save to Realm database + let realm = Current.realm() + realm.reentrantWrite { + // On first complication, clear existing ones + if index == 0 { + Current.Log.info("Clearing existing complications from watch database") + realm.delete(realm.objects(WatchComplication.self)) + } + realm.add(complication, update: .all) + } + + Current.Log.info("Saved complication \(index + 1) of \(total) to watch database") + } catch { + Current.Log.error("Failed to save complication at index \(index): \(error.localizedDescription)") + } + } + + /// Triggers a reload of all complications on the watch + private func reloadComplications() { + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() + + if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { + Current.Log.info("Reloading \(activeComplications.count) active complications") + for complication in activeComplications { + CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) + } + } } func info(for magicItem: MagicItem) -> MagicItem.Info { diff --git a/Sources/Watch/WatchCommunicatorService.swift b/Sources/Watch/WatchCommunicatorService.swift index c0c387ea3b..3145f37f1c 100644 --- a/Sources/Watch/WatchCommunicatorService.swift +++ b/Sources/Watch/WatchCommunicatorService.swift @@ -54,7 +54,15 @@ final class WatchCommunicatorService { InteractiveImmediateMessage.observations.store[.init(queue: .main)] = { [weak self] message in Current.Log.verbose("Received \(message.identifier) \(message) \(message.content)") - guard let self, let messageId = InteractiveImmediateMessages(rawValue: message.identifier) else { + guard let self else { return } + + // Handle custom syncComplication message (paginated approach) + if message.identifier == "syncComplication" { + self.syncSingleComplication(message: message) + return + } + + guard let messageId = InteractiveImmediateMessages(rawValue: message.identifier) else { Current.Log .error( "Received InteractiveImmediateMessage not mapped in InteractiveImmediateMessages: \(message.identifier)" @@ -153,6 +161,89 @@ final class WatchCommunicatorService { message.reply(.init(identifier: responseIdentifier)) } + /// Syncs a single complication to the watch by index (paginated approach with reply) + /// This avoids payload size limits by sending complications one at a time. + /// + /// Protocol: + /// 1. Watch sends "syncComplication" InteractiveImmediateMessage with {"index": N} + /// 2. Phone replies with complication data containing: + /// - "complicationData": Data (JSON of the complication) + /// - "hasMore": Bool (true if index+1 < total) + /// - "index": Int (the index sent by watch) + /// - "total": Int (total number of complications) + /// 3. Watch saves the complication and requests next if hasMore is true + /// + /// - Parameter message: The InteractiveImmediateMessage containing {"index": Int} + private func syncSingleComplication(message: InteractiveImmediateMessage) { + guard let index = message.content["index"] as? Int else { + Current.Log.error("syncComplication message missing 'index' parameter") + message.reply(.init( + identifier: "syncComplicationResponse", + content: [ + "error": "Missing index parameter", + "hasMore": false, + "index": -1, + "total": 0 + ] + )) + return + } + + Current.Log.info("Watch requested complication at index \(index)") + + let realm = Current.realm() + let complications = realm.objects(WatchComplication.self) + let total = complications.count + + // Validate index + guard index >= 0, index < total else { + Current.Log.error("Invalid complication index \(index) (total: \(total))") + message.reply(.init( + identifier: "syncComplicationResponse", + content: [ + "error": "Invalid index \(index), total is \(total)", + "hasMore": false, + "index": -1, + "total": total + ] + )) + return + } + + let complication = complications[index] + + // Serialize this single complication + guard let complicationJSONString = complication.toJSONString(), + let complicationData = complicationJSONString.data(using: .utf8) else { + Current.Log.error("Failed to serialize complication at index \(index)") + message.reply(.init( + identifier: "syncComplicationResponse", + content: [ + "error": "Failed to serialize complication", + "hasMore": false, + "index": index, + "total": total + ] + )) + return + } + + let hasMore = (index + 1) < total + + Current.Log.info("Sending complication \(index + 1) of \(total) to watch (hasMore: \(hasMore))") + + // Reply with complication data + message.reply(.init( + identifier: "syncComplicationResponse", + content: [ + "complicationData": complicationData, + "hasMore": hasMore, + "index": index, + "total": total + ] + )) + } + private func magicItemPressed(message: InteractiveImmediateMessage) { let responseIdentifier = InteractiveImmediateResponses.magicItemRowPressedResponse.rawValue guard let itemType = message.content["itemType"] as? String, From cdadf2a0b4c00972a73f5ea328e2b2571b60b759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:56:44 +0100 Subject: [PATCH 02/16] Organize code --- HomeAssistant.xcodeproj/project.pbxproj | 10 + .../Extensions/Watch/ExtensionDelegate.swift | 31 ++- .../Home/WatchComplicationSyncMessages.swift | 41 +++ .../Watch/Home/WatchHomeViewModel.swift | 233 +++++++++--------- .../WatchComplicationSyncMessages.swift | 41 +++ Sources/Watch/WatchCommunicatorService.swift | 113 ++++++--- 6 files changed, 313 insertions(+), 156 deletions(-) create mode 100644 Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift create mode 100644 Sources/Shared/WatchComplicationSyncMessages.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index a1b913008d..89abf45545 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -925,6 +925,9 @@ 42CB330D2DAE4FD800491DCE /* ServerSelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */; }; 42CB330F2DAE530400491DCE /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330E2DAE530400491DCE /* SettingsButton.swift */; }; 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */ = {isa = PBXBuildFile; fileRef = 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */; }; + 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; 42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; @@ -2609,6 +2612,8 @@ 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectView.swift; sourceTree = ""; }; 42CB330E2DAE530400491DCE /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = COMPLICATION_SYNC_PROTOCOL.md; sourceTree = ""; }; + 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; + 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; @@ -5441,6 +5446,7 @@ 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */, 429764F52E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift */, 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */, + 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */, ); path = Home; sourceTree = ""; @@ -6400,6 +6406,7 @@ D03D891820E0A85300D4F28D /* Shared */ = { isa = PBXGroup; children = ( + 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */, 4245DC022EBA42C3005E0E04 /* AreasService.swift */, 420CFC612D3F9C15009A94F3 /* Database */, 4278CB822D01F09400CFAAC9 /* AppGesture.swift */, @@ -8993,6 +9000,7 @@ 1121CD4D271295AD0071C2AA /* Style.swift in Sources */, 116570782702B0F6003906A7 /* DiskCache.swift in Sources */, 11657051270188E4003906A7 /* URLComponents+WidgetAuthenticity.swift in Sources */, + 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 11F855DD24DF6C7A0018013E /* IconImageView.swift in Sources */, B67CE87922200F220034C1D0 /* RealmZone.swift in Sources */, 420CFC802D3F9D89009A94F3 /* DatabaseTables.swift in Sources */, @@ -9088,6 +9096,7 @@ 42DF98712E93A22C00837EA2 /* NotificationSubControllerMedia.swift in Sources */, 42B1A7432C11E65100904548 /* WatchAssistService.swift in Sources */, 42DF986D2E93A20400837EA2 /* NotificationSubControllerMJPEG.swift in Sources */, + 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 423F44FF2C186E4500766A99 /* WatchCommunicatorService.swift in Sources */, 42DF98692E93A1E000837EA2 /* DynamicNotificationController.swift in Sources */, 42DF98652E93A11000837EA2 /* HostingController.swift in Sources */, @@ -9352,6 +9361,7 @@ 420CFC6C2D3F9C6E009A94F3 /* CarPlayConfigTable.swift in Sources */, 114E9B4E24E89B1300B43EED /* INImage+MaterialDesignIcons.swift in Sources */, 118261FD24F9B81A000795C6 /* HACoreBlahProperty.swift in Sources */, + 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */, 42FCCFDA2B9B19F70057783F /* ThreadClientService.swift in Sources */, 1101568724D7712F009424C9 /* TagManagerProtocol.swift in Sources */, 42E9B0002CE63944009DDA46 /* AudioOutputSensor.swift in Sources */, diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index 23af66a99c..7f87695d56 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -245,7 +245,7 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { private func updateContext(_ content: Content) { let realm = Current.realm() - + // Enhanced logging to diagnose sync issues Current.Log.info("Received context update with keys: \(content.keys)") @@ -259,23 +259,29 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { // Check for new serialized format (complicationsData as Data) if let complicationsData = content["complicationsData"] as? Data { Current.Log.info("Received complicationsData (\(complicationsData.count) bytes), deserializing...") - + do { // Deserialize JSON data to array of dictionaries - guard let complicationsDictionary = try JSONSerialization.jsonObject(with: complicationsData, options: []) as? [[String: Any]] else { + guard let complicationsDictionary = try JSONSerialization.jsonObject( + with: complicationsData, + options: [] + ) as? [[String: Any]] else { Current.Log.error("Failed to deserialize complications data to array of dictionaries") return } - + let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } - - Current.Log.info("Deserialized \(complications.count) complications from data: \(complications.map(\.identifier))") - + + Current.Log + .info( + "Deserialized \(complications.count) complications from data: \(complications.map(\.identifier))" + ) + realm.reentrantWrite { realm.delete(realm.objects(WatchComplication.self)) realm.add(complications, update: .all) } - + Current.Log.info("Successfully saved \(complications.count) complications to watch database") } catch { Current.Log.error("Failed to deserialize complications: \(error.localizedDescription)") @@ -285,13 +291,16 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { else if let complicationsDictionary = content["complications"] as? [[String: Any]] { let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } - Current.Log.info("Updating \(complications.count) complications from context (legacy format): \(complications.map(\.identifier))") + Current.Log + .info( + "Updating \(complications.count) complications from context (legacy format): \(complications.map(\.identifier))" + ) realm.reentrantWrite { realm.delete(realm.objects(WatchComplication.self)) realm.add(complications, update: .all) } - + Current.Log.info("Successfully saved \(complications.count) complications to watch database") } else { Current.Log.warning("No complications data in context - context may not be syncing from phone") @@ -303,7 +312,7 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } private var isUpdatingComplications = false - + private func updateComplications() { // avoid double-updating due to e.g. complication info update request guard !isUpdatingComplications else { return } diff --git a/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift b/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift new file mode 100644 index 0000000000..b4f958de21 --- /dev/null +++ b/Sources/Extensions/Watch/Home/WatchComplicationSyncMessages.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Constants for complication sync message identifiers and content keys +/// Used for paginated complication sync between iPhone and Apple Watch +enum WatchComplicationSyncMessages { + /// Message identifiers for complication sync protocol + enum Identifier { + /// Request to sync a single complication by index (sent by watch) + static let syncComplication = "syncComplication" + + /// Response containing complication data (sent by phone) + static let syncComplicationResponse = "syncComplicationResponse" + + /// Legacy message to sync all complications at once (deprecated) + static let syncComplications = "syncComplications" + } + + /// Content keys used in complication sync messages + enum ContentKey { + /// The index of the complication to request/send (Int) + static let index = "index" + + /// The serialized complication data (Data) + static let complicationData = "complicationData" + + /// Whether more complications are pending after this one (Bool) + static let hasMore = "hasMore" + + /// The total number of complications being synced (Int) + static let total = "total" + + /// Error message if sync failed (String) + static let error = "error" + + /// Success flag for legacy sync (Bool) + static let success = "success" + + /// Count of complications for legacy sync (Int) + static let count = "count" + } +} diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index 3283750c13..0261837563 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -52,7 +52,7 @@ final class WatchHomeViewModel: ObservableObject { return } isLoading = true - + // Request watch config via interactive message Communicator.shared.send(.init( identifier: InteractiveImmediateMessages.watchConfig.rawValue, @@ -60,117 +60,9 @@ final class WatchHomeViewModel: ObservableObject { self?.handleMessageResponse(message) } )) - - // Request complications context sync from phone - // This initiates a paginated sync where complications are sent one at a time - // The phone will respond with each complication and a flag indicating if more are pending - Current.Log.info("Requesting complications sync from phone (paginated approach)") - requestNextComplication(index: 0) - } - - /// Requests a single complication from the phone by index - /// - Parameter index: The index of the complication to request (0-based) - /// - /// This implements a paginated sync protocol: - /// 1. Watch sends interactive request with index - /// 2. Phone responds with complication at that index + "hasMore" flag in reply - /// 3. If hasMore is true, watch requests next index - /// 4. Continues until hasMore is false or error occurs - private func requestNextComplication(index: Int) { - Current.Log.info("Requesting complication at index \(index)") - - Communicator.shared.send(.init( - identifier: "syncComplication", - content: ["index": index], - reply: { [weak self] replyMessage in - self?.handleComplicationResponse(replyMessage, requestedIndex: index) - } - ), errorHandler: { error in - Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") - }) - } - - /// Handles the response for a single complication request - /// - Parameters: - /// - message: The reply message from the phone - /// - requestedIndex: The index that was requested - private func handleComplicationResponse(_ message: ImmediateMessage, requestedIndex: Int) { - // Check for error - if let error = message.content["error"] as? String { - Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") - return - } - - guard let complicationData = message.content["complicationData"] as? Data, - let hasMore = message.content["hasMore"] as? Bool, - let index = message.content["index"] as? Int, - let total = message.content["total"] as? Int else { - Current.Log.error("Invalid syncComplication response format") - return - } - - Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") - - // Save the complication - saveComplicationToDatabase(complicationData, index: index, total: total) - - // Request next complication if more are pending - if hasMore { - Current.Log.verbose("More complications pending, requesting index \(index + 1)") - requestNextComplication(index: index + 1) - } else { - Current.Log.info("Complication sync complete! Received \(total) complications") - // Trigger complication reload - reloadComplications() - } - } - - /// Saves a single complication to the watch database - /// - Parameters: - /// - complicationData: JSON data of the complication - /// - index: The index of this complication - /// - total: Total number of complications being synced - private func saveComplicationToDatabase(_ complicationData: Data, index: Int, total: Int) { - do { - guard let json = try JSONSerialization.jsonObject(with: complicationData, options: []) as? [String: Any] else { - Current.Log.error("Failed to deserialize complication JSON at index \(index)") - return - } - - guard let complication = try? WatchComplication(JSON: json) else { - Current.Log.error("Failed to create WatchComplication from JSON at index \(index)") - return - } - - Current.Log.verbose("Deserialized complication: \(complication.identifier)") - - // Save to Realm database - let realm = Current.realm() - realm.reentrantWrite { - // On first complication, clear existing ones - if index == 0 { - Current.Log.info("Clearing existing complications from watch database") - realm.delete(realm.objects(WatchComplication.self)) - } - realm.add(complication, update: .all) - } - - Current.Log.info("Saved complication \(index + 1) of \(total) to watch database") - } catch { - Current.Log.error("Failed to save complication at index \(index): \(error.localizedDescription)") - } - } - - /// Triggers a reload of all complications on the watch - private func reloadComplications() { - CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() - - if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { - Current.Log.info("Reloading \(activeComplications.count) active complications") - for complication in activeComplications { - CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) - } - } + + // Request complications sync from phone + requestComplicationsSync() } func info(for magicItem: MagicItem) -> MagicItem.Info { @@ -349,3 +241,120 @@ final class WatchHomeViewModel: ObservableObject { } } } + +// MARK: - Complication Sync + +extension WatchHomeViewModel { + /// Initiates the complication sync process from the phone + /// This starts a paginated sync where complications are sent one at a time + func requestComplicationsSync() { + Current.Log.info("Requesting complications sync from phone (paginated approach)") + requestNextComplication(index: 0) + } + + /// Requests a single complication from the phone by index + /// - Parameter index: The index of the complication to request (0-based) + /// + /// This implements a paginated sync protocol: + /// 1. Watch sends interactive request with index + /// 2. Phone responds with complication at that index + "hasMore" flag in reply + /// 3. If hasMore is true, watch requests next index + /// 4. Continues until hasMore is false or error occurs + private func requestNextComplication(index: Int) { + Current.Log.info("Requesting complication at index \(index)") + + Communicator.shared.send(.init( + identifier: WatchComplicationSyncMessages.Identifier.syncComplication, + content: [WatchComplicationSyncMessages.ContentKey.index: index], + reply: { [weak self] replyMessage in + self?.handleComplicationResponse(replyMessage, requestedIndex: index) + } + ), errorHandler: { error in + Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") + }) + } + + /// Handles the response for a single complication request + /// - Parameters: + /// - message: The reply message from the phone + /// - requestedIndex: The index that was requested + private func handleComplicationResponse(_ message: ImmediateMessage, requestedIndex: Int) { + // Check for error + if let error = message.content[WatchComplicationSyncMessages.ContentKey.error] as? String { + Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") + return + } + + guard let complicationData = message + .content[WatchComplicationSyncMessages.ContentKey.complicationData] as? Data, + let hasMore = message.content[WatchComplicationSyncMessages.ContentKey.hasMore] as? Bool, + let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int, + let total = message.content[WatchComplicationSyncMessages.ContentKey.total] as? Int else { + Current.Log.error("Invalid syncComplication response format") + return + } + + Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") + + // Save the complication + saveComplicationToDatabase(complicationData, index: index, total: total) + + // Request next complication if more are pending + if hasMore { + Current.Log.verbose("More complications pending, requesting index \(index + 1)") + requestNextComplication(index: index + 1) + } else { + Current.Log.info("Complication sync complete! Received \(total) complications") + // Trigger complication reload + reloadComplications() + } + } + + /// Saves a single complication to the watch database + /// - Parameters: + /// - complicationData: JSON data of the complication + /// - index: The index of this complication + /// - total: Total number of complications being synced + private func saveComplicationToDatabase(_ complicationData: Data, index: Int, total: Int) { + do { + guard let json = try JSONSerialization.jsonObject(with: complicationData, options: []) as? [String: Any] else { + Current.Log.error("Failed to deserialize complication JSON at index \(index)") + return + } + + guard let complication = try? WatchComplication(JSON: json) else { + Current.Log.error("Failed to create WatchComplication from JSON at index \(index)") + return + } + + Current.Log.verbose("Deserialized complication: \(complication.identifier)") + + // Save to Realm database + let realm = Current.realm() + realm.reentrantWrite { + // On first complication, clear existing ones + if index == 0 { + Current.Log.info("Clearing existing complications from watch database") + realm.delete(realm.objects(WatchComplication.self)) + } + realm.add(complication, update: .all) + } + + Current.Log.info("Saved complication \(index + 1) of \(total) to watch database") + } catch { + Current.Log.error("Failed to save complication at index \(index): \(error.localizedDescription)") + } + } + + /// Triggers a reload of all complications on the watch + private func reloadComplications() { + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() + + if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { + Current.Log.info("Reloading \(activeComplications.count) active complications") + for complication in activeComplications { + CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) + } + } + } +} diff --git a/Sources/Shared/WatchComplicationSyncMessages.swift b/Sources/Shared/WatchComplicationSyncMessages.swift new file mode 100644 index 0000000000..ce459caac8 --- /dev/null +++ b/Sources/Shared/WatchComplicationSyncMessages.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Constants for complication sync message identifiers and content keys +/// Used for paginated complication sync between iPhone and Apple Watch +public enum WatchComplicationSyncMessages { + /// Message identifiers for complication sync protocol + public enum Identifier { + /// Request to sync a single complication by index (sent by watch) + public static let syncComplication = "syncComplication" + + /// Response containing complication data (sent by phone) + public static let syncComplicationResponse = "syncComplicationResponse" + + /// Legacy message to sync all complications at once (deprecated) + public static let syncComplications = "syncComplications" + } + + /// Content keys used in complication sync messages + public enum ContentKey { + /// The index of the complication to request/send (Int) + public static let index = "index" + + /// The serialized complication data (Data) + public static let complicationData = "complicationData" + + /// Whether more complications are pending after this one (Bool) + public static let hasMore = "hasMore" + + /// The total number of complications being synced (Int) + public static let total = "total" + + /// Error message if sync failed (String) + public static let error = "error" + + /// Success flag for legacy sync (Bool) + public static let success = "success" + + /// Count of complications for legacy sync (Int) + public static let count = "count" + } +} diff --git a/Sources/Watch/WatchCommunicatorService.swift b/Sources/Watch/WatchCommunicatorService.swift index 3145f37f1c..3cb1e2d9b5 100644 --- a/Sources/Watch/WatchCommunicatorService.swift +++ b/Sources/Watch/WatchCommunicatorService.swift @@ -55,13 +55,19 @@ final class WatchCommunicatorService { Current.Log.verbose("Received \(message.identifier) \(message) \(message.content)") guard let self else { return } - + // Handle custom syncComplication message (paginated approach) - if message.identifier == "syncComplication" { - self.syncSingleComplication(message: message) + if message.identifier == WatchComplicationSyncMessages.Identifier.syncComplication { + syncSingleComplication(message: message) + return + } + + // Handle legacy syncComplications message (for backward compatibility) + if message.identifier == WatchComplicationSyncMessages.Identifier.syncComplications { + syncComplications(message: message) return } - + guard let messageId = InteractiveImmediateMessages(rawValue: message.identifier) else { Current.Log .error( @@ -175,75 +181,116 @@ final class WatchCommunicatorService { /// /// - Parameter message: The InteractiveImmediateMessage containing {"index": Int} private func syncSingleComplication(message: InteractiveImmediateMessage) { - guard let index = message.content["index"] as? Int else { + guard let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int else { Current.Log.error("syncComplication message missing 'index' parameter") message.reply(.init( - identifier: "syncComplicationResponse", + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, content: [ - "error": "Missing index parameter", - "hasMore": false, - "index": -1, - "total": 0 + WatchComplicationSyncMessages.ContentKey.error: "Missing index parameter", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: -1, + WatchComplicationSyncMessages.ContentKey.total: 0, ] )) return } - + Current.Log.info("Watch requested complication at index \(index)") - + let realm = Current.realm() let complications = realm.objects(WatchComplication.self) let total = complications.count - + // Validate index guard index >= 0, index < total else { Current.Log.error("Invalid complication index \(index) (total: \(total))") message.reply(.init( - identifier: "syncComplicationResponse", + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, content: [ - "error": "Invalid index \(index), total is \(total)", - "hasMore": false, - "index": -1, - "total": total + WatchComplicationSyncMessages.ContentKey.error: "Invalid index \(index), total is \(total)", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: -1, + WatchComplicationSyncMessages.ContentKey.total: total, ] )) return } - + let complication = complications[index] - + // Serialize this single complication guard let complicationJSONString = complication.toJSONString(), let complicationData = complicationJSONString.data(using: .utf8) else { Current.Log.error("Failed to serialize complication at index \(index)") message.reply(.init( - identifier: "syncComplicationResponse", + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, content: [ - "error": "Failed to serialize complication", - "hasMore": false, - "index": index, - "total": total + WatchComplicationSyncMessages.ContentKey.error: "Failed to serialize complication", + WatchComplicationSyncMessages.ContentKey.hasMore: false, + WatchComplicationSyncMessages.ContentKey.index: index, + WatchComplicationSyncMessages.ContentKey.total: total, ] )) return } - + let hasMore = (index + 1) < total - + Current.Log.info("Sending complication \(index + 1) of \(total) to watch (hasMore: \(hasMore))") - + // Reply with complication data message.reply(.init( - identifier: "syncComplicationResponse", + identifier: WatchComplicationSyncMessages.Identifier.syncComplicationResponse, content: [ - "complicationData": complicationData, - "hasMore": hasMore, - "index": index, - "total": total + WatchComplicationSyncMessages.ContentKey.complicationData: complicationData, + WatchComplicationSyncMessages.ContentKey.hasMore: hasMore, + WatchComplicationSyncMessages.ContentKey.index: index, + WatchComplicationSyncMessages.ContentKey.total: total, ] )) } + /// Legacy method for syncing all complications at once via Context + /// Kept for backward compatibility but may fail with large payloads + private func syncComplications(message: InteractiveImmediateMessage) { + Current.Log.info("Watch requested complications sync - fetching complications from phone database") + + let realm = Current.realm() + let complications = realm.objects(WatchComplication.self) + + Current.Log.info("Found \(complications.count) complications in phone database") + + // Convert complications to JSON data, then send as Data type + // WatchConnectivity supports Data, but not arbitrary nested dictionaries with complex types + let complicationsArray = complications.compactMap { complication -> [String: Any]? in + complication.toJSON() + } + + // Serialize the complications array to Data (JSON) + guard let complicationsData = try? JSONSerialization.data(withJSONObject: complicationsArray, options: []) else { + Current.Log.error("Failed to serialize complications to JSON data") + message.reply(.init( + identifier: "syncComplicationsResponse", + content: ["success": false, "error": "Failed to serialize complications"] + )) + return + } + + Current.Log + .verbose("Serialized \(complications.count) complications to \(complicationsData.count) bytes of JSON data") + + var contextContent: [String: Any] = [:] + // Include complications as Data (will be deserialized on watch side) + contextContent["complicationsData"] = complicationsData + Current.Log.info("Successfully sent \(complications.count) complications to watch via Context") + + // Reply to acknowledge success + message.reply(.init( + identifier: "syncComplicationsResponse", + content: ["success": true, "count": complications.count] + )) + } + private func magicItemPressed(message: InteractiveImmediateMessage) { let responseIdentifier = InteractiveImmediateResponses.magicItemRowPressedResponse.rawValue guard let itemType = message.content["itemType"] as? String, From 670b11bd6bb25dda002440b6dc1f68ca88a65ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:05:15 +0100 Subject: [PATCH 03/16] Clean up old sync --- .../Extensions/Watch/ExtensionDelegate.swift | 55 ------------------- Sources/Shared/API/WatchHelpers.swift | 1 - 2 files changed, 56 deletions(-) diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index 7f87695d56..e69c5bf7d2 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -244,8 +244,6 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } private func updateContext(_ content: Content) { - let realm = Current.realm() - // Enhanced logging to diagnose sync issues Current.Log.info("Received context update with keys: \(content.keys)") @@ -255,59 +253,6 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { } else { Current.Log.verbose("No servers data in context") } - - // Check for new serialized format (complicationsData as Data) - if let complicationsData = content["complicationsData"] as? Data { - Current.Log.info("Received complicationsData (\(complicationsData.count) bytes), deserializing...") - - do { - // Deserialize JSON data to array of dictionaries - guard let complicationsDictionary = try JSONSerialization.jsonObject( - with: complicationsData, - options: [] - ) as? [[String: Any]] else { - Current.Log.error("Failed to deserialize complications data to array of dictionaries") - return - } - - let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } - - Current.Log - .info( - "Deserialized \(complications.count) complications from data: \(complications.map(\.identifier))" - ) - - realm.reentrantWrite { - realm.delete(realm.objects(WatchComplication.self)) - realm.add(complications, update: .all) - } - - Current.Log.info("Successfully saved \(complications.count) complications to watch database") - } catch { - Current.Log.error("Failed to deserialize complications: \(error.localizedDescription)") - } - } - // Fallback: check for old format (complications as [[String: Any]]) - else if let complicationsDictionary = content["complications"] as? [[String: Any]] { - let complications = complicationsDictionary.compactMap { try? WatchComplication(JSON: $0) } - - Current.Log - .info( - "Updating \(complications.count) complications from context (legacy format): \(complications.map(\.identifier))" - ) - - realm.reentrantWrite { - realm.delete(realm.objects(WatchComplication.self)) - realm.add(complications, update: .all) - } - - Current.Log.info("Successfully saved \(complications.count) complications to watch database") - } else { - Current.Log.warning("No complications data in context - context may not be syncing from phone") - let existingCount = realm.objects(WatchComplication.self).count - Current.Log.info("Watch database currently has \(existingCount) complications") - } - updateComplications() } diff --git a/Sources/Shared/API/WatchHelpers.swift b/Sources/Shared/API/WatchHelpers.swift index 23c340d1cb..1b5b0111d0 100644 --- a/Sources/Shared/API/WatchHelpers.swift +++ b/Sources/Shared/API/WatchHelpers.swift @@ -26,7 +26,6 @@ public extension HomeAssistantAPI { #if os(iOS) content[WatchContext.servers.rawValue] = Current.servers.restorableState() - content[WatchContext.complications.rawValue] = Array(Current.realm().objects(WatchComplication.self)).toJSON() #if targetEnvironment(simulator) content[WatchContext.ssid.rawValue] = "SimulatorWiFi" From f2a3b35132e1882429404c2fcbba49f58c6784d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:35:52 +0100 Subject: [PATCH 04/16] Save complication in watch GRDB instead of Realm --- HomeAssistant.xcodeproj/project.pbxproj | 16 ++ .../Watch/Home/AppWatchComplication.swift | 154 ++++++++++++++++++ .../Home/AppWatchComplicationTable.swift | 35 ++++ .../Extensions/Watch/Home/MIGRATION_NOTES.md | 101 ++++++++++++ .../Extensions/Watch/Home/WatchHomeView.swift | 18 ++ .../Watch/Home/WatchHomeViewModel.swift | 105 ++++++++++-- Sources/Shared/Database/DatabaseTables.swift | 12 ++ .../Shared/Database/GRDB+Initialization.swift | 1 + 8 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 Sources/Extensions/Watch/Home/AppWatchComplication.swift create mode 100644 Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift create mode 100644 Sources/Extensions/Watch/Home/MIGRATION_NOTES.md diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 89abf45545..152c299f58 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -928,6 +928,11 @@ 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */; }; 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; + 42CD57272EE73E2C0032D7C5 /* MIGRATION_NOTES.md in Resources */ = {isa = PBXBuildFile; fileRef = 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */; }; + 42CD57282EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; + 42CD57292EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; + 42CD572A2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */; }; + 42CD572B2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */; }; 42CE8FA72B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; @@ -2614,6 +2619,9 @@ 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = COMPLICATION_SYNC_PROTOCOL.md; sourceTree = ""; }; 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; + 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplicationTable.swift; sourceTree = ""; }; + 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplication.swift; sourceTree = ""; }; + 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = MIGRATION_NOTES.md; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; @@ -5447,6 +5455,9 @@ 429764F52E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift */, 42CD571B2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md */, 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */, + 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */, + 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */, + 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */, ); path = Home; sourceTree = ""; @@ -7647,6 +7658,7 @@ FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */, 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */, 421BBC702E93A64B00745EC8 /* Interface.storyboard in Resources */, + 42CD57272EE73E2C0032D7C5 /* MIGRATION_NOTES.md in Resources */, 426266422C11A6700081A818 /* SharedAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -8842,6 +8854,7 @@ 11BA5ECA2759AC0300FC40E8 /* XCGLogger+Export.swift in Sources */, 42CE8FAA2B45D1E900C707F9 /* FrontendStrings.swift in Sources */, 11B38EF0275C54A300205C7B /* FireEventIntentHandler.swift in Sources */, + 42CD57292EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */, 1120C5852749C6350046C38B /* ServerProviding.swift in Sources */, 429821192CD0DEE2005ECD39 /* HAButtonStyles.swift in Sources */, 11C4628024B04CB800031902 /* Promise+RetryNetworking.swift in Sources */, @@ -8939,6 +8952,7 @@ 11C65CC1249838EB00D07FC7 /* StreamCameraResponse.swift in Sources */, 111858DB24CB7F9900B8CDDC /* SiriIntents+ConvenienceInits.swift in Sources */, 11195F73267F01E4003DF674 /* HACancellable+App.swift in Sources */, + 42CD572B2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */, 427A7CE02EBDFB4200D17841 /* AppArea+Queries.swift in Sources */, 42E3B8B92D8AC63300F5D084 /* Float+HA.swift in Sources */, B6B74CBE228399AC00D58A68 /* Action.swift in Sources */, @@ -9192,6 +9206,7 @@ 1133F59C25F1DA5D00AD776F /* CLLocation+Sanitize.swift in Sources */, 11AF4D1C249C8AA0006C74C0 /* BatterySensor.swift in Sources */, D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */, + 42CD57282EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */, 424151FC2CD8F27100D7A6F9 /* CarPlayConfig.swift in Sources */, 11169BC8262BE460005EF90A /* UNNotificationContent+Additions.swift in Sources */, 42A3B63B2BD91854007BC0F3 /* Color+Codable.swift in Sources */, @@ -9311,6 +9326,7 @@ 42A3B63E2BD918D6007BC0F3 /* MaterialDesignIcons+Encodable.swift in Sources */, 427E92BE2D65E43A0001566B /* WidgetInteractionType.swift in Sources */, 420CFC652D3F9C2C009A94F3 /* HAppEntityTable.swift in Sources */, + 42CD572A2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */, B658AA7E2250B2A000C9BFE3 /* MobileAppUpdateRegistrationRequest.swift in Sources */, 42F5CAE52B10CDC600409816 /* HACornerRadius.swift in Sources */, 1182620724F9C492000795C6 /* HACoreMediaObjectCamera.swift in Sources */, diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift new file mode 100644 index 0000000000..24f3330c28 --- /dev/null +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -0,0 +1,154 @@ +import Foundation +import GRDB + +/// AppWatchComplication represents a complication stored in the watch's GRDB database +/// It stores the complete JSON data from the iPhone's Realm WatchComplication object +public struct AppWatchComplication: Codable { + public var identifier: String + public var serverIdentifier: String? + public var rawFamily: String + public var rawTemplate: String + public var complicationData: [String: Any] + public var createdAt: Date + public var name: String? + + enum CodingKeys: String, CodingKey { + case identifier + case serverIdentifier + case rawFamily + case rawTemplate + case complicationData + case createdAt + case name + } + + public init( + identifier: String, + serverIdentifier: String?, + rawFamily: String, + rawTemplate: String, + complicationData: [String: Any], + createdAt: Date, + name: String? + ) { + self.identifier = identifier + self.serverIdentifier = serverIdentifier + self.rawFamily = rawFamily + self.rawTemplate = rawTemplate + self.complicationData = complicationData + self.createdAt = createdAt + self.name = name + } + + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.identifier = try container.decode(String.self, forKey: .identifier) + self.serverIdentifier = try container.decodeIfPresent(String.self, forKey: .serverIdentifier) + self.rawFamily = try container.decode(String.self, forKey: .rawFamily) + self.rawTemplate = try container.decode(String.self, forKey: .rawTemplate) + self.createdAt = try container.decode(Date.self, forKey: .createdAt) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + // Decode JSON string to dictionary + let jsonString = try container.decode(String.self, forKey: .complicationData) + if let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { + self.complicationData = json + } else { + self.complicationData = [:] + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(identifier, forKey: .identifier) + try container.encodeIfPresent(serverIdentifier, forKey: .serverIdentifier) + try container.encode(rawFamily, forKey: .rawFamily) + try container.encode(rawTemplate, forKey: .rawTemplate) + try container.encode(createdAt, forKey: .createdAt) + try container.encodeIfPresent(name, forKey: .name) + + // Encode dictionary to JSON string for database storage + let data = try JSONSerialization.data(withJSONObject: complicationData, options: []) + if let jsonString = String(data: data, encoding: .utf8) { + try container.encode(jsonString, forKey: .complicationData) + } + } +} + +// MARK: - GRDB Conformance + +extension AppWatchComplication: FetchableRecord, PersistableRecord { + public static var databaseTableName: String { + GRDBDatabaseTable.appWatchComplication.rawValue + } +} + +// MARK: - Convenience Methods + +public extension AppWatchComplication { + /// Creates an AppWatchComplication from JSON data received from iPhone + static func from(jsonData: Data) throws -> AppWatchComplication { + guard let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw NSError( + domain: "AppWatchComplication", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to deserialize JSON data"] + ) + } + + guard let identifier = json["identifier"] as? String else { + throw NSError( + domain: "AppWatchComplication", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing identifier in JSON"] + ) + } + + let serverIdentifier = json["serverIdentifier"] as? String + let rawFamily = json["Family"] as? String ?? "" + let rawTemplate = json["Template"] as? String ?? "" + let name = json["name"] as? String + let complicationData = json["Data"] as? [String: Any] ?? [:] + + // Parse CreatedAt date + let createdAt: Date + if let timestamp = json["CreatedAt"] as? TimeInterval { + createdAt = Date(timeIntervalSince1970: timestamp) + } else if let dateString = json["CreatedAt"] as? String { + let formatter = ISO8601DateFormatter() + createdAt = formatter.date(from: dateString) ?? Date() + } else { + createdAt = Date() + } + + return AppWatchComplication( + identifier: identifier, + serverIdentifier: serverIdentifier, + rawFamily: rawFamily, + rawTemplate: rawTemplate, + complicationData: complicationData, + createdAt: createdAt, + name: name + ) + } + + /// Fetches all complications from the database + static func fetchAll(from database: Database) throws -> [AppWatchComplication] { + try AppWatchComplication.fetchAll(database) + } + + /// Fetches a specific complication by identifier + static func fetch(identifier: String, from database: Database) throws -> AppWatchComplication? { + try AppWatchComplication + .filter(Column(DatabaseTables.AppWatchComplication.identifier.rawValue) == identifier) + .fetchOne(database) + } + + /// Deletes all complications from the database + static func deleteAll(from database: Database) throws { + try AppWatchComplication.deleteAll(database) + } +} diff --git a/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift b/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift new file mode 100644 index 0000000000..ff3894f4b8 --- /dev/null +++ b/Sources/Extensions/Watch/Home/AppWatchComplicationTable.swift @@ -0,0 +1,35 @@ +import Foundation +import GRDB + +final class AppWatchComplicationTable: DatabaseTableProtocol { + func createIfNeeded(database: DatabaseQueue) throws { + let shouldCreateTable = try database.read { db in + try !db.tableExists(GRDBDatabaseTable.appWatchComplication.rawValue) + } + if shouldCreateTable { + try database.write { db in + try db.create(table: GRDBDatabaseTable.appWatchComplication.rawValue) { t in + t.primaryKey(DatabaseTables.AppWatchComplication.identifier.rawValue, .text).notNull() + // Store the entire complication as JSON data + t.column(DatabaseTables.AppWatchComplication.complicationData.rawValue, .jsonText).notNull() + } + } + } else { + // In case a new column is added to the table, we need to alter the table + try database.write { db in + for column in DatabaseTables.AppWatchComplication.allCases { + let shouldCreateColumn = try !db.columns(in: GRDBDatabaseTable.appWatchComplication.rawValue) + .contains { columnInfo in + columnInfo.name == column.rawValue + } + + if shouldCreateColumn { + try db.alter(table: GRDBDatabaseTable.appWatchComplication.rawValue) { tableAlteration in + tableAlteration.add(column: column.rawValue) + } + } + } + } + } + } +} diff --git a/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md b/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md new file mode 100644 index 0000000000..496d5e8fb3 --- /dev/null +++ b/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md @@ -0,0 +1,101 @@ +# Migration: WatchComplication from Realm to GRDB + +## Overview +This migration moves watch complication storage from Realm to GRDB on the Apple Watch, following the same pattern used for other watch-specific data like `WatchConfig`. + +## Changes Made + +### 1. Database Schema (`DatabaseTables.swift`) +- Added `appWatchComplication` case to `GRDBDatabaseTable` enum +- Added `AppWatchComplication` table definition enum with columns: + - `identifier` (primary key, text) + - `complicationData` (JSON text containing the full complication data) + +### 2. Table Creation (`AppWatchComplicationTable.swift`) ✨ NEW FILE +- Implements `DatabaseTableProtocol` to manage table creation and updates +- Creates table with automatic column addition for future schema changes +- Follows the same pattern as `HAppEntityTable.swift` + +### 3. Model Definition (`AppWatchComplication.swift`) ✨ NEW FILE +- Swift struct conforming to `Codable`, `FetchableRecord`, and `PersistableRecord` +- Stores complete JSON data from iPhone's Realm `WatchComplication` object +- Provides convenience methods: + - `from(jsonData:)` - Creates instance from JSON data received from iPhone + - `fetchAll(from:)` - Fetches all complications + - `fetch(identifier:from:)` - Fetches specific complication + - `deleteAll(from:)` - Clears all complications + +### 4. ViewModel Update (`WatchHomeViewModel.swift`) +Updated two methods: + +#### `saveComplicationToDatabase` +- **Before:** Saved to Realm using `WatchComplication(JSON:)` and `realm.add()` +- **After:** Saves to GRDB using `AppWatchComplication.from(jsonData:)` and `db.insert()` +- Uses `onConflict: .replace` for upsert behavior +- Clears existing complications on first sync (index == 0) + +#### `fetchComplicationCount` +- **Before:** Used `realm.objects(WatchComplication.self).count` +- **After:** Uses `Current.database().read { try AppWatchComplication.fetchCount(db) }` +- Added error handling + +## Design Decisions + +### Why Store as JSON Text? +The complication data coming from iPhone is complex Realm object JSON. Rather than mapping all fields, we store the complete JSON as text, which: +- Preserves all data without loss +- Simplifies migration (no field mapping needed) +- Allows the existing `ComplicationController` to work unchanged +- Follows SQLite best practices for complex nested data + +### Primary Key: identifier +Uses `identifier` as primary key to match the Realm object's primary key, enabling proper upsert behavior when syncing from iPhone. + +## Additional Steps Required + +### 1. Update Database Initialization +The `AppWatchComplicationTable` must be registered in the database setup code. Look for where other tables like `WatchConfigTable` are initialized and add: +```swift +try AppWatchComplicationTable().createIfNeeded(database: database) +``` + +### 2. Update ComplicationController +The `ComplicationController.swift` currently reads from Realm: +```swift +Current.realm().object(ofType: WatchComplication.self, forPrimaryKey: ...) +``` + +This needs to be updated to read from GRDB: +```swift +try? Current.database().read { db in + try AppWatchComplication.fetch(identifier: identifier, from: db) +} +``` + +You'll also need to create a method to convert `AppWatchComplication` to the expected complication template format. + +### 3. Migration Strategy +Consider adding migration logic to: +- Copy existing Realm complications to GRDB on first launch +- Delete Realm complications after successful migration +- Handle cases where both databases exist + +### 4. Testing +Test the following scenarios: +- Initial sync from iPhone +- Incremental sync (updating existing complications) +- Clearing complications +- Complication count display +- Complication templates in watch faces + +## Benefits +✅ Consistent database layer (GRDB for watch data) +✅ Better performance for watch queries +✅ Simplified maintenance (one database system) +✅ Thread-safe access with GRDB's queue +✅ Easier to debug and test + +## Notes +- The iPhone still uses Realm for `WatchComplication` storage - only the watch side changed +- The sync protocol remains unchanged - complications still come as JSON data +- The paginated sync approach is preserved diff --git a/Sources/Extensions/Watch/Home/WatchHomeView.swift b/Sources/Extensions/Watch/Home/WatchHomeView.swift index 604c5a15bf..269d2e206c 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeView.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeView.swift @@ -188,6 +188,7 @@ struct WatchHomeView: View { private var footer: some View { VStack(spacing: .zero) { appVersion + complicationCount ssidLabel } .listRowBackground(Color.clear) @@ -204,6 +205,23 @@ struct WatchHomeView: View { .foregroundStyle(.secondary) } + private var complicationCount: some View { + HStack(spacing: 4) { + Text(verbatim: "Complications: \(viewModel.complicationCount)") + .font(DesignSystem.Font.caption3) + + if viewModel.isSyncingComplications { + ProgressView(value: viewModel.complicationSyncProgress) + .progressViewStyle(.circular) + .scaleEffect(0.6) + .frame(width: 12, height: 12) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .foregroundStyle(.secondary) + } + @ViewBuilder private var ssidLabel: some View { if !viewModel.currentSSID.isEmpty { diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index 0261837563..75ac41e1dc 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -1,6 +1,7 @@ import ClockKit import Communicator import Foundation +import GRDB import NetworkExtension import PromiseKit import Shared @@ -27,6 +28,16 @@ final class WatchHomeViewModel: ObservableObject { // are different, the list won't refresh. This is a workaround to force a refresh @Published var refreshListID: UUID = .init() + @Published var complicationCount: Int = 0 + @Published var isSyncingComplications: Bool = false + @Published var complicationSyncProgress: Double = 0.0 // 0.0 to 1.0 + + private var complicationCountObservation: AnyDatabaseCancellable? + + init() { + setupComplicationObservation() + } + @MainActor func fetchNetworkInfo() async { let networkInformation = await Current.networkInformation @@ -34,10 +45,47 @@ final class WatchHomeViewModel: ObservableObject { currentSSID = networkInformation?.ssid ?? "" } + @MainActor + func fetchComplicationCount() { + do { + let count = try Current.database().read { db in + try AppWatchComplication.fetchCount(db) + } + complicationCount = count + Current.Log.verbose("Fetched complication count: \(count)") + } catch { + Current.Log.error("Failed to fetch complication count from GRDB: \(error.localizedDescription)") + complicationCount = 0 + } + } + + /// Sets up database observation for AppWatchComplication changes + /// Automatically updates complicationCount when complications are added/removed + private func setupComplicationObservation() { + let observation = ValueObservation.tracking { db in + try AppWatchComplication.fetchCount(db) + } + + complicationCountObservation = observation.start( + in: Current.database(), + scheduling: .immediate, + onError: { error in + Current.Log.error("Error observing complication count: \(error.localizedDescription)") + }, + onChange: { [weak self] count in + Task { @MainActor [weak self] in + self?.complicationCount = count + Current.Log.verbose("Complication count updated via observation: \(count)") + } + } + ) + } + @MainActor func initialRoutine() { // First display whatever is in cache loadCache() + // Complication count is now automatically observed via setupComplicationObservation() // Now fetch new data in the background (shows loading indicator only for this fetch) isLoading = true requestConfig() @@ -249,6 +297,10 @@ extension WatchHomeViewModel { /// This starts a paginated sync where complications are sent one at a time func requestComplicationsSync() { Current.Log.info("Requesting complications sync from phone (paginated approach)") + Task { @MainActor in + isSyncingComplications = true + complicationSyncProgress = 0.0 + } requestNextComplication(index: 0) } @@ -269,8 +321,11 @@ extension WatchHomeViewModel { reply: { [weak self] replyMessage in self?.handleComplicationResponse(replyMessage, requestedIndex: index) } - ), errorHandler: { error in + ), errorHandler: { [weak self] error in Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") + Task { @MainActor in + self?.isSyncingComplications = false + } }) } @@ -282,6 +337,9 @@ extension WatchHomeViewModel { // Check for error if let error = message.content[WatchComplicationSyncMessages.ContentKey.error] as? String { Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") + Task { @MainActor in + isSyncingComplications = false + } return } @@ -291,11 +349,21 @@ extension WatchHomeViewModel { let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int, let total = message.content[WatchComplicationSyncMessages.ContentKey.total] as? Int else { Current.Log.error("Invalid syncComplication response format") + Task { @MainActor in + isSyncingComplications = false + } return } Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") + // Update progress + Task { @MainActor in + if total > 0 { + complicationSyncProgress = Double(index + 1) / Double(total) + } + } + // Save the complication saveComplicationToDatabase(complicationData, index: index, total: total) @@ -307,40 +375,39 @@ extension WatchHomeViewModel { Current.Log.info("Complication sync complete! Received \(total) complications") // Trigger complication reload reloadComplications() + // Mark sync as complete + Task { @MainActor in + isSyncingComplications = false + complicationSyncProgress = 1.0 + } } } - /// Saves a single complication to the watch database + /// Saves a single complication to the watch GRDB database /// - Parameters: /// - complicationData: JSON data of the complication /// - index: The index of this complication /// - total: Total number of complications being synced private func saveComplicationToDatabase(_ complicationData: Data, index: Int, total: Int) { do { - guard let json = try JSONSerialization.jsonObject(with: complicationData, options: []) as? [String: Any] else { - Current.Log.error("Failed to deserialize complication JSON at index \(index)") - return - } - - guard let complication = try? WatchComplication(JSON: json) else { - Current.Log.error("Failed to create WatchComplication from JSON at index \(index)") - return - } + // Convert JSON data to AppWatchComplication + let complication = try AppWatchComplication.from(jsonData: complicationData) Current.Log.verbose("Deserialized complication: \(complication.identifier)") - // Save to Realm database - let realm = Current.realm() - realm.reentrantWrite { + // Save to GRDB database + try Current.database().write { db in // On first complication, clear existing ones if index == 0 { - Current.Log.info("Clearing existing complications from watch database") - realm.delete(realm.objects(WatchComplication.self)) + Current.Log.info("Clearing existing complications from watch GRDB database") + try AppWatchComplication.deleteAll(from: db) } - realm.add(complication, update: .all) + + // Insert or replace the complication + try complication.insert(db, onConflict: .replace) } - Current.Log.info("Saved complication \(index + 1) of \(total) to watch database") + Current.Log.info("Saved complication \(index + 1) of \(total) to watch GRDB database") } catch { Current.Log.error("Failed to save complication at index \(index): \(error.localizedDescription)") } @@ -356,5 +423,7 @@ extension WatchHomeViewModel { CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) } } + + // Complication count will be automatically updated via database observation } } diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 7c323ba11d..40ce427511 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -9,6 +9,7 @@ public enum GRDBDatabaseTable: String { case appPanel case customWidget case appArea + case appWatchComplication // Dropped since 2025.2, now saved as json file // Context: https://github.com/groue/GRDB.swift/issues/1626#issuecomment-2623927815 @@ -84,4 +85,15 @@ public enum DatabaseTables { case icon case entities } + + // Watch Complications stored in GRDB (watch side only) + public enum AppWatchComplication: String, CaseIterable { + case identifier + case serverIdentifier + case rawFamily + case rawTemplate + case complicationData + case createdAt + case name + } } diff --git a/Sources/Shared/Database/GRDB+Initialization.swift b/Sources/Shared/Database/GRDB+Initialization.swift index 74a6f419a5..c124bafa13 100644 --- a/Sources/Shared/Database/GRDB+Initialization.swift +++ b/Sources/Shared/Database/GRDB+Initialization.swift @@ -52,6 +52,7 @@ public extension DatabaseQueue { AppPanelTable(), CustomWidgetTable(), AppAreaTable(), + AppWatchComplicationTable(), ] } From ec7d336fe8612e4875449fb6cf8c7387790ce59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:41:11 +0100 Subject: [PATCH 05/16] Fetch from GRDB instead of Realm for complications --- .../Extensions/Watch/ExtensionDelegate.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index e69c5bf7d2..8d0993997d 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -111,27 +111,39 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { // Triggered when a complication is tapped func handleUserActivity(_ userInfo: [AnyHashable: Any]?) { - let complication: WatchComplication? + let complication: AppWatchComplication? if let identifier = userInfo?[CLKLaunchedComplicationIdentifierKey] as? String, identifier != CLKDefaultComplicationIdentifier { - complication = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: identifier - ) + // Fetch from GRDB instead of Realm + do { + complication = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: identifier, from: db) + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + complication = nil + } } else if let date = userInfo?[CLKLaunchedTimelineEntryDateKey] as? Date, let clkFamily = date.complicationFamilyFromEncodedDate { let family = ComplicationGroupMember(family: clkFamily) - complication = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: family.rawValue - ) + // Fetch from GRDB using family rawValue as identifier + do { + complication = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: family.rawValue, from: db) + } + } catch { + Current.Log.error("Failed to fetch complication by family from GRDB: \(error.localizedDescription)") + complication = nil + } } else { complication = nil } if let complication { - Current.Log.info("launched for \(complication.identifier) of family \(complication.Family)") + // Parse family from rawFamily string + let familyString = complication.rawFamily.isEmpty ? "unknown" : complication.rawFamily + Current.Log.info("launched for \(complication.identifier) of family \(familyString)") } else if let identifier = userInfo?[CLKLaunchedComplicationIdentifierKey] as? String, identifier == AssistDefaultComplication.defaultComplicationId { NotificationCenter.default.post(name: AssistDefaultComplication.launchNotification, object: nil) From f49fe1a8f127da70f94dcd9e23e957420d988b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:16:37 +0100 Subject: [PATCH 06/16] Use GRDB complications --- HomeAssistant.xcodeproj/project.pbxproj | 4 - .../Complication/ComplicationController.swift | 95 +++++++++++----- .../Extensions/Watch/Home/MIGRATION_NOTES.md | 101 ------------------ .../Extensions/Watch/Home/WatchHomeView.swift | 21 ++-- .../Watch/Home/WatchHomeViewModel.swift | 53 +++------ Sources/Shared/API/WatchHelpers.swift | 35 +++++- 6 files changed, 124 insertions(+), 185 deletions(-) delete mode 100644 Sources/Extensions/Watch/Home/MIGRATION_NOTES.md diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 152c299f58..4f07bfe8e5 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -928,7 +928,6 @@ 42CD571E2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */; }; 42CD57202EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; 42CD57212EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */; }; - 42CD57272EE73E2C0032D7C5 /* MIGRATION_NOTES.md in Resources */ = {isa = PBXBuildFile; fileRef = 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */; }; 42CD57282EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; 42CD57292EE73E850032D7C5 /* AppWatchComplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */; }; 42CD572A2EE73ECB0032D7C5 /* AppWatchComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */; }; @@ -2621,7 +2620,6 @@ 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationSyncMessages.swift; sourceTree = ""; }; 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplicationTable.swift; sourceTree = ""; }; 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWatchComplication.swift; sourceTree = ""; }; - 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = MIGRATION_NOTES.md; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; @@ -5457,7 +5455,6 @@ 42CD571D2EE738D60032D7C5 /* WatchComplicationSyncMessages.swift */, 42CD57222EE73DEC0032D7C5 /* AppWatchComplicationTable.swift */, 42CD57242EE73DFD0032D7C5 /* AppWatchComplication.swift */, - 42CD57262EE73E2C0032D7C5 /* MIGRATION_NOTES.md */, ); path = Home; sourceTree = ""; @@ -7658,7 +7655,6 @@ FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */, 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */, 421BBC702E93A64B00745EC8 /* Interface.storyboard in Resources */, - 42CD57272EE73E2C0032D7C5 /* MIGRATION_NOTES.md in Resources */, 426266422C11A6700081A818 /* SharedAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 40137d812c..7930a4340e 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -1,5 +1,4 @@ import ClockKit -import RealmSwift import Shared class ComplicationController: NSObject, CLKComplicationDataSource { @@ -7,37 +6,60 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // https://github.com/LoopKit/Loop/issues/816 // https://crunchybagel.com/detecting-which-complication-was-tapped/ - private func complicationModel(for complication: CLKComplication) -> WatchComplication? { + private func complicationModel(for complication: CLKComplication) -> AppWatchComplication? { // Helper function to get a complication using the correct ID depending on watchOS version - let model: WatchComplication? - - if complication.identifier != CLKDefaultComplicationIdentifier { - // existing complications that were configured pre-7 have no identifier set - // so we can only access the value if it's a valid one. otherwise, fall back to old matching behavior. - model = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: complication.identifier - ) - } else { - // we migrate pre-existing complications, and when still using watchOS 6 create new ones, - // with the family as the identifier, so we can rely on this code path for older OS and older complications - let matchedFamily = ComplicationGroupMember(family: complication.family) - model = Current.realm().object( - ofType: WatchComplication.self, - forPrimaryKey: matchedFamily.rawValue - ) + let model: AppWatchComplication? + + do { + if complication.identifier != CLKDefaultComplicationIdentifier { + // existing complications that were configured pre-7 have no identifier set + // so we can only access the value if it's a valid one. otherwise, fall back to old matching behavior. + + // Fetch from GRDB + model = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: complication.identifier, from: db) + } + } else { + // we migrate pre-existing complications, and when still using watchOS 6 create new ones, + // with the family as the identifier, so we can rely on this code path for older OS and older complications + let matchedFamily = ComplicationGroupMember(family: complication.family) + + // Fetch from GRDB using family rawValue + model = try Current.database().read { db in + try AppWatchComplication.fetch(identifier: matchedFamily.rawValue, from: db) + } + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + model = nil } return model } + + /// Converts AppWatchComplication to WatchComplication for accessing business logic methods + private func toWatchComplication(_ appComplication: AppWatchComplication) -> WatchComplication? { + try? WatchComplication(JSON: [ + "identifier": appComplication.identifier, + "serverIdentifier": appComplication.serverIdentifier as Any, + "Family": appComplication.rawFamily, + "Template": appComplication.rawTemplate, + "Data": appComplication.complicationData, + "CreatedAt": appComplication.createdAt.timeIntervalSince1970, + "name": appComplication.name as Any, + "IsPublic": true // Default value, can be added to AppWatchComplication if needed + ]) + } private func template(for complication: CLKComplication) -> CLKComplicationTemplate { MaterialDesignIcons.register() let template: CLKComplicationTemplate - if let generated = complicationModel(for: complication)?.CLKComplicationTemplate(family: complication.family) { + if let appModel = complicationModel(for: complication), + let watchModel = toWatchComplication(appModel), + let generated = watchModel.CLKComplicationTemplate(family: complication.family) { template = generated } else if complication.identifier == AssistDefaultComplication.defaultComplicationId { template = AssistDefaultComplication.createAssistTemplate(for: complication.family) @@ -59,11 +81,15 @@ class ComplicationController: NSObject, CLKComplicationDataSource { for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { - let model = complicationModel(for: complication) - - if model?.IsPublic == false { - handler(.hideOnLockScreen) + if let appModel = complicationModel(for: complication), + let watchModel = toWatchComplication(appModel) { + if watchModel.IsPublic == false { + handler(.hideOnLockScreen) + } else { + handler(.showOnLockScreen) + } } else { + // Default to showing on lock screen if no model found handler(.showOnLockScreen) } } @@ -94,8 +120,25 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Complication Descriptors func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { - let configured = Current.realm().objects(WatchComplication.self) - .map(\.complicationDescriptor) + // Fetch complications from GRDB + let configured: [CLKComplicationDescriptor] + do { + let appComplications = try Current.database().read { db in + try AppWatchComplication.fetchAll(from: db) + } + + // Convert to WatchComplication and map to descriptors + configured = appComplications.compactMap { appComplication in + guard let watchComplication = toWatchComplication(appComplication) else { + Current.Log.error("Failed to convert AppWatchComplication to WatchComplication") + return nil + } + return watchComplication.complicationDescriptor + } + } catch { + Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") + configured = [] + } let placeholders = ComplicationGroupMember.allCases .map(\.placeholderComplicationDescriptor) diff --git a/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md b/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md deleted file mode 100644 index 496d5e8fb3..0000000000 --- a/Sources/Extensions/Watch/Home/MIGRATION_NOTES.md +++ /dev/null @@ -1,101 +0,0 @@ -# Migration: WatchComplication from Realm to GRDB - -## Overview -This migration moves watch complication storage from Realm to GRDB on the Apple Watch, following the same pattern used for other watch-specific data like `WatchConfig`. - -## Changes Made - -### 1. Database Schema (`DatabaseTables.swift`) -- Added `appWatchComplication` case to `GRDBDatabaseTable` enum -- Added `AppWatchComplication` table definition enum with columns: - - `identifier` (primary key, text) - - `complicationData` (JSON text containing the full complication data) - -### 2. Table Creation (`AppWatchComplicationTable.swift`) ✨ NEW FILE -- Implements `DatabaseTableProtocol` to manage table creation and updates -- Creates table with automatic column addition for future schema changes -- Follows the same pattern as `HAppEntityTable.swift` - -### 3. Model Definition (`AppWatchComplication.swift`) ✨ NEW FILE -- Swift struct conforming to `Codable`, `FetchableRecord`, and `PersistableRecord` -- Stores complete JSON data from iPhone's Realm `WatchComplication` object -- Provides convenience methods: - - `from(jsonData:)` - Creates instance from JSON data received from iPhone - - `fetchAll(from:)` - Fetches all complications - - `fetch(identifier:from:)` - Fetches specific complication - - `deleteAll(from:)` - Clears all complications - -### 4. ViewModel Update (`WatchHomeViewModel.swift`) -Updated two methods: - -#### `saveComplicationToDatabase` -- **Before:** Saved to Realm using `WatchComplication(JSON:)` and `realm.add()` -- **After:** Saves to GRDB using `AppWatchComplication.from(jsonData:)` and `db.insert()` -- Uses `onConflict: .replace` for upsert behavior -- Clears existing complications on first sync (index == 0) - -#### `fetchComplicationCount` -- **Before:** Used `realm.objects(WatchComplication.self).count` -- **After:** Uses `Current.database().read { try AppWatchComplication.fetchCount(db) }` -- Added error handling - -## Design Decisions - -### Why Store as JSON Text? -The complication data coming from iPhone is complex Realm object JSON. Rather than mapping all fields, we store the complete JSON as text, which: -- Preserves all data without loss -- Simplifies migration (no field mapping needed) -- Allows the existing `ComplicationController` to work unchanged -- Follows SQLite best practices for complex nested data - -### Primary Key: identifier -Uses `identifier` as primary key to match the Realm object's primary key, enabling proper upsert behavior when syncing from iPhone. - -## Additional Steps Required - -### 1. Update Database Initialization -The `AppWatchComplicationTable` must be registered in the database setup code. Look for where other tables like `WatchConfigTable` are initialized and add: -```swift -try AppWatchComplicationTable().createIfNeeded(database: database) -``` - -### 2. Update ComplicationController -The `ComplicationController.swift` currently reads from Realm: -```swift -Current.realm().object(ofType: WatchComplication.self, forPrimaryKey: ...) -``` - -This needs to be updated to read from GRDB: -```swift -try? Current.database().read { db in - try AppWatchComplication.fetch(identifier: identifier, from: db) -} -``` - -You'll also need to create a method to convert `AppWatchComplication` to the expected complication template format. - -### 3. Migration Strategy -Consider adding migration logic to: -- Copy existing Realm complications to GRDB on first launch -- Delete Realm complications after successful migration -- Handle cases where both databases exist - -### 4. Testing -Test the following scenarios: -- Initial sync from iPhone -- Incremental sync (updating existing complications) -- Clearing complications -- Complication count display -- Complication templates in watch faces - -## Benefits -✅ Consistent database layer (GRDB for watch data) -✅ Better performance for watch queries -✅ Simplified maintenance (one database system) -✅ Thread-safe access with GRDB's queue -✅ Easier to debug and test - -## Notes -- The iPhone still uses Realm for `WatchComplication` storage - only the watch side changed -- The sync protocol remains unchanged - complications still come as JSON data -- The paginated sync approach is preserved diff --git a/Sources/Extensions/Watch/Home/WatchHomeView.swift b/Sources/Extensions/Watch/Home/WatchHomeView.swift index 269d2e206c..e6563da6ba 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeView.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeView.swift @@ -204,22 +204,13 @@ struct WatchHomeView: View { .listRowBackground(Color.clear) .foregroundStyle(.secondary) } - + private var complicationCount: some View { - HStack(spacing: 4) { - Text(verbatim: "Complications: \(viewModel.complicationCount)") - .font(DesignSystem.Font.caption3) - - if viewModel.isSyncingComplications { - ProgressView(value: viewModel.complicationSyncProgress) - .progressViewStyle(.circular) - .scaleEffect(0.6) - .frame(width: 12, height: 12) - } - } - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color.clear) - .foregroundStyle(.secondary) + Text(verbatim: "Complications: \(viewModel.complicationCount)") + .font(DesignSystem.Font.caption3) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .foregroundStyle(.secondary) } @ViewBuilder diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index 75ac41e1dc..da1431f5b0 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -1,4 +1,6 @@ import ClockKit +import WidgetKit +import ClockKit import Communicator import Foundation import GRDB @@ -27,13 +29,11 @@ final class WatchHomeViewModel: ObservableObject { // If the watchConfig items are the same but it's customization properties // are different, the list won't refresh. This is a workaround to force a refresh @Published var refreshListID: UUID = .init() - + @Published var complicationCount: Int = 0 - @Published var isSyncingComplications: Bool = false - @Published var complicationSyncProgress: Double = 0.0 // 0.0 to 1.0 - + private var complicationCountObservation: AnyDatabaseCancellable? - + init() { setupComplicationObservation() } @@ -44,7 +44,7 @@ final class WatchHomeViewModel: ObservableObject { WatchUserDefaults.shared.set(networkInformation?.ssid, key: .watchSSID) currentSSID = networkInformation?.ssid ?? "" } - + @MainActor func fetchComplicationCount() { do { @@ -58,14 +58,14 @@ final class WatchHomeViewModel: ObservableObject { complicationCount = 0 } } - + /// Sets up database observation for AppWatchComplication changes /// Automatically updates complicationCount when complications are added/removed private func setupComplicationObservation() { let observation = ValueObservation.tracking { db in try AppWatchComplication.fetchCount(db) } - + complicationCountObservation = observation.start( in: Current.database(), scheduling: .immediate, @@ -297,10 +297,6 @@ extension WatchHomeViewModel { /// This starts a paginated sync where complications are sent one at a time func requestComplicationsSync() { Current.Log.info("Requesting complications sync from phone (paginated approach)") - Task { @MainActor in - isSyncingComplications = true - complicationSyncProgress = 0.0 - } requestNextComplication(index: 0) } @@ -321,11 +317,8 @@ extension WatchHomeViewModel { reply: { [weak self] replyMessage in self?.handleComplicationResponse(replyMessage, requestedIndex: index) } - ), errorHandler: { [weak self] error in + ), errorHandler: { error in Current.Log.error("Failed to send syncComplication request for index \(index): \(error)") - Task { @MainActor in - self?.isSyncingComplications = false - } }) } @@ -337,9 +330,6 @@ extension WatchHomeViewModel { // Check for error if let error = message.content[WatchComplicationSyncMessages.ContentKey.error] as? String { Current.Log.error("Received error for complication at index \(requestedIndex): \(error)") - Task { @MainActor in - isSyncingComplications = false - } return } @@ -349,21 +339,11 @@ extension WatchHomeViewModel { let index = message.content[WatchComplicationSyncMessages.ContentKey.index] as? Int, let total = message.content[WatchComplicationSyncMessages.ContentKey.total] as? Int else { Current.Log.error("Invalid syncComplication response format") - Task { @MainActor in - isSyncingComplications = false - } return } Current.Log.info("Received complication \(index + 1) of \(total) (hasMore: \(hasMore))") - // Update progress - Task { @MainActor in - if total > 0 { - complicationSyncProgress = Double(index + 1) / Double(total) - } - } - // Save the complication saveComplicationToDatabase(complicationData, index: index, total: total) @@ -375,11 +355,6 @@ extension WatchHomeViewModel { Current.Log.info("Complication sync complete! Received \(total) complications") // Trigger complication reload reloadComplications() - // Mark sync as complete - Task { @MainActor in - isSyncingComplications = false - complicationSyncProgress = 1.0 - } } } @@ -392,7 +367,7 @@ extension WatchHomeViewModel { do { // Convert JSON data to AppWatchComplication let complication = try AppWatchComplication.from(jsonData: complicationData) - + Current.Log.verbose("Deserialized complication: \(complication.identifier)") // Save to GRDB database @@ -402,7 +377,7 @@ extension WatchHomeViewModel { Current.Log.info("Clearing existing complications from watch GRDB database") try AppWatchComplication.deleteAll(from: db) } - + // Insert or replace the complication try complication.insert(db, onConflict: .replace) } @@ -415,6 +390,10 @@ extension WatchHomeViewModel { /// Triggers a reload of all complications on the watch private func reloadComplications() { + if #available(watchOS 9.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { @@ -423,7 +402,7 @@ extension WatchHomeViewModel { CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) } } - + // Complication count will be automatically updated via database observation } } diff --git a/Sources/Shared/API/WatchHelpers.swift b/Sources/Shared/API/WatchHelpers.swift index 1b5b0111d0..a64eeb34aa 100644 --- a/Sources/Shared/API/WatchHelpers.swift +++ b/Sources/Shared/API/WatchHelpers.swift @@ -79,12 +79,43 @@ public extension HomeAssistantAPI { Current.Log.verbose("skipping complication updates; no paired watch") return .value(()) } - #endif - + + // On iOS, use Realm (iPhone stores complications in Realm) let complications = Set( Current.realm().objects(WatchComplication.self) .filter("serverIdentifier = %@", server.identifier.rawValue) ) + + #elseif os(watchOS) + + // On watchOS, use GRDB instead of Realm + let complications: Set + do { + let grdbComplications = try Current.database().read { db in + try AppWatchComplication.fetchAll(from: db) + .filter { complication in + complication.serverIdentifier == server.identifier.rawValue + } + } + + // Convert AppWatchComplication to WatchComplication + complications = Set(grdbComplications.compactMap { appComplication in + try? WatchComplication(JSON: [ + "identifier": appComplication.identifier, + "serverIdentifier": appComplication.serverIdentifier as Any, + "Family": appComplication.rawFamily, + "Template": appComplication.rawTemplate, + "Data": appComplication.complicationData, // Already a [String: Any] + "CreatedAt": appComplication.createdAt.timeIntervalSince1970, + "name": appComplication.name as Any + ]) + }) + } catch { + Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") + complications = Set() + } + + #endif guard let request = WebhookResponseUpdateComplications.request(for: complications) else { Current.Log.verbose("no complications need templates rendered") From c7595aeb5f66ef11d4e0574c7e3189aa77926278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:28:26 +0100 Subject: [PATCH 07/16] Sync servers inside the watch App and render template async --- .../Complication/ComplicationController.swift | 402 ++++++++++++++++-- .../Extensions/Watch/ExtensionDelegate.swift | 9 +- .../Watch/Home/AppWatchComplication.swift | 157 +++++++ .../Extensions/Watch/Home/WatchHomeView.swift | 11 +- .../Watch/Home/WatchHomeViewModel.swift | 82 +++- Sources/Shared/API/WatchHelpers.swift | 15 +- .../Watch/InteractiveImmediateMessages.swift | 4 + Sources/Watch/WatchCommunicatorService.swift | 143 +++++++ 8 files changed, 765 insertions(+), 58 deletions(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 7930a4340e..54401ecfd8 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -1,11 +1,73 @@ import ClockKit +import Communicator import Shared +/// Controller responsible for providing complication data to ClockKit +/// +/// This class serves as the data source for all complications displayed on watch faces. +/// It fetches complication data from GRDB, converts it to ClockKit templates, and handles +/// privacy settings and complication descriptors. +/// +/// ## Architecture +/// - **Data Source**: GRDB database containing `AppWatchComplication` records +/// - **ClockKit Integration**: Implements `CLKComplicationDataSource` protocol +/// - **Fallbacks**: Provides placeholder templates when no data is available +/// +/// ## Key Responsibilities +/// 1. Fetch complications from GRDB by identifier or family +/// 2. Generate ClockKit templates for rendering on watch faces +/// 3. Manage privacy behavior (show/hide on lock screen) +/// 4. Provide complication descriptors for the watch face editor +/// +/// ## Data Flow +/// ``` +/// ClockKit Request +/// ↓ +/// complicationModel(for:) → Fetch from GRDB +/// ↓ +/// AppWatchComplication.clkComplicationTemplate() +/// ↓ +/// CLKComplicationTemplate → Display on watch face +/// ``` +/// +/// - Note: This controller is called frequently by ClockKit. Ensure database queries are optimized. +/// - Important: Always provide fallback templates to prevent blank complications. +/// +/// ## Related Types +/// - `AppWatchComplication`: GRDB data model for complications +/// - `WatchComplication`: Realm model with template generation logic (used internally) +/// - `ComplicationGroupMember`: Family groupings for complications +/// - `AssistDefaultComplication`: Special complication for launching Assist class ComplicationController: NSObject, CLKComplicationDataSource { // Helpful resources // https://github.com/LoopKit/Loop/issues/816 // https://crunchybagel.com/detecting-which-complication-was-tapped/ + // MARK: - Private Helper Methods + + /// Fetches the complication model from GRDB database + /// + /// This method handles two identifier strategies: + /// 1. **Modern approach (watchOS 7+)**: Uses `complication.identifier` to fetch by unique ID + /// 2. **Legacy approach (watchOS 6)**: Uses complication family as the identifier + /// + /// The legacy approach is necessary because: + /// - Pre-watchOS 7 complications don't have unique identifiers + /// - We store them using the family rawValue as the primary key + /// - This maintains backward compatibility with older watch faces + /// + /// - Parameter complication: The `CLKComplication` to fetch data for + /// - Returns: `AppWatchComplication` if found in database, `nil` otherwise + /// + /// ## Example Usage + /// ```swift + /// if let model = complicationModel(for: complication) { + /// let template = model.clkComplicationTemplate(family: complication.family) + /// } + /// ``` + /// + /// - Note: Database errors are logged but don't crash - returns `nil` on failure + /// - Important: This is called frequently by ClockKit, so performance matters private func complicationModel(for complication: CLKComplication) -> AppWatchComplication? { // Helper function to get a complication using the correct ID depending on watchOS version @@ -15,16 +77,17 @@ class ComplicationController: NSObject, CLKComplicationDataSource { if complication.identifier != CLKDefaultComplicationIdentifier { // existing complications that were configured pre-7 have no identifier set // so we can only access the value if it's a valid one. otherwise, fall back to old matching behavior. - + // Fetch from GRDB model = try Current.database().read { db in try AppWatchComplication.fetch(identifier: complication.identifier, from: db) } } else { // we migrate pre-existing complications, and when still using watchOS 6 create new ones, - // with the family as the identifier, so we can rely on this code path for older OS and older complications + // with the family as the identifier, so we can rely on this code path for older OS and older + // complications let matchedFamily = ComplicationGroupMember(family: complication.family) - + // Fetch from GRDB using family rawValue model = try Current.database().read { db in try AppWatchComplication.fetch(identifier: matchedFamily.rawValue, from: db) @@ -37,29 +100,39 @@ class ComplicationController: NSObject, CLKComplicationDataSource { return model } - - /// Converts AppWatchComplication to WatchComplication for accessing business logic methods - private func toWatchComplication(_ appComplication: AppWatchComplication) -> WatchComplication? { - try? WatchComplication(JSON: [ - "identifier": appComplication.identifier, - "serverIdentifier": appComplication.serverIdentifier as Any, - "Family": appComplication.rawFamily, - "Template": appComplication.rawTemplate, - "Data": appComplication.complicationData, - "CreatedAt": appComplication.createdAt.timeIntervalSince1970, - "name": appComplication.name as Any, - "IsPublic": true // Default value, can be added to AppWatchComplication if needed - ]) - } + /// Generates a ClockKit template for displaying a complication + /// + /// This method follows a priority-based fallback strategy: + /// 1. **Primary**: Try to generate template from database model + /// 2. **Assist**: Check if it's the default Assist complication + /// 3. **Fallback**: Provide a placeholder template + /// + /// The fallback ensures complications never appear blank, which would be a poor user experience. + /// + /// - Parameter complication: The `CLKComplication` to generate a template for + /// - Returns: A `CLKComplicationTemplate` ready for ClockKit to render + /// + /// ## Template Sources + /// - **Database Model**: Custom user-configured complications from Home Assistant + /// - **Assist Default**: Special complication for launching Assist with one tap + /// - **Placeholder**: Generic fallback showing the complication family name + /// + /// ## Example + /// ```swift + /// let template = template(for: complication) + /// // Always returns a valid template, never nil + /// ``` + /// + /// - Important: MaterialDesignIcons must be registered before generating templates + /// - Note: This method is called every time the watch face updates private func template(for complication: CLKComplication) -> CLKComplicationTemplate { MaterialDesignIcons.register() let template: CLKComplicationTemplate - if let appModel = complicationModel(for: complication), - let watchModel = toWatchComplication(appModel), - let generated = watchModel.CLKComplicationTemplate(family: complication.family) { + if let model = complicationModel(for: complication), + let generated = model.clkComplicationTemplate(family: complication.family) { template = generated } else if complication.identifier == AssistDefaultComplication.defaultComplicationId { template = AssistDefaultComplication.createAssistTemplate(for: complication.family) @@ -77,13 +150,32 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Configuration + /// Determines whether a complication should be visible on the lock screen + /// + /// This ClockKit delegate method is called to determine privacy behavior for each complication. + /// It allows users to hide sensitive information when their watch is locked. + /// + /// ## Privacy Modes + /// - `.showOnLockScreen`: Complication is visible even when locked (default) + /// - `.hideOnLockScreen`: Complication is hidden until watch is unlocked + /// + /// - Parameters: + /// - complication: The complication to check + /// - handler: Completion handler to call with the privacy behavior + /// + /// ## Current Implementation + /// - If model has `isPublic = false`: Hide on lock screen + /// - If model has `isPublic = true` or missing: Show on lock screen + /// - If no model found: Default to showing (fail-safe) + /// + /// - Note: Currently `isPublic` defaults to `true` for all complications + /// - Todo: Add UI to let users configure privacy per-complication func getPrivacyBehavior( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { - if let appModel = complicationModel(for: complication), - let watchModel = toWatchComplication(appModel) { - if watchModel.IsPublic == false { + if let model = complicationModel(for: complication) { + if model.isPublic == false { handler(.hideOnLockScreen) } else { handler(.showOnLockScreen) @@ -96,6 +188,39 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Population + /// Provides the current timeline entry for a complication + /// + /// This is the primary method ClockKit calls to get complication content. + /// It's called frequently (every time the watch face updates), so performance is critical. + /// + /// ## Real-Time Template Rendering + /// This method now supports real-time template rendering for complications with Jinja2 templates. + /// It will: + /// 1. Check if the complication has templates that need rendering + /// 2. Attempt to render them via the Home Assistant API + /// 3. Update the stored complication with rendered values + /// 4. Generate the template with fresh data + /// + /// If rendering fails or times out, it falls back to the last cached rendered values. + /// + /// ## Timeline Entry Components + /// - **Date**: When this entry should be displayed (encoded with family info for tracking) + /// - **Template**: The visual representation of the complication + /// + /// - Parameters: + /// - complication: The complication requesting an entry + /// - handler: Completion handler to call with the timeline entry + /// + /// ## Date Encoding + /// The date is encoded with the complication family for debugging purposes: + /// ```swift + /// // Helps identify which family triggered a tap in logs + /// let date = Date().encodedForComplication(family: complication.family) + /// ``` + /// + /// - Important: This method MUST call the handler, or the complication won't update + /// - Note: Currently only provides current entry; could be extended for future/past entries + /// - SeeAlso: `template(for:)` which generates the actual visual content func getCurrentTimelineEntry( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void @@ -105,11 +230,156 @@ class ComplicationController: NSObject, CLKComplicationDataSource { } let date = Date().encodedForComplication(family: complication.family) ?? Date() - handler(.init(date: date, complicationTemplate: template(for: complication))) + + // Try to render templates in real-time if needed + if let model = complicationModel(for: complication), + !model.rawRendered().isEmpty { + // This complication has templates that need rendering + Current.Log.info("Rendering templates in real-time for complication \(complication.identifier)") + + renderTemplatesAndProvideEntry( + for: complication, + model: model, + date: date, + handler: handler + ) + } else { + // No templates to render, use existing data + handler(.init(date: date, complicationTemplate: template(for: complication))) + } + } + + /// Renders templates in real-time and provides the complication entry + /// + /// This method: + /// 1. Extracts templates from the complication that need rendering + /// 2. Sends them to iPhone for rendering via send/reply message + /// 3. Updates the database with rendered values + /// 4. Generates and returns the updated template + /// + /// If rendering fails, it falls back to cached values. + /// + /// - Parameters: + /// - complication: The complication to render + /// - model: The complication model from database + /// - date: The date for the timeline entry + /// - handler: Completion handler to call with the entry + private func renderTemplatesAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + handler: @escaping (CLKComplicationTimelineEntry?) -> Void + ) { + guard let serverIdentifier = model.serverIdentifier else { + Current.Log.warning("No server identifier for complication, using cached values") + handler(.init(date: date, complicationTemplate: template(for: complication))) + return + } + + // Check if iPhone is reachable + guard Communicator.shared.currentReachability != .notReachable else { + Current.Log.warning("iPhone not reachable, using cached template values") + handler(.init(date: date, complicationTemplate: template(for: complication))) + return + } + + let rawTemplates = model.rawRendered() + + #if DEBUG + let timeoutSeconds: TimeInterval = 32.0 + #else + let timeoutSeconds: TimeInterval = 2.0 + #endif + + var hasCompleted = false + + // Set a timeout fallback + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { [weak self] in + guard let self else { return } + if !hasCompleted { + hasCompleted = true + Current.Log.warning("Template rendering timed out after \(timeoutSeconds)s, using cached values") + handler(.init(date: date, complicationTemplate: template(for: complication))) + } + } + + Current.Log.info("Requesting template rendering from iPhone for complication \(complication.identifier)") + + // Send render request to iPhone via Communicator + Communicator.shared.send(.init( + identifier: InteractiveImmediateMessages.renderTemplates.rawValue, + content: [ + "templates": rawTemplates, + "serverIdentifier": serverIdentifier, + ], + reply: { [weak self] replyMessage in + guard let self else { return } + guard !hasCompleted else { return } + hasCompleted = true + + // Check for error + if let error = replyMessage.content["error"] as? String { + Current.Log.error("Template rendering failed: \(error), using cached values") + handler(.init(date: date, complicationTemplate: template(for: complication))) + return + } + + // Extract rendered values + guard let renderedValues = replyMessage.content["rendered"] as? [String: Any] else { + Current.Log.error("No rendered values in response, using cached values") + handler(.init(date: date, complicationTemplate: template(for: complication))) + return + } + + Current.Log.info("Successfully received \(renderedValues.count) rendered templates from iPhone") + + // Update the database with rendered values + do { + try Current.database().write { db in + var updatedModel = model + updatedModel.updateRenderedValues(from: renderedValues) + try updatedModel.update(db) + } + + // Generate template with fresh rendered values + handler(.init(date: date, complicationTemplate: template(for: complication))) + } catch { + Current.Log.error("Failed to update complication with rendered values: \(error)") + // Still try to provide the template with cached values + handler(.init(date: date, complicationTemplate: template(for: complication))) + } + } + ), errorHandler: { error in + guard !hasCompleted else { return } + hasCompleted = true + Current.Log.error("Failed to send render request to iPhone: \(error), using cached values") + handler(.init(date: date, complicationTemplate: self.template(for: complication))) + }) } // MARK: - Placeholder Templates + /// Provides a sample template for the complication editor + /// + /// This ClockKit delegate method is called when: + /// - User is customizing their watch face + /// - Browsing available complications in the editor + /// - Previewing what the complication will look like + /// + /// ## Purpose + /// Shows a representative example of what the complication will display, + /// helping users decide if they want to add it to their watch face. + /// + /// - Parameters: + /// - complication: The complication to provide a sample for + /// - handler: Completion handler to call with the sample template + /// + /// ## Implementation + /// Currently returns the same template as `getCurrentTimelineEntry`, + /// showing real data rather than a static placeholder. + /// + /// - Note: This could be customized to show a specific sample/demo template + /// - SeeAlso: `template(for:)` for the actual template generation func getLocalizableSampleTemplate( for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void @@ -119,6 +389,40 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Complication Descriptors + /// Provides the list of all available complications for the watch face editor + /// + /// This ClockKit delegate method is called when: + /// - Watch face editor is opened + /// - User wants to add/change a complication + /// - System needs to update the available complication list + /// + /// ## Descriptor Categories + /// 1. **Configured**: User-created complications from Home Assistant (from GRDB) + /// 2. **Placeholders**: Generic placeholders for each family type + /// 3. **Assist Default**: Special complication for launching Assist + /// + /// - Parameter handler: Completion handler to call with the descriptor array + /// + /// ## Data Flow + /// ``` + /// Fetch all AppWatchComplications from GRDB + /// ↓ + /// Map to CLKComplicationDescriptors + /// ↓ + /// Add placeholders + Assist default + /// ↓ + /// Return combined list to ClockKit + /// ``` + /// + /// ## Descriptor Properties + /// Each descriptor contains: + /// - `identifier`: Unique ID for the complication + /// - `displayName`: Human-readable name shown in editor + /// - `supportedFamilies`: Which watch face slots it can fill + /// + /// - Important: This determines what users see in the watch face editor + /// - Note: Errors result in empty configured list, but placeholders are still shown + /// - SeeAlso: `AppWatchComplication.complicationDescriptor` for descriptor creation func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { // Fetch complications from GRDB let configured: [CLKComplicationDescriptor] @@ -126,15 +430,9 @@ class ComplicationController: NSObject, CLKComplicationDataSource { let appComplications = try Current.database().read { db in try AppWatchComplication.fetchAll(from: db) } - - // Convert to WatchComplication and map to descriptors - configured = appComplications.compactMap { appComplication in - guard let watchComplication = toWatchComplication(appComplication) else { - Current.Log.error("Failed to convert AppWatchComplication to WatchComplication") - return nil - } - return watchComplication.complicationDescriptor - } + + // Map directly to descriptors - no conversion needed! + configured = appComplications.map(\.complicationDescriptor) } catch { Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") configured = [] @@ -149,7 +447,45 @@ class ComplicationController: NSObject, CLKComplicationDataSource { } } +// MARK: - CLKComplicationFamily Extension + +/// Extension providing human-readable descriptions for complication families +/// +/// This extension helps with logging and debugging by providing clear names +/// for each complication family type instead of raw enum values. +/// +/// ## Complication Families +/// ClockKit supports various complication families, each designed for +/// different watch face slots and sizes: +/// +/// ### Classic Families (Pre-watchOS 7) +/// - **Circular Small**: Small circular slot +/// - **Modular Small/Large**: Modular watch face slots +/// - **Utilitarian Small/Large**: Utility-focused watch faces +/// - **Extra Large**: Large central complication +/// +/// ### Graphic Families (watchOS 7+) +/// - **Graphic Corner**: Corner slot on Infograph faces +/// - **Graphic Circular**: Circular graphic complication +/// - **Graphic Rectangular**: Rectangular graphic complication +/// - **Graphic Bezel**: Bezel around circular complications +/// +/// - Note: The `default` case handles future family types Apple may add +/// - SeeAlso: [CLKComplicationFamily +/// Documentation](https://developer.apple.com/documentation/clockkit/clkcomplicationfamily) extension CLKComplicationFamily { + /// Human-readable description of the complication family + /// + /// Provides clear, user-friendly names for each family type, + /// useful for logging and debugging. + /// + /// - Returns: A string description of the family (e.g., "Graphic Corner") + /// + /// ## Example Usage + /// ```swift + /// Current.Log.verbose("Providing template for \(complication.family.description)") + /// // Logs: "Providing template for Graphic Corner" + /// ``` var description: String { switch self { case .circularSmall: diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index 8d0993997d..092929f688 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -259,12 +259,9 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { // Enhanced logging to diagnose sync issues Current.Log.info("Received context update with keys: \(content.keys)") - if let servers = content["servers"] as? Data { - Current.servers.restoreState(servers) - Current.Log.info("Updated servers from context") - } else { - Current.Log.verbose("No servers data in context") - } + // Note: Servers are now synced via send/reply pattern + // See WatchHomeViewModel.requestServers() and WatchCommunicatorService.syncServers() + updateComplications() } diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift index 24f3330c28..df2fa94bb9 100644 --- a/Sources/Extensions/Watch/Home/AppWatchComplication.swift +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -152,3 +152,160 @@ public extension AppWatchComplication { try AppWatchComplication.deleteAll(database) } } + +// MARK: - watchOS Complication Support + +#if os(watchOS) +import ClockKit +import UIKit + +public extension AppWatchComplication { + /// Display name for the complication + var displayName: String { + name ?? template.style + } + + /// Whether the complication should be shown on lock screen + /// Default to true since we don't store this yet + var isPublic: Bool { + // TODO: Add isPublic field to database schema if needed + true + } + + /// The Family enum from rawFamily string + var family: ComplicationGroupMember { + ComplicationGroupMember(rawValue: rawFamily) ?? .modularSmall + } + + /// The Template enum from rawTemplate string + var template: ComplicationTemplate { + ComplicationTemplate(rawValue: rawTemplate) ?? family.templates.first! + } + + // MARK: - Rendered Values Support + + /// Enum representing different types of renderable values in a complication + enum RenderedValueType: Hashable { + case textArea(String) + case gauge + case ring + + init?(stringValue: String) { + let values = stringValue.components(separatedBy: ",") + + guard values.count >= 1 else { + return nil + } + + switch values[0] { + case "textArea" where values.count >= 2: + self = .textArea(values[1]) + case "gauge": + self = .gauge + case "ring": + self = .ring + default: + return nil + } + } + + var stringValue: String { + switch self { + case let .textArea(value): return "textArea,\(value)" + case .gauge: return "gauge" + case .ring: return "ring" + } + } + } + + /// Returns the rendered values dictionary from server template rendering + func renderedValues() -> [RenderedValueType: Any] { + (complicationData["rendered"] as? [String: Any] ?? [:]) + .compactMapKeys(RenderedValueType.init(stringValue:)) + } + + /// Updates the rendered values with response from server + /// - Parameter response: Dictionary of rendered template values from webhook + mutating func updateRenderedValues(from response: [String: Any]) { + complicationData["rendered"] = response + } + + /// Returns the raw unrendered template strings that need server-side rendering + /// Used by webhook system to request template rendering from Home Assistant + func rawRendered() -> [String: String] { + var renders = [RenderedValueType: String]() + + if let textAreas = complicationData["textAreas"] as? [String: [String: Any]], textAreas.isEmpty == false { + let toAdd = textAreas.compactMapValues { $0["text"] as? String } + .filter { $1.containsJinjaTemplate } // Note: Requires String extension from Shared module + .mapKeys { RenderedValueType.textArea($0) } + renders.merge(toAdd, uniquingKeysWith: { a, _ in a }) + } + + if let gaugeDict = complicationData["gauge"] as? [String: String], + let gauge = gaugeDict["gauge"], gauge.containsJinjaTemplate { + renders[.gauge] = gauge + } + + if let ringDict = complicationData["ring"] as? [String: String], + let ringValue = ringDict["ring_value"], ringValue.containsJinjaTemplate { + renders[.ring] = ringValue + } + + return renders.mapKeys { $0.stringValue } + } + + /// Complication descriptor for ClockKit + var complicationDescriptor: CLKComplicationDescriptor { + CLKComplicationDescriptor( + identifier: identifier, + displayName: displayName, + supportedFamilies: [family.family] + ) + } + + /// Generate CLKComplicationTemplate for display + /// This delegates to the WatchComplication implementation temporarily + /// TODO: Port template generation logic directly to AppWatchComplication + func clkComplicationTemplate(family complicationFamily: CLKComplicationFamily) -> CLKComplicationTemplate? { + // For now, convert to WatchComplication to use existing template logic + // This is a temporary solution until we fully port the template generation + guard let watchComplication = try? WatchComplication(JSON: [ + "identifier": identifier, + "serverIdentifier": serverIdentifier as Any, + "Family": rawFamily, + "Template": rawTemplate, + "Data": complicationData, + "CreatedAt": createdAt.timeIntervalSince1970, + "name": name as Any, + "IsPublic": true, + ]) else { + return nil + } + + return watchComplication.CLKComplicationTemplate(family: complicationFamily) + } +} + +// MARK: - Dictionary Helpers + +fileprivate extension Dictionary { + func mapKeys(_ transform: (Key) -> T) -> [T: Value] { + var result = [T: Value]() + for (key, value) in self { + result[transform(key)] = value + } + return result + } + + func compactMapKeys(_ transform: (Key) -> T?) -> [T: Value] { + var result = [T: Value]() + for (key, value) in self { + if let newKey = transform(key) { + result[newKey] = value + } + } + return result + } +} +#endif diff --git a/Sources/Extensions/Watch/Home/WatchHomeView.swift b/Sources/Extensions/Watch/Home/WatchHomeView.swift index e6563da6ba..a20f1aca15 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeView.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeView.swift @@ -189,6 +189,7 @@ struct WatchHomeView: View { VStack(spacing: .zero) { appVersion complicationCount + serversCount ssidLabel } .listRowBackground(Color.clear) @@ -204,7 +205,7 @@ struct WatchHomeView: View { .listRowBackground(Color.clear) .foregroundStyle(.secondary) } - + private var complicationCount: some View { Text(verbatim: "Complications: \(viewModel.complicationCount)") .font(DesignSystem.Font.caption3) @@ -213,6 +214,14 @@ struct WatchHomeView: View { .foregroundStyle(.secondary) } + private var serversCount: some View { + Text(verbatim: "Servers: \(viewModel.serversCount)") + .font(DesignSystem.Font.caption3) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .foregroundStyle(.secondary) + } + @ViewBuilder private var ssidLabel: some View { if !viewModel.currentSSID.isEmpty { diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index da1431f5b0..1ba7bec5d7 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -1,12 +1,11 @@ import ClockKit -import WidgetKit -import ClockKit import Communicator import Foundation import GRDB import NetworkExtension import PromiseKit import Shared +import WidgetKit enum WatchHomeType { case undefined @@ -29,11 +28,12 @@ final class WatchHomeViewModel: ObservableObject { // If the watchConfig items are the same but it's customization properties // are different, the list won't refresh. This is a workaround to force a refresh @Published var refreshListID: UUID = .init() - + @Published var complicationCount: Int = 0 - + @Published var serversCount: Int = 0 + private var complicationCountObservation: AnyDatabaseCancellable? - + init() { setupComplicationObservation() } @@ -44,7 +44,7 @@ final class WatchHomeViewModel: ObservableObject { WatchUserDefaults.shared.set(networkInformation?.ssid, key: .watchSSID) currentSSID = networkInformation?.ssid ?? "" } - + @MainActor func fetchComplicationCount() { do { @@ -58,14 +58,14 @@ final class WatchHomeViewModel: ObservableObject { complicationCount = 0 } } - + /// Sets up database observation for AppWatchComplication changes /// Automatically updates complicationCount when complications are added/removed private func setupComplicationObservation() { let observation = ValueObservation.tracking { db in try AppWatchComplication.fetchCount(db) } - + complicationCountObservation = observation.start( in: Current.database(), scheduling: .immediate, @@ -86,6 +86,13 @@ final class WatchHomeViewModel: ObservableObject { // First display whatever is in cache loadCache() // Complication count is now automatically observed via setupComplicationObservation() + + // Set initial servers count from current state + serversCount = Current.servers.all.count + + // Request servers from phone + requestServers() + // Now fetch new data in the background (shows loading indicator only for this fetch) isLoading = true requestConfig() @@ -113,6 +120,59 @@ final class WatchHomeViewModel: ObservableObject { requestComplicationsSync() } + /// Requests server configuration from the iPhone + /// + /// This method implements the send/reply pattern for syncing servers from iPhone to Watch. + /// It replaces the legacy context-based sync to avoid payload size limits. + /// + /// Flow: + /// 1. Watch sends "syncServers" message to iPhone + /// 2. iPhone replies with server data + /// 3. Watch restores servers from data (same as ExtensionDelegate.updateContext) + @MainActor + func requestServers() { + guard Communicator.shared.currentReachability != .notReachable else { + Current.Log.warning("Cannot sync servers - iPhone not reachable") + return + } + + Current.Log.info("Requesting servers from iPhone") + + Communicator.shared.send(.init( + identifier: InteractiveImmediateMessages.syncServers.rawValue, + reply: { [weak self] message in + self?.handleServersResponse(message) + } + ), errorHandler: { error in + Current.Log.error("Failed to request servers from iPhone: \(error)") + }) + } + + /// Handles the server sync response from the iPhone + /// - Parameter message: Reply message containing server data + @MainActor + private func handleServersResponse(_ message: ImmediateMessage) { + guard message.identifier == InteractiveImmediateResponses.syncServersResponse.rawValue else { + Current.Log.error("Received unexpected response identifier for servers: \(message.identifier)") + return + } + + guard let serversData = message.content["servers"] as? Data else { + Current.Log.error("No servers data in syncServers response") + return + } + + Current.Log.info("Received \(serversData.count) bytes of server data from iPhone") + + // Restore servers - same logic as ExtensionDelegate.updateContext + Current.servers.restoreState(serversData) + + // Update servers count + serversCount = Current.servers.all.count + + Current.Log.info("Successfully restored \(serversCount) servers on watch") + } + func info(for magicItem: MagicItem) -> MagicItem.Info { magicItemsInfo.first(where: { $0.id == magicItem.serverUniqueId @@ -367,7 +427,7 @@ extension WatchHomeViewModel { do { // Convert JSON data to AppWatchComplication let complication = try AppWatchComplication.from(jsonData: complicationData) - + Current.Log.verbose("Deserialized complication: \(complication.identifier)") // Save to GRDB database @@ -377,7 +437,7 @@ extension WatchHomeViewModel { Current.Log.info("Clearing existing complications from watch GRDB database") try AppWatchComplication.deleteAll(from: db) } - + // Insert or replace the complication try complication.insert(db, onConflict: .replace) } @@ -402,7 +462,7 @@ extension WatchHomeViewModel { CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) } } - + // Complication count will be automatically updated via database observation } } diff --git a/Sources/Shared/API/WatchHelpers.swift b/Sources/Shared/API/WatchHelpers.swift index a64eeb34aa..b491d5b7e9 100644 --- a/Sources/Shared/API/WatchHelpers.swift +++ b/Sources/Shared/API/WatchHelpers.swift @@ -25,7 +25,8 @@ public extension HomeAssistantAPI { var content: Content = Communicator.shared.mostRecentlyReceievedContext.content #if os(iOS) - content[WatchContext.servers.rawValue] = Current.servers.restorableState() + // Note: Servers are now synced via send/reply pattern, not context + // See WatchHomeViewModel.requestServers() and WatchCommunicatorService.syncServers() #if targetEnvironment(simulator) content[WatchContext.ssid.rawValue] = "SimulatorWiFi" @@ -79,15 +80,15 @@ public extension HomeAssistantAPI { Current.Log.verbose("skipping complication updates; no paired watch") return .value(()) } - + // On iOS, use Realm (iPhone stores complications in Realm) let complications = Set( Current.realm().objects(WatchComplication.self) .filter("serverIdentifier = %@", server.identifier.rawValue) ) - + #elseif os(watchOS) - + // On watchOS, use GRDB instead of Realm let complications: Set do { @@ -97,7 +98,7 @@ public extension HomeAssistantAPI { complication.serverIdentifier == server.identifier.rawValue } } - + // Convert AppWatchComplication to WatchComplication complications = Set(grdbComplications.compactMap { appComplication in try? WatchComplication(JSON: [ @@ -107,14 +108,14 @@ public extension HomeAssistantAPI { "Template": appComplication.rawTemplate, "Data": appComplication.complicationData, // Already a [String: Any] "CreatedAt": appComplication.createdAt.timeIntervalSince1970, - "name": appComplication.name as Any + "name": appComplication.name as Any, ]) }) } catch { Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") complications = Set() } - + #endif guard let request = WebhookResponseUpdateComplications.request(for: complications) else { diff --git a/Sources/Shared/Watch/InteractiveImmediateMessages.swift b/Sources/Shared/Watch/InteractiveImmediateMessages.swift index 072f8b7ce4..d1b2fc9a98 100644 --- a/Sources/Shared/Watch/InteractiveImmediateMessages.swift +++ b/Sources/Shared/Watch/InteractiveImmediateMessages.swift @@ -8,6 +8,8 @@ public enum InteractiveImmediateMessages: String, CaseIterable { case assistPipelinesFetch case assistAudioDataChunked case watchConfig + case syncServers + case renderTemplates } public enum InteractiveImmediateResponses: String, CaseIterable { @@ -23,4 +25,6 @@ public enum InteractiveImmediateResponses: String, CaseIterable { case assistError case watchConfigResponse case emptyWatchConfigResponse + case syncServersResponse + case renderTemplatesResponse } diff --git a/Sources/Watch/WatchCommunicatorService.swift b/Sources/Watch/WatchCommunicatorService.swift index 3cb1e2d9b5..2c455417ea 100644 --- a/Sources/Watch/WatchCommunicatorService.swift +++ b/Sources/Watch/WatchCommunicatorService.swift @@ -81,6 +81,10 @@ final class WatchCommunicatorService { message.reply(.init(identifier: InteractiveImmediateResponses.pong.rawValue)) case .watchConfig: watchConfig(message: message) + case .syncServers: + syncServers(message: message) + case .renderTemplates: + renderTemplates(message: message) case .actionRowPressed: actionRowPressed(message: message) case .pushAction: @@ -167,6 +171,145 @@ final class WatchCommunicatorService { message.reply(.init(identifier: responseIdentifier)) } + /// Syncs server configuration to the watch via send/reply pattern + /// + /// This method handles requests from the watch for server configuration data. + /// It serializes the current server state and sends it back via a reply message. + /// + /// Protocol: + /// 1. Watch sends "syncServers" InteractiveImmediateMessage + /// 2. Phone replies with "syncServersResponse" containing server data + /// 3. Watch restores servers from the data (same as ExtensionDelegate.updateContext does) + /// + /// - Parameter message: The InteractiveImmediateMessage requesting servers + private func syncServers(message: InteractiveImmediateMessage) { + Current.Log.info("Watch requested servers sync") + + let serversData = Current.servers.restorableState() + + Current.Log.info("Sending \(serversData.count) bytes of server data to watch") + + message.reply(.init( + identifier: InteractiveImmediateResponses.syncServersResponse.rawValue, + content: ["servers": serversData] + )) + } + + /// Renders Jinja2 templates on behalf of the watch + /// + /// This method handles template rendering requests from watch complications. + /// The iPhone is better positioned to render templates because: + /// - Better network connectivity to Home Assistant + /// - More processing power + /// - Can handle longer timeouts without blocking the UI + /// + /// Protocol: + /// 1. Watch sends "renderTemplates" message with templates dictionary and server ID + /// 2. iPhone renders templates via Home Assistant API + /// 3. iPhone replies with rendered values or error + /// + /// - Parameter message: The InteractiveImmediateMessage containing: + /// - "templates": [String: String] - Dictionary of template keys to template strings + /// - "serverIdentifier": String - Server identifier to use for rendering + private func renderTemplates(message: InteractiveImmediateMessage) { + guard let templatesDict = message.content["templates"] as? [String: String], + let serverIdentifier = message.content["serverIdentifier"] as? String else { + Current.Log.error("Invalid renderTemplates request - missing templates or serverIdentifier") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Missing required parameters"] + )) + return + } + + guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverIdentifier }), + let connection = Current.api(for: server)?.connection else { + Current.Log.error("No API available for server \(serverIdentifier)") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "No API available for server"] + )) + return + } + + Current.Log.info("Rendering \(templatesDict.count) templates for server \(serverIdentifier)") + + // Create a combined template string with keys as markers + // Format: "key1:::{{template1}}|||key2:::{{template2}}|||..." + let combinedTemplate = templatesDict + .map { key, template in "\(key):::\(template)" } + .joined(separator: "|||") + + // Send render request to Home Assistant + connection.send(.init( + type: .rest(.post, "template"), + data: ["template": combinedTemplate], + shouldRetry: true + )) { [weak self] result in + guard let self else { return } + + switch result { + case let .success(data): + // Parse the response + var renderedResults: [String: Any] = [:] + + switch data { + case let .primitive(response): + if let renderedString = response as? String { + // Split the response back into individual results + let parts = renderedString.components(separatedBy: "|||") + + for part in parts { + let keyValue = part.components(separatedBy: ":::") + if keyValue.count == 2 { + let key = keyValue[0] + let value = keyValue[1] + renderedResults[key] = value + } + } + + if renderedResults.count == templatesDict.count { + Current.Log.info("Successfully rendered \(renderedResults.count) templates") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["rendered": renderedResults] + )) + } else { + Current.Log + .error( + "Rendered count mismatch: expected \(templatesDict.count), got \(renderedResults.count)" + ) + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Rendered count mismatch"] + )) + } + } else { + Current.Log.error("Template rendering returned non-string response") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Invalid response format"] + )) + } + + default: + Current.Log.error("Template rendering returned unexpected data type") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": "Unexpected response data type"] + )) + } + + case let .failure(error): + Current.Log.error("Failed to render templates: \(error)") + message.reply(.init( + identifier: InteractiveImmediateResponses.renderTemplatesResponse.rawValue, + content: ["error": error.localizedDescription] + )) + } + } + } + /// Syncs a single complication to the watch by index (paginated approach with reply) /// This avoids payload size limits by sending complications one at a time. /// From cc98b61d146693c5b90c5c670243c499b9862977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:38:58 +0100 Subject: [PATCH 08/16] Set complication to update every 15 minutes --- .../Complication/ComplicationController.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 54401ecfd8..8c160de181 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -186,6 +186,44 @@ class ComplicationController: NSObject, CLKComplicationDataSource { } } + /// Schedules the next requested update date for complications + /// + /// This ClockKit delegate method tells the system when it should request fresh complication data. + /// By returning the soonest reasonable date, we maximize update frequency within watchOS constraints. + /// + /// ## Update Frequency Strategy + /// We request updates as frequently as possible to keep Home Assistant data fresh: + /// - **Current implementation**: Every 15 minutes (matches watchOS budget) + /// - **watchOS budget**: System limits background updates to ~4 per hour maximum + /// - **Actual frequency**: watchOS decides based on battery, usage, and our requests + /// + /// ## Why 15 Minutes? + /// - Aligns with watchOS budget of 4 updates/hour (60 minutes ÷ 4 = 15 minutes) + /// - Avoids wasting requests that would be ignored by the system + /// - Balances freshness with battery efficiency + /// - For complications with templates, this ensures rendered values stay reasonably current + /// - System may extend the interval if battery is low or watch face isn't active + /// + /// ## Real-Time Updates + /// This method handles scheduled updates. For immediate updates when data changes, use: + /// ```swift + /// CLKComplicationServer.sharedInstance().reloadActiveComplications() + /// ``` + /// + /// - Parameter handler: Completion handler to call with the next update date + /// + /// - Note: Returning `nil` tells the system "no more updates needed" + /// - Important: The system may adjust or ignore this date based on resources + /// - SeeAlso: `getCurrentTimelineEntry(for:withHandler:)` which is called when update triggers + func getNextRequestedUpdateDate(handler: @escaping (Date?) -> Void) { + // Request update in 15 minutes - aligns with watchOS budget of ~4 updates per hour + let nextUpdate = Date(timeIntervalSinceNow: 15 * 60) + + Current.Log.verbose("Scheduling next complication update for \(nextUpdate)") + + handler(nextUpdate) + } + // MARK: - Timeline Population /// Provides the current timeline entry for a complication From 05e60cfc0381b2d63d5c04af646f347a4031c3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:12:54 +0100 Subject: [PATCH 09/16] Remove code to update complications templates --- .../Complication/ComplicationController.swift | 7 +- .../Extensions/Watch/ExtensionDelegate.swift | 31 +- .../Watch/Home/AppWatchComplication.swift | 607 +++++++++++++++++- .../Watch/Home/WatchHomeViewModel.swift | 2 - Sources/Shared/API/HAAPI.swift | 1 - Sources/Shared/API/WatchHelpers.swift | 65 -- 6 files changed, 607 insertions(+), 106 deletions(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 8c160de181..8c43efb7ac 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -34,8 +34,7 @@ import Shared /// - Important: Always provide fallback templates to prevent blank complications. /// /// ## Related Types -/// - `AppWatchComplication`: GRDB data model for complications -/// - `WatchComplication`: Realm model with template generation logic (used internally) +/// - `AppWatchComplication`: GRDB data model for complications with template generation /// - `ComplicationGroupMember`: Family groupings for complications /// - `AssistDefaultComplication`: Special complication for launching Assist class ComplicationController: NSObject, CLKComplicationDataSource { @@ -218,9 +217,9 @@ class ComplicationController: NSObject, CLKComplicationDataSource { func getNextRequestedUpdateDate(handler: @escaping (Date?) -> Void) { // Request update in 15 minutes - aligns with watchOS budget of ~4 updates per hour let nextUpdate = Date(timeIntervalSinceNow: 15 * 60) - + Current.Log.verbose("Scheduling next complication update for \(nextUpdate)") - + handler(nextUpdate) } diff --git a/Sources/Extensions/Watch/ExtensionDelegate.swift b/Sources/Extensions/Watch/ExtensionDelegate.swift index 092929f688..9a3410feb4 100644 --- a/Sources/Extensions/Watch/ExtensionDelegate.swift +++ b/Sources/Extensions/Watch/ExtensionDelegate.swift @@ -4,6 +4,7 @@ import PromiseKit import Shared import UserNotifications import WatchKit +import WidgetKit import XCGLogger class ExtensionDelegate: NSObject, WKExtensionDelegate { @@ -72,14 +73,8 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { case let backgroundTask as WKApplicationRefreshBackgroundTask: // Be sure to complete the background task once you’re done. Current.Log.verbose("WKApplicationRefreshBackgroundTask received") - - firstly { - when(fulfilled: Current.apis.map { $0.updateComplications(passively: true) }) - }.ensureThen { - Current.backgroundRefreshScheduler.schedule() - }.ensure { - backgroundTask.setTaskCompletedWithSnapshot(false) - }.cauterize() + // No need to update complication here anymore since they render templates by themselves now + backgroundTask.setTaskCompletedWithSnapshot(false) case let snapshotTask as WKSnapshotRefreshBackgroundTask: // Snapshot tasks have a unique completion call, make sure to set your expiration date snapshotTask.setTaskCompleted( @@ -268,18 +263,18 @@ class ExtensionDelegate: NSObject, WKExtensionDelegate { private var isUpdatingComplications = false private func updateComplications() { - // avoid double-updating due to e.g. complication info update request - guard !isUpdatingComplications else { return } + if #available(watchOS 9.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } - isUpdatingComplications = true + CLKComplicationServer.sharedInstance().reloadComplicationDescriptors() - firstly { - when(fulfilled: Current.apis.map { $0.updateComplications(passively: true) }) - }.ensure { [self] in - isUpdatingComplications = false - }.ensure { [self] in - endWatchConnectivityBackgroundTaskIfNecessary() - }.cauterize() + if let activeComplications = CLKComplicationServer.sharedInstance().activeComplications { + Current.Log.info("Reloading \(activeComplications.count) active complications") + for complication in activeComplications { + CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) + } + } } func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift index df2fa94bb9..350b1292b2 100644 --- a/Sources/Extensions/Watch/Home/AppWatchComplication.swift +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -2,7 +2,10 @@ import Foundation import GRDB /// AppWatchComplication represents a complication stored in the watch's GRDB database -/// It stores the complete JSON data from the iPhone's Realm WatchComplication object +/// It stores the complete JSON data from the iPhone and handles all template generation +/// +/// This struct is fully self-contained and does not depend on Realm for any functionality. +/// All ClockKit template generation is performed directly from the stored GRDB data. public struct AppWatchComplication: Codable { public var identifier: String public var serverIdentifier: String? @@ -265,25 +268,597 @@ public extension AppWatchComplication { } /// Generate CLKComplicationTemplate for display - /// This delegates to the WatchComplication implementation temporarily - /// TODO: Port template generation logic directly to AppWatchComplication + /// This generates the template directly from GRDB data without using Realm func clkComplicationTemplate(family complicationFamily: CLKComplicationFamily) -> CLKComplicationTemplate? { - // For now, convert to WatchComplication to use existing template logic - // This is a temporary solution until we fully port the template generation - guard let watchComplication = try? WatchComplication(JSON: [ - "identifier": identifier, - "serverIdentifier": serverIdentifier as Any, - "Family": rawFamily, - "Template": rawTemplate, - "Data": complicationData, - "CreatedAt": createdAt.timeIntervalSince1970, - "name": name as Any, - "IsPublic": true, - ]) else { + // Create the template based on the stored template type and family + let templateGenerator = ComplicationTemplateGenerator( + family: complicationFamily, + rawTemplate: rawTemplate, + data: complicationData, + renderedValues: renderedValues() + ) + + return templateGenerator.generate() + } +} + +// MARK: - Template Generation + +/// Handles generation of CLKComplicationTemplates from stored data +private struct ComplicationTemplateGenerator { + let family: CLKComplicationFamily + let rawTemplate: String + let data: [String: Any] + let renderedValues: [AppWatchComplication.RenderedValueType: Any] + + func generate() -> CLKComplicationTemplate? { + // Generate template based on family + switch family { + case .graphicRectangular: + return generateGraphicRectangular() + case .graphicCircular: + return generateGraphicCircular() + case .graphicCorner: + return generateGraphicCorner() + case .graphicBezel: + return generateGraphicBezel() + case .modularSmall: + return generateModularSmall() + case .modularLarge: + return generateModularLarge() + case .utilitarianSmall, .utilitarianSmallFlat: + return generateUtilitarianSmall() + case .utilitarianLarge: + return generateUtilitarianLarge() + case .circularSmall: + return generateCircularSmall() + case .extraLarge: + return generateExtraLarge() + case .graphicExtraLarge: + return generateGraphicExtraLarge() + @unknown default: + return nil + } + } + + // MARK: - Graphic Templates + + private func generateGraphicRectangular() -> CLKComplicationTemplate { + // Use string matching instead of enum cases + if rawTemplate.contains("TextGauge") { + return generateGraphicRectangularTextGauge() + } else if rawTemplate.contains("LargeImage") { + return generateGraphicRectangularLargeImage() + } else { + return generateGraphicRectangularStandardBody() + } + } + + private func generateGraphicCircular() -> CLKComplicationTemplate { + if rawTemplate.contains("OpenGauge") { + return generateGraphicCircularOpenGaugeImage() + } else if rawTemplate.contains("ClosedGauge") { + return generateGraphicCircularClosedGaugeImage() + } else { + return generateGraphicCircularImage() + } + } + + private func generateGraphicCorner() -> CLKComplicationTemplate { + if rawTemplate.contains("GaugeImage") { + return generateGraphicCornerGaugeImage() + } else if rawTemplate.contains("CircularImage") { + return generateGraphicCornerCircularImage() + } else { + return generateGraphicCornerTextImage() + } + } + + private func generateGraphicBezel() -> CLKComplicationTemplate { + // Graphic bezel wraps a circular template + let circularTemplate = generateGraphicCircular() + + guard let circularGraphicTemplate = circularTemplate as? CLKComplicationTemplateGraphicCircular else { + // Fallback: create a simple circular template + let gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .white, fillFraction: 0.5) + let centerTextProvider = CLKSimpleTextProvider(text: "HA") + let fallbackCircular = CLKComplicationTemplateGraphicCircularClosedGaugeText( + gaugeProvider: gaugeProvider, + centerTextProvider: centerTextProvider + ) + let textProvider = self.textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") + return CLKComplicationTemplateGraphicBezelCircularText( + circularTemplate: fallbackCircular, + textProvider: textProvider + ) + } + + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") + + return CLKComplicationTemplateGraphicBezelCircularText( + circularTemplate: circularGraphicTemplate, + textProvider: textProvider + ) + } + + // MARK: - Modular Templates + + private func generateModularSmall() -> CLKComplicationTemplate { + if rawTemplate.contains("SimpleText") { + return generateModularSmallSimpleText() + } else if rawTemplate.contains("RingImage") { + return generateModularSmallRingImage() + } else if rawTemplate.contains("StackImage") { + return generateModularSmallStackImage() + } else { + return generateModularSmallSimpleImage() + } + } + + private func generateModularLarge() -> CLKComplicationTemplate { + if rawTemplate.contains("TallBody") { + return generateModularLargeTallBody() + } else if rawTemplate.contains("Table") { + return generateModularLargeTable() + } else { + return generateModularLargeStandardBody() + } + } + + // MARK: - Utilitarian Templates + + private func generateUtilitarianSmall() -> CLKComplicationTemplate { + let imageProvider = imageProvider(for: "icon") + let textProvider = textProvider(for: "line1") + + if let imageProvider { + return CLKComplicationTemplateUtilitarianSmallSquare(imageProvider: imageProvider) + } else if let textProvider { + return CLKComplicationTemplateUtilitarianSmallFlat(textProvider: textProvider) + } + + return CLKComplicationTemplateUtilitarianSmallFlat( + textProvider: CLKSimpleTextProvider(text: "HA") + ) + } + + private func generateUtilitarianLarge() -> CLKComplicationTemplate { + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") + return CLKComplicationTemplateUtilitarianLargeFlat(textProvider: textProvider) + } + + // MARK: - Circular Templates + + private func generateCircularSmall() -> CLKComplicationTemplate { + if let imageProvider = imageProvider(for: "icon") { + return CLKComplicationTemplateCircularSmallSimpleImage(imageProvider: imageProvider) + } + + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") + return CLKComplicationTemplateCircularSmallSimpleText(textProvider: textProvider) + } + + private func generateExtraLarge() -> CLKComplicationTemplate { + if let imageProvider = imageProvider(for: "icon") { + return CLKComplicationTemplateExtraLargeSimpleImage(imageProvider: imageProvider) + } + + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") + return CLKComplicationTemplateExtraLargeSimpleText(textProvider: textProvider) + } + + private func generateGraphicExtraLarge() -> CLKComplicationTemplate { + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicExtraLargeCircularImage(imageProvider: imageProvider) + } + + let textProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "HA") + return CLKComplicationTemplateGraphicExtraLargeCircularStackText( + line1TextProvider: textProvider, + line2TextProvider: self.textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "") + ) + } + + // MARK: - Specific Template Implementations + + private func generateGraphicRectangularStandardBody() -> CLKComplicationTemplate { + let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") + let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Ready") + + return CLKComplicationTemplateGraphicRectangularStandardBody( + headerTextProvider: headerTextProvider, + body1TextProvider: body1TextProvider + ) + } + + private func generateGraphicRectangularTextGauge() -> CLKComplicationTemplate { + let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") + let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Status") + let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: .white, + fillFraction: 0.5 + ) + + return CLKComplicationTemplateGraphicRectangularTextGauge( + headerTextProvider: headerTextProvider, + body1TextProvider: body1TextProvider, + gaugeProvider: gaugeProvider + ) + } + + private func generateGraphicRectangularLargeImage() -> CLKComplicationTemplate { + if let imageProvider = fullColorImageProvider(for: "image") { + // GraphicRectangularLargeImage requires non-optional textProvider + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "") + return CLKComplicationTemplateGraphicRectangularLargeImage( + textProvider: textProvider, + imageProvider: imageProvider + ) + } + + // Fallback to standard body if no image + return generateGraphicRectangularStandardBody() + } + + private func generateGraphicCircularImage() -> CLKComplicationTemplate { + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCircularImage(imageProvider: imageProvider) + } + + // Fallback: create a simple closed gauge with text + let gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .white, fillFraction: 0.5) + let centerTextProvider = CLKSimpleTextProvider(text: "HA") + return CLKComplicationTemplateGraphicCircularClosedGaugeText( + gaugeProvider: gaugeProvider, + centerTextProvider: centerTextProvider + ) + } + + private func generateGraphicCircularOpenGaugeImage() -> CLKComplicationTemplate { + let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: .white, + fillFraction: 0.5 + ) + + let bottomTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "50%") + let centerTextProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "") + + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCircularOpenGaugeImage( + gaugeProvider: gaugeProvider, + bottomImageProvider: imageProvider, + centerTextProvider: centerTextProvider + ) + } + + return CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText( + gaugeProvider: gaugeProvider, + bottomTextProvider: bottomTextProvider, + centerTextProvider: centerTextProvider + ) + } + + private func generateGraphicCircularClosedGaugeImage() -> CLKComplicationTemplate { + let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: .white, + fillFraction: 0.5 + ) + + let centerTextProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "50%") + + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCircularClosedGaugeImage( + gaugeProvider: gaugeProvider, + imageProvider: imageProvider + ) + } + + return CLKComplicationTemplateGraphicCircularClosedGaugeText( + gaugeProvider: gaugeProvider, + centerTextProvider: centerTextProvider + ) + } + + private func generateGraphicCornerGaugeImage() -> CLKComplicationTemplate { + let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: .white, + fillFraction: 0.5 + ) + + let outerTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") + + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCornerGaugeImage( + gaugeProvider: gaugeProvider, + leadingTextProvider: nil, + trailingTextProvider: nil, + imageProvider: imageProvider + ) + } + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gaugeProvider, + leadingTextProvider: nil, + trailingTextProvider: nil, + outerTextProvider: outerTextProvider + ) + } + + private func generateGraphicCornerTextImage() -> CLKComplicationTemplate { + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home") + + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCornerTextImage( + textProvider: textProvider, + imageProvider: imageProvider + ) + } + + // Fallback to text-only corner template + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: textProvider, + outerTextProvider: CLKSimpleTextProvider(text: "HA") + ) + } + + private func generateGraphicCornerCircularImage() -> CLKComplicationTemplate { + if let imageProvider = fullColorImageProvider(for: "icon") { + return CLKComplicationTemplateGraphicCornerCircularImage(imageProvider: imageProvider) + } + + // Fallback to text image + return generateGraphicCornerTextImage() + } + + private func generateModularSmallSimpleImage() -> CLKComplicationTemplate { + if let imageProvider = imageProvider(for: "icon") { + return CLKComplicationTemplateModularSmallSimpleImage(imageProvider: imageProvider) + } + + // Fallback to text + return generateModularSmallSimpleText() + } + + private func generateModularSmallSimpleText() -> CLKComplicationTemplate { + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") + return CLKComplicationTemplateModularSmallSimpleText(textProvider: textProvider) + } + + private func generateModularSmallRingImage() -> CLKComplicationTemplate { + let fillFraction = ringFillFraction() + let ringStyle: CLKComplicationRingStyle = fillFraction > 0 ? .closed : .open + + if let imageProvider = imageProvider(for: "icon") { + return CLKComplicationTemplateModularSmallRingImage( + imageProvider: imageProvider, + fillFraction: fillFraction, + ringStyle: ringStyle + ) + } + + // Fallback to ring text + let textProvider = CLKSimpleTextProvider(text: String(format: "%.0f%%", fillFraction * 100)) + return CLKComplicationTemplateModularSmallRingText( + textProvider: textProvider, + fillFraction: fillFraction, + ringStyle: ringStyle + ) + } + + private func generateModularSmallStackImage() -> CLKComplicationTemplate { + let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") + + if let imageProvider = imageProvider(for: "icon") { + return CLKComplicationTemplateModularSmallStackImage( + line1ImageProvider: imageProvider, + line2TextProvider: textProvider + ) + } + + // Fallback to stack text + return CLKComplicationTemplateModularSmallStackText( + line1TextProvider: CLKSimpleTextProvider(text: "HA"), + line2TextProvider: textProvider + ) + } + + private func generateModularLargeStandardBody() -> CLKComplicationTemplate { + let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") + let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Ready") + + let body2TextProvider = textProvider(for: "line2") + + if let imageProvider = imageProvider(for: "icon"), let body2 = body2TextProvider { + return CLKComplicationTemplateModularLargeStandardBody( + headerImageProvider: imageProvider, + headerTextProvider: headerTextProvider, + body1TextProvider: body1TextProvider, + body2TextProvider: body2 + ) + } + + return CLKComplicationTemplateModularLargeStandardBody( + headerTextProvider: headerTextProvider, + body1TextProvider: body1TextProvider + ) + } + + private func generateModularLargeTallBody() -> CLKComplicationTemplate { + let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") + let bodyTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Status: Ready") + + return CLKComplicationTemplateModularLargeTallBody( + headerTextProvider: headerTextProvider, + bodyTextProvider: bodyTextProvider + ) + } + + private func generateModularLargeTable() -> CLKComplicationTemplate { + let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") + let row1Column1TextProvider = textProvider(for: "row1col1") ?? CLKSimpleTextProvider(text: "Status") + let row1Column2TextProvider = textProvider(for: "row1col2") ?? CLKSimpleTextProvider(text: "Ready") + let row2Column1TextProvider = textProvider(for: "row2col1") ?? CLKSimpleTextProvider(text: "") + let row2Column2TextProvider = textProvider(for: "row2col2") ?? CLKSimpleTextProvider(text: "") + + return CLKComplicationTemplateModularLargeTable( + headerTextProvider: headerTextProvider, + row1Column1TextProvider: row1Column1TextProvider, + row1Column2TextProvider: row1Column2TextProvider, + row2Column1TextProvider: row2Column1TextProvider, + row2Column2TextProvider: row2Column2TextProvider + ) + } + + // MARK: - Helper Methods + + /// Extract text provider for a given key + private func textProvider(for key: String) -> CLKTextProvider? { + // Check rendered values first + let renderedKey = AppWatchComplication.RenderedValueType.textArea(key) + if let renderedText = renderedValues[renderedKey] as? String { + return CLKSimpleTextProvider(text: renderedText) + } + + // Fall back to text areas in data + if let textAreas = data["textAreas"] as? [String: [String: Any]], + let textArea = textAreas[key], + let text = textArea["text"] as? String { + return CLKSimpleTextProvider(text: text) + } + + // Check direct key + if let text = data[key] as? String { + return CLKSimpleTextProvider(text: text) + } + + return nil + } + + /// Extract image provider for a given key + private func imageProvider(for key: String) -> CLKImageProvider? { + guard let icon = data[key] as? [String: Any], + let iconName = icon["icon"] as? String else { + return nil + } + + // Create MaterialDesignIcons icon + // MaterialDesignIcons initializer doesn't return optional, it returns the icon or uses a fallback + let mdiIcon = MaterialDesignIcons(named: iconName) + + // Generate image from icon + let image = mdiIcon.image(ofSize: CGSize(width: 32, height: 32), color: .white) + return CLKImageProvider(onePieceImage: image) + } + + /// Extract full color image provider for a given key + private func fullColorImageProvider(for key: String) -> CLKFullColorImageProvider? { + guard let icon = data[key] as? [String: Any], + let iconName = icon["icon"] as? String else { + return nil + } + + // Create MaterialDesignIcons icon + // MaterialDesignIcons initializer doesn't return optional, it returns the icon or uses a fallback + let mdiIcon = MaterialDesignIcons(named: iconName) + + // Get color if specified, otherwise use white + let color: UIColor + if let colorHex = icon["color"] as? String { + color = UIColor(hex: colorHex) ?? .white + } else { + color = .white + } + + // Generate full-color image from icon + let image = mdiIcon.image(ofSize: CGSize(width: 48, height: 48), color: color) + return CLKFullColorImageProvider(fullColorImage: image) + } + + /// Extract gauge provider + private func gaugeProvider() -> CLKGaugeProvider? { + // Check rendered gauge value + if let gaugeValue = renderedValues[.gauge] as? Double { + return CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: gaugeColor(), + fillFraction: Float(max(0, min(1, gaugeValue))) + ) + } + + // Check data + if let gaugeDict = data["gauge"] as? [String: Any], + let gaugeValue = gaugeDict["gauge"] as? Double { + return CLKSimpleGaugeProvider( + style: .fill, + gaugeColor: gaugeColor(), + fillFraction: Float(max(0, min(1, gaugeValue))) + ) + } + + return nil + } + + /// Extract gauge color + private func gaugeColor() -> UIColor { + if let gaugeDict = data["gauge"] as? [String: Any], + let colorHex = gaugeDict["gauge_color"] as? String { + return UIColor(hex: colorHex) ?? .white + } + return .white + } + + /// Extract ring fill fraction + private func ringFillFraction() -> Float { + // Check rendered ring value + if let ringValue = renderedValues[.ring] as? Double { + return Float(max(0, min(1, ringValue))) + } + + // Check data + if let ringDict = data["ring"] as? [String: Any], + let ringValue = ringDict["ring_value"] as? Double { + return Float(max(0, min(1, ringValue))) + } + + return 0.5 + } +} + +// MARK: - UIColor Hex Extension + +private extension UIColor { + convenience init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { + return nil + } + + let length = hexSanitized.count + let r, g, b, a: CGFloat + + if length == 6 { + r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + b = CGFloat(rgb & 0x0000FF) / 255.0 + a = 1.0 + } else if length == 8 { + r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0 + g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0 + b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0 + a = CGFloat(rgb & 0x0000_00FF) / 255.0 + } else { return nil } - return watchComplication.CLKComplicationTemplate(family: complicationFamily) + self.init(red: r, green: g, blue: b, alpha: a) } } diff --git a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift index 1ba7bec5d7..64ada933f3 100644 --- a/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift +++ b/Sources/Extensions/Watch/Home/WatchHomeViewModel.swift @@ -462,7 +462,5 @@ extension WatchHomeViewModel { CLKComplicationServer.sharedInstance().reloadTimeline(for: complication) } } - - // Complication count will be automatically updated via database observation } } diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 6d5f12efaa..c2fe3917db 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -225,7 +225,6 @@ public class HomeAssistantAPI { if !Current.isAppExtension { promises.append(getConfig()) promises.append(Current.modelManager.fetch(apis: [self])) - promises.append(updateComplications(passively: false).asVoid()) } promises.append(UpdateSensors(trigger: reason.updateSensorTrigger).asVoid()) diff --git a/Sources/Shared/API/WatchHelpers.swift b/Sources/Shared/API/WatchHelpers.swift index b491d5b7e9..54f1d08c9b 100644 --- a/Sources/Shared/API/WatchHelpers.swift +++ b/Sources/Shared/API/WatchHelpers.swift @@ -73,69 +73,4 @@ public extension HomeAssistantAPI { return nil } - - func updateComplications(passively: Bool) -> Promise { - #if os(iOS) - guard case .paired = Communicator.shared.currentWatchState else { - Current.Log.verbose("skipping complication updates; no paired watch") - return .value(()) - } - - // On iOS, use Realm (iPhone stores complications in Realm) - let complications = Set( - Current.realm().objects(WatchComplication.self) - .filter("serverIdentifier = %@", server.identifier.rawValue) - ) - - #elseif os(watchOS) - - // On watchOS, use GRDB instead of Realm - let complications: Set - do { - let grdbComplications = try Current.database().read { db in - try AppWatchComplication.fetchAll(from: db) - .filter { complication in - complication.serverIdentifier == server.identifier.rawValue - } - } - - // Convert AppWatchComplication to WatchComplication - complications = Set(grdbComplications.compactMap { appComplication in - try? WatchComplication(JSON: [ - "identifier": appComplication.identifier, - "serverIdentifier": appComplication.serverIdentifier as Any, - "Family": appComplication.rawFamily, - "Template": appComplication.rawTemplate, - "Data": appComplication.complicationData, // Already a [String: Any] - "CreatedAt": appComplication.createdAt.timeIntervalSince1970, - "name": appComplication.name as Any, - ]) - }) - } catch { - Current.Log.error("Failed to fetch complications from GRDB: \(error.localizedDescription)") - complications = Set() - } - - #endif - - guard let request = WebhookResponseUpdateComplications.request(for: complications) else { - Current.Log.verbose("no complications need templates rendered") - - #if os(iOS) - // in case the user deleted the last complication, sync that fact up to the watch - _ = HomeAssistantAPI.SyncWatchContext() - #else - // in case the user updated just the complication's metadata, force a refresh - WebhookResponseUpdateComplications.updateComplications() - #endif - - return .value(()) - } - - if passively { - return Current.webhooks.sendPassive(identifier: .updateComplications, server: server, request: request) - } else { - return Current.webhooks.send(identifier: .updateComplications, server: server, request: request) - } - } } From 29b506e5e7d6ff45b553e387c262a007703c9456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:37:25 +0100 Subject: [PATCH 10/16] Refresh complication more often when debugging --- .../Watch/Complication/ComplicationController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 8c43efb7ac..26b9c74ca6 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -215,9 +215,12 @@ class ComplicationController: NSObject, CLKComplicationDataSource { /// - Important: The system may adjust or ignore this date based on resources /// - SeeAlso: `getCurrentTimelineEntry(for:withHandler:)` which is called when update triggers func getNextRequestedUpdateDate(handler: @escaping (Date?) -> Void) { + #if DEBUG + let nextUpdate = Date(timeIntervalSinceNow: 60) + #else // Request update in 15 minutes - aligns with watchOS budget of ~4 updates per hour let nextUpdate = Date(timeIntervalSinceNow: 15 * 60) - + #endif Current.Log.verbose("Scheduling next complication update for \(nextUpdate)") handler(nextUpdate) From 67cd5eb37926af4f00cbf3e4b299275b243970d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:40:16 +0100 Subject: [PATCH 11/16] Revert changes to keep using WatchComplication styling for now --- .../Complication/ComplicationController.swift | 2 +- .../Watch/Home/AppWatchComplication.swift | 607 +----------------- 2 files changed, 17 insertions(+), 592 deletions(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 26b9c74ca6..fac043fd69 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -328,7 +328,7 @@ class ComplicationController: NSObject, CLKComplicationDataSource { #if DEBUG let timeoutSeconds: TimeInterval = 32.0 #else - let timeoutSeconds: TimeInterval = 2.0 + let timeoutSeconds: TimeInterval = 10.0 #endif var hasCompleted = false diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift index 350b1292b2..df2fa94bb9 100644 --- a/Sources/Extensions/Watch/Home/AppWatchComplication.swift +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -2,10 +2,7 @@ import Foundation import GRDB /// AppWatchComplication represents a complication stored in the watch's GRDB database -/// It stores the complete JSON data from the iPhone and handles all template generation -/// -/// This struct is fully self-contained and does not depend on Realm for any functionality. -/// All ClockKit template generation is performed directly from the stored GRDB data. +/// It stores the complete JSON data from the iPhone's Realm WatchComplication object public struct AppWatchComplication: Codable { public var identifier: String public var serverIdentifier: String? @@ -268,597 +265,25 @@ public extension AppWatchComplication { } /// Generate CLKComplicationTemplate for display - /// This generates the template directly from GRDB data without using Realm + /// This delegates to the WatchComplication implementation temporarily + /// TODO: Port template generation logic directly to AppWatchComplication func clkComplicationTemplate(family complicationFamily: CLKComplicationFamily) -> CLKComplicationTemplate? { - // Create the template based on the stored template type and family - let templateGenerator = ComplicationTemplateGenerator( - family: complicationFamily, - rawTemplate: rawTemplate, - data: complicationData, - renderedValues: renderedValues() - ) - - return templateGenerator.generate() - } -} - -// MARK: - Template Generation - -/// Handles generation of CLKComplicationTemplates from stored data -private struct ComplicationTemplateGenerator { - let family: CLKComplicationFamily - let rawTemplate: String - let data: [String: Any] - let renderedValues: [AppWatchComplication.RenderedValueType: Any] - - func generate() -> CLKComplicationTemplate? { - // Generate template based on family - switch family { - case .graphicRectangular: - return generateGraphicRectangular() - case .graphicCircular: - return generateGraphicCircular() - case .graphicCorner: - return generateGraphicCorner() - case .graphicBezel: - return generateGraphicBezel() - case .modularSmall: - return generateModularSmall() - case .modularLarge: - return generateModularLarge() - case .utilitarianSmall, .utilitarianSmallFlat: - return generateUtilitarianSmall() - case .utilitarianLarge: - return generateUtilitarianLarge() - case .circularSmall: - return generateCircularSmall() - case .extraLarge: - return generateExtraLarge() - case .graphicExtraLarge: - return generateGraphicExtraLarge() - @unknown default: - return nil - } - } - - // MARK: - Graphic Templates - - private func generateGraphicRectangular() -> CLKComplicationTemplate { - // Use string matching instead of enum cases - if rawTemplate.contains("TextGauge") { - return generateGraphicRectangularTextGauge() - } else if rawTemplate.contains("LargeImage") { - return generateGraphicRectangularLargeImage() - } else { - return generateGraphicRectangularStandardBody() - } - } - - private func generateGraphicCircular() -> CLKComplicationTemplate { - if rawTemplate.contains("OpenGauge") { - return generateGraphicCircularOpenGaugeImage() - } else if rawTemplate.contains("ClosedGauge") { - return generateGraphicCircularClosedGaugeImage() - } else { - return generateGraphicCircularImage() - } - } - - private func generateGraphicCorner() -> CLKComplicationTemplate { - if rawTemplate.contains("GaugeImage") { - return generateGraphicCornerGaugeImage() - } else if rawTemplate.contains("CircularImage") { - return generateGraphicCornerCircularImage() - } else { - return generateGraphicCornerTextImage() - } - } - - private func generateGraphicBezel() -> CLKComplicationTemplate { - // Graphic bezel wraps a circular template - let circularTemplate = generateGraphicCircular() - - guard let circularGraphicTemplate = circularTemplate as? CLKComplicationTemplateGraphicCircular else { - // Fallback: create a simple circular template - let gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .white, fillFraction: 0.5) - let centerTextProvider = CLKSimpleTextProvider(text: "HA") - let fallbackCircular = CLKComplicationTemplateGraphicCircularClosedGaugeText( - gaugeProvider: gaugeProvider, - centerTextProvider: centerTextProvider - ) - let textProvider = self.textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") - return CLKComplicationTemplateGraphicBezelCircularText( - circularTemplate: fallbackCircular, - textProvider: textProvider - ) - } - - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") - - return CLKComplicationTemplateGraphicBezelCircularText( - circularTemplate: circularGraphicTemplate, - textProvider: textProvider - ) - } - - // MARK: - Modular Templates - - private func generateModularSmall() -> CLKComplicationTemplate { - if rawTemplate.contains("SimpleText") { - return generateModularSmallSimpleText() - } else if rawTemplate.contains("RingImage") { - return generateModularSmallRingImage() - } else if rawTemplate.contains("StackImage") { - return generateModularSmallStackImage() - } else { - return generateModularSmallSimpleImage() - } - } - - private func generateModularLarge() -> CLKComplicationTemplate { - if rawTemplate.contains("TallBody") { - return generateModularLargeTallBody() - } else if rawTemplate.contains("Table") { - return generateModularLargeTable() - } else { - return generateModularLargeStandardBody() - } - } - - // MARK: - Utilitarian Templates - - private func generateUtilitarianSmall() -> CLKComplicationTemplate { - let imageProvider = imageProvider(for: "icon") - let textProvider = textProvider(for: "line1") - - if let imageProvider { - return CLKComplicationTemplateUtilitarianSmallSquare(imageProvider: imageProvider) - } else if let textProvider { - return CLKComplicationTemplateUtilitarianSmallFlat(textProvider: textProvider) - } - - return CLKComplicationTemplateUtilitarianSmallFlat( - textProvider: CLKSimpleTextProvider(text: "HA") - ) - } - - private func generateUtilitarianLarge() -> CLKComplicationTemplate { - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home Assistant") - return CLKComplicationTemplateUtilitarianLargeFlat(textProvider: textProvider) - } - - // MARK: - Circular Templates - - private func generateCircularSmall() -> CLKComplicationTemplate { - if let imageProvider = imageProvider(for: "icon") { - return CLKComplicationTemplateCircularSmallSimpleImage(imageProvider: imageProvider) - } - - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") - return CLKComplicationTemplateCircularSmallSimpleText(textProvider: textProvider) - } - - private func generateExtraLarge() -> CLKComplicationTemplate { - if let imageProvider = imageProvider(for: "icon") { - return CLKComplicationTemplateExtraLargeSimpleImage(imageProvider: imageProvider) - } - - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") - return CLKComplicationTemplateExtraLargeSimpleText(textProvider: textProvider) - } - - private func generateGraphicExtraLarge() -> CLKComplicationTemplate { - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicExtraLargeCircularImage(imageProvider: imageProvider) - } - - let textProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "HA") - return CLKComplicationTemplateGraphicExtraLargeCircularStackText( - line1TextProvider: textProvider, - line2TextProvider: self.textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "") - ) - } - - // MARK: - Specific Template Implementations - - private func generateGraphicRectangularStandardBody() -> CLKComplicationTemplate { - let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") - let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Ready") - - return CLKComplicationTemplateGraphicRectangularStandardBody( - headerTextProvider: headerTextProvider, - body1TextProvider: body1TextProvider - ) - } - - private func generateGraphicRectangularTextGauge() -> CLKComplicationTemplate { - let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") - let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Status") - let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: .white, - fillFraction: 0.5 - ) - - return CLKComplicationTemplateGraphicRectangularTextGauge( - headerTextProvider: headerTextProvider, - body1TextProvider: body1TextProvider, - gaugeProvider: gaugeProvider - ) - } - - private func generateGraphicRectangularLargeImage() -> CLKComplicationTemplate { - if let imageProvider = fullColorImageProvider(for: "image") { - // GraphicRectangularLargeImage requires non-optional textProvider - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "") - return CLKComplicationTemplateGraphicRectangularLargeImage( - textProvider: textProvider, - imageProvider: imageProvider - ) - } - - // Fallback to standard body if no image - return generateGraphicRectangularStandardBody() - } - - private func generateGraphicCircularImage() -> CLKComplicationTemplate { - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCircularImage(imageProvider: imageProvider) - } - - // Fallback: create a simple closed gauge with text - let gaugeProvider = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .white, fillFraction: 0.5) - let centerTextProvider = CLKSimpleTextProvider(text: "HA") - return CLKComplicationTemplateGraphicCircularClosedGaugeText( - gaugeProvider: gaugeProvider, - centerTextProvider: centerTextProvider - ) - } - - private func generateGraphicCircularOpenGaugeImage() -> CLKComplicationTemplate { - let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: .white, - fillFraction: 0.5 - ) - - let bottomTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "50%") - let centerTextProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "") - - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCircularOpenGaugeImage( - gaugeProvider: gaugeProvider, - bottomImageProvider: imageProvider, - centerTextProvider: centerTextProvider - ) - } - - return CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText( - gaugeProvider: gaugeProvider, - bottomTextProvider: bottomTextProvider, - centerTextProvider: centerTextProvider - ) - } - - private func generateGraphicCircularClosedGaugeImage() -> CLKComplicationTemplate { - let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: .white, - fillFraction: 0.5 - ) - - let centerTextProvider = textProvider(for: "center") ?? CLKSimpleTextProvider(text: "50%") - - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCircularClosedGaugeImage( - gaugeProvider: gaugeProvider, - imageProvider: imageProvider - ) - } - - return CLKComplicationTemplateGraphicCircularClosedGaugeText( - gaugeProvider: gaugeProvider, - centerTextProvider: centerTextProvider - ) - } - - private func generateGraphicCornerGaugeImage() -> CLKComplicationTemplate { - let gaugeProvider = gaugeProvider() ?? CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: .white, - fillFraction: 0.5 - ) - - let outerTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") - - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCornerGaugeImage( - gaugeProvider: gaugeProvider, - leadingTextProvider: nil, - trailingTextProvider: nil, - imageProvider: imageProvider - ) - } - - return CLKComplicationTemplateGraphicCornerGaugeText( - gaugeProvider: gaugeProvider, - leadingTextProvider: nil, - trailingTextProvider: nil, - outerTextProvider: outerTextProvider - ) - } - - private func generateGraphicCornerTextImage() -> CLKComplicationTemplate { - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Home") - - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCornerTextImage( - textProvider: textProvider, - imageProvider: imageProvider - ) - } - - // Fallback to text-only corner template - return CLKComplicationTemplateGraphicCornerStackText( - innerTextProvider: textProvider, - outerTextProvider: CLKSimpleTextProvider(text: "HA") - ) - } - - private func generateGraphicCornerCircularImage() -> CLKComplicationTemplate { - if let imageProvider = fullColorImageProvider(for: "icon") { - return CLKComplicationTemplateGraphicCornerCircularImage(imageProvider: imageProvider) - } - - // Fallback to text image - return generateGraphicCornerTextImage() - } - - private func generateModularSmallSimpleImage() -> CLKComplicationTemplate { - if let imageProvider = imageProvider(for: "icon") { - return CLKComplicationTemplateModularSmallSimpleImage(imageProvider: imageProvider) - } - - // Fallback to text - return generateModularSmallSimpleText() - } - - private func generateModularSmallSimpleText() -> CLKComplicationTemplate { - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") - return CLKComplicationTemplateModularSmallSimpleText(textProvider: textProvider) - } - - private func generateModularSmallRingImage() -> CLKComplicationTemplate { - let fillFraction = ringFillFraction() - let ringStyle: CLKComplicationRingStyle = fillFraction > 0 ? .closed : .open - - if let imageProvider = imageProvider(for: "icon") { - return CLKComplicationTemplateModularSmallRingImage( - imageProvider: imageProvider, - fillFraction: fillFraction, - ringStyle: ringStyle - ) - } - - // Fallback to ring text - let textProvider = CLKSimpleTextProvider(text: String(format: "%.0f%%", fillFraction * 100)) - return CLKComplicationTemplateModularSmallRingText( - textProvider: textProvider, - fillFraction: fillFraction, - ringStyle: ringStyle - ) - } - - private func generateModularSmallStackImage() -> CLKComplicationTemplate { - let textProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "HA") - - if let imageProvider = imageProvider(for: "icon") { - return CLKComplicationTemplateModularSmallStackImage( - line1ImageProvider: imageProvider, - line2TextProvider: textProvider - ) - } - - // Fallback to stack text - return CLKComplicationTemplateModularSmallStackText( - line1TextProvider: CLKSimpleTextProvider(text: "HA"), - line2TextProvider: textProvider - ) - } - - private func generateModularLargeStandardBody() -> CLKComplicationTemplate { - let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") - let body1TextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Ready") - - let body2TextProvider = textProvider(for: "line2") - - if let imageProvider = imageProvider(for: "icon"), let body2 = body2TextProvider { - return CLKComplicationTemplateModularLargeStandardBody( - headerImageProvider: imageProvider, - headerTextProvider: headerTextProvider, - body1TextProvider: body1TextProvider, - body2TextProvider: body2 - ) - } - - return CLKComplicationTemplateModularLargeStandardBody( - headerTextProvider: headerTextProvider, - body1TextProvider: body1TextProvider - ) - } - - private func generateModularLargeTallBody() -> CLKComplicationTemplate { - let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") - let bodyTextProvider = textProvider(for: "line1") ?? CLKSimpleTextProvider(text: "Status: Ready") - - return CLKComplicationTemplateModularLargeTallBody( - headerTextProvider: headerTextProvider, - bodyTextProvider: bodyTextProvider - ) - } - - private func generateModularLargeTable() -> CLKComplicationTemplate { - let headerTextProvider = textProvider(for: "header") ?? CLKSimpleTextProvider(text: "Home Assistant") - let row1Column1TextProvider = textProvider(for: "row1col1") ?? CLKSimpleTextProvider(text: "Status") - let row1Column2TextProvider = textProvider(for: "row1col2") ?? CLKSimpleTextProvider(text: "Ready") - let row2Column1TextProvider = textProvider(for: "row2col1") ?? CLKSimpleTextProvider(text: "") - let row2Column2TextProvider = textProvider(for: "row2col2") ?? CLKSimpleTextProvider(text: "") - - return CLKComplicationTemplateModularLargeTable( - headerTextProvider: headerTextProvider, - row1Column1TextProvider: row1Column1TextProvider, - row1Column2TextProvider: row1Column2TextProvider, - row2Column1TextProvider: row2Column1TextProvider, - row2Column2TextProvider: row2Column2TextProvider - ) - } - - // MARK: - Helper Methods - - /// Extract text provider for a given key - private func textProvider(for key: String) -> CLKTextProvider? { - // Check rendered values first - let renderedKey = AppWatchComplication.RenderedValueType.textArea(key) - if let renderedText = renderedValues[renderedKey] as? String { - return CLKSimpleTextProvider(text: renderedText) - } - - // Fall back to text areas in data - if let textAreas = data["textAreas"] as? [String: [String: Any]], - let textArea = textAreas[key], - let text = textArea["text"] as? String { - return CLKSimpleTextProvider(text: text) - } - - // Check direct key - if let text = data[key] as? String { - return CLKSimpleTextProvider(text: text) - } - - return nil - } - - /// Extract image provider for a given key - private func imageProvider(for key: String) -> CLKImageProvider? { - guard let icon = data[key] as? [String: Any], - let iconName = icon["icon"] as? String else { - return nil - } - - // Create MaterialDesignIcons icon - // MaterialDesignIcons initializer doesn't return optional, it returns the icon or uses a fallback - let mdiIcon = MaterialDesignIcons(named: iconName) - - // Generate image from icon - let image = mdiIcon.image(ofSize: CGSize(width: 32, height: 32), color: .white) - return CLKImageProvider(onePieceImage: image) - } - - /// Extract full color image provider for a given key - private func fullColorImageProvider(for key: String) -> CLKFullColorImageProvider? { - guard let icon = data[key] as? [String: Any], - let iconName = icon["icon"] as? String else { - return nil - } - - // Create MaterialDesignIcons icon - // MaterialDesignIcons initializer doesn't return optional, it returns the icon or uses a fallback - let mdiIcon = MaterialDesignIcons(named: iconName) - - // Get color if specified, otherwise use white - let color: UIColor - if let colorHex = icon["color"] as? String { - color = UIColor(hex: colorHex) ?? .white - } else { - color = .white - } - - // Generate full-color image from icon - let image = mdiIcon.image(ofSize: CGSize(width: 48, height: 48), color: color) - return CLKFullColorImageProvider(fullColorImage: image) - } - - /// Extract gauge provider - private func gaugeProvider() -> CLKGaugeProvider? { - // Check rendered gauge value - if let gaugeValue = renderedValues[.gauge] as? Double { - return CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: gaugeColor(), - fillFraction: Float(max(0, min(1, gaugeValue))) - ) - } - - // Check data - if let gaugeDict = data["gauge"] as? [String: Any], - let gaugeValue = gaugeDict["gauge"] as? Double { - return CLKSimpleGaugeProvider( - style: .fill, - gaugeColor: gaugeColor(), - fillFraction: Float(max(0, min(1, gaugeValue))) - ) - } - - return nil - } - - /// Extract gauge color - private func gaugeColor() -> UIColor { - if let gaugeDict = data["gauge"] as? [String: Any], - let colorHex = gaugeDict["gauge_color"] as? String { - return UIColor(hex: colorHex) ?? .white - } - return .white - } - - /// Extract ring fill fraction - private func ringFillFraction() -> Float { - // Check rendered ring value - if let ringValue = renderedValues[.ring] as? Double { - return Float(max(0, min(1, ringValue))) - } - - // Check data - if let ringDict = data["ring"] as? [String: Any], - let ringValue = ringDict["ring_value"] as? Double { - return Float(max(0, min(1, ringValue))) - } - - return 0.5 - } -} - -// MARK: - UIColor Hex Extension - -private extension UIColor { - convenience init?(hex: String) { - var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) - hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") - - var rgb: UInt64 = 0 - - guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { - return nil - } - - let length = hexSanitized.count - let r, g, b, a: CGFloat - - if length == 6 { - r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 - g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 - b = CGFloat(rgb & 0x0000FF) / 255.0 - a = 1.0 - } else if length == 8 { - r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0 - g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0 - b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0 - a = CGFloat(rgb & 0x0000_00FF) / 255.0 - } else { + // For now, convert to WatchComplication to use existing template logic + // This is a temporary solution until we fully port the template generation + guard let watchComplication = try? WatchComplication(JSON: [ + "identifier": identifier, + "serverIdentifier": serverIdentifier as Any, + "Family": rawFamily, + "Template": rawTemplate, + "Data": complicationData, + "CreatedAt": createdAt.timeIntervalSince1970, + "name": name as Any, + "IsPublic": true, + ]) else { return nil } - self.init(red: r, green: g, blue: b, alpha: a) + return watchComplication.CLKComplicationTemplate(family: complicationFamily) } } From 8d3301b47d17c4dfa466da3514b3e2dd4a7747c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:49:52 +0100 Subject: [PATCH 12/16] Include isPublic to GRDB --- .../Watch/Home/AppWatchComplication.swift | 21 ++++++++++--------- Sources/Shared/Database/DatabaseTables.swift | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/Extensions/Watch/Home/AppWatchComplication.swift b/Sources/Extensions/Watch/Home/AppWatchComplication.swift index df2fa94bb9..a8cfe4234a 100644 --- a/Sources/Extensions/Watch/Home/AppWatchComplication.swift +++ b/Sources/Extensions/Watch/Home/AppWatchComplication.swift @@ -11,6 +11,7 @@ public struct AppWatchComplication: Codable { public var complicationData: [String: Any] public var createdAt: Date public var name: String? + public var isPublic: Bool enum CodingKeys: String, CodingKey { case identifier @@ -20,6 +21,7 @@ public struct AppWatchComplication: Codable { case complicationData case createdAt case name + case isPublic } public init( @@ -29,7 +31,8 @@ public struct AppWatchComplication: Codable { rawTemplate: String, complicationData: [String: Any], createdAt: Date, - name: String? + name: String?, + isPublic: Bool = true ) { self.identifier = identifier self.serverIdentifier = serverIdentifier @@ -38,6 +41,7 @@ public struct AppWatchComplication: Codable { self.complicationData = complicationData self.createdAt = createdAt self.name = name + self.isPublic = isPublic } // MARK: - Codable @@ -50,6 +54,7 @@ public struct AppWatchComplication: Codable { self.rawTemplate = try container.decode(String.self, forKey: .rawTemplate) self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.isPublic = try container.decodeIfPresent(Bool.self, forKey: .isPublic) ?? true // Decode JSON string to dictionary let jsonString = try container.decode(String.self, forKey: .complicationData) @@ -69,6 +74,7 @@ public struct AppWatchComplication: Codable { try container.encode(rawTemplate, forKey: .rawTemplate) try container.encode(createdAt, forKey: .createdAt) try container.encodeIfPresent(name, forKey: .name) + try container.encode(isPublic, forKey: .isPublic) // Encode dictionary to JSON string for database storage let data = try JSONSerialization.data(withJSONObject: complicationData, options: []) @@ -112,6 +118,7 @@ public extension AppWatchComplication { let rawTemplate = json["Template"] as? String ?? "" let name = json["name"] as? String let complicationData = json["Data"] as? [String: Any] ?? [:] + let isPublic = json["IsPublic"] as? Bool ?? true // Parse CreatedAt date let createdAt: Date @@ -131,7 +138,8 @@ public extension AppWatchComplication { rawTemplate: rawTemplate, complicationData: complicationData, createdAt: createdAt, - name: name + name: name, + isPublic: isPublic ) } @@ -165,13 +173,6 @@ public extension AppWatchComplication { name ?? template.style } - /// Whether the complication should be shown on lock screen - /// Default to true since we don't store this yet - var isPublic: Bool { - // TODO: Add isPublic field to database schema if needed - true - } - /// The Family enum from rawFamily string var family: ComplicationGroupMember { ComplicationGroupMember(rawValue: rawFamily) ?? .modularSmall @@ -278,7 +279,7 @@ public extension AppWatchComplication { "Data": complicationData, "CreatedAt": createdAt.timeIntervalSince1970, "name": name as Any, - "IsPublic": true, + "IsPublic": isPublic, ]) else { return nil } diff --git a/Sources/Shared/Database/DatabaseTables.swift b/Sources/Shared/Database/DatabaseTables.swift index 40ce427511..7f70736338 100644 --- a/Sources/Shared/Database/DatabaseTables.swift +++ b/Sources/Shared/Database/DatabaseTables.swift @@ -95,5 +95,6 @@ public enum DatabaseTables { case complicationData case createdAt case name + case isPublic } } From 2d916c23aa61164f1da4dfb70dd16bd5cf04b4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:49:54 +0100 Subject: [PATCH 13/16] Update ComplicationController.swift --- .../Watch/Complication/ComplicationController.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index fac043fd69..85773dc9b2 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -215,12 +215,8 @@ class ComplicationController: NSObject, CLKComplicationDataSource { /// - Important: The system may adjust or ignore this date based on resources /// - SeeAlso: `getCurrentTimelineEntry(for:withHandler:)` which is called when update triggers func getNextRequestedUpdateDate(handler: @escaping (Date?) -> Void) { - #if DEBUG - let nextUpdate = Date(timeIntervalSinceNow: 60) - #else // Request update in 15 minutes - aligns with watchOS budget of ~4 updates per hour let nextUpdate = Date(timeIntervalSinceNow: 15 * 60) - #endif Current.Log.verbose("Scheduling next complication update for \(nextUpdate)") handler(nextUpdate) From a963fba12f048e1b3edb9467dda7096a5378d1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:54:43 +0100 Subject: [PATCH 14/16] Update ComplicationController.swift --- .../Watch/Complication/ComplicationController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 85773dc9b2..1ed9908311 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -174,10 +174,10 @@ class ComplicationController: NSObject, CLKComplicationDataSource { withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void ) { if let model = complicationModel(for: complication) { - if model.isPublic == false { - handler(.hideOnLockScreen) - } else { + if model.isPublic { handler(.showOnLockScreen) + } else { + handler(.hideOnLockScreen) } } else { // Default to showing on lock screen if no model found From fd6254d9af924cf942c7631e759018524ee44252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:42:15 +0100 Subject: [PATCH 15/16] Comment out code --- .../ComplicationEditViewController.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift index 6e724f218f..b66960a7bf 100644 --- a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift +++ b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift @@ -53,10 +53,12 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy Current.Log.verbose("COMPLICATION \(config) \(config.Data)") realm.add(config, update: .all) - }.then(on: nil) { [server] in - Current.api(for: server)? - .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.cauterize() + //.then(on: nil) { [server] in +// Current.api(for: server)? +// .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) + // TODO: Send send complication to watch so it can save on database + //}.cauterize() onDismissCallback?(self) } @@ -78,10 +80,12 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy let realm = Current.realm() realm.reentrantWrite { realm.delete(config) - }.then(on: nil) { - Current.api(for: server)? - .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) }.cauterize() + //.then(on: nil) { +// Current.api(for: server)? +// .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) + // TODO: Send send complication to watch so it can remove from database + //}.cauterize() self.onDismissCallback?(self) } From 44a227b730e4bbb2c3ac76f8dd8351e318c1ef68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:58:20 +0100 Subject: [PATCH 16/16] Refactor complication template rendering architecture Extracts template rendering logic from ComplicationController into a new ComplicationTemplateRenderer class, introducing a protocol-based architecture for rendering, error handling, and database updates. Adds detailed architecture documentation (ARCHITECTURE_DIAGRAMS.md), updates project structure, and moves WatchComplicationSyncMessages.swift to a new Watch subdirectory for better organization. --- HomeAssistant.xcodeproj/project.pbxproj | 10 +- .../ComplicationEditViewController.swift | 12 +- .../Complication/ARCHITECTURE_DIAGRAMS.md | 302 +++++++++ .../Complication/ComplicationController.swift | 109 +--- .../ComplicationTemplateRenderer.swift | 575 ++++++++++++++++++ .../WatchComplicationSyncMessages.swift | 0 6 files changed, 910 insertions(+), 98 deletions(-) create mode 100644 Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md create mode 100644 Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift rename Sources/Shared/{ => Watch}/WatchComplicationSyncMessages.swift (100%) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 4f07bfe8e5..839c9fc8a4 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1034,6 +1034,8 @@ 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28AF2B101D6B0093B31A /* CardView.swift */; }; 42F5CAE82B10CDC900409816 /* HAButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28B52B1022680093B31A /* HAButton.swift */; }; 42F5CAED2B10CF3A00409816 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B15042273188300635D5C /* Assets.swift */; }; + 42F68DAB2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md in Resources */ = {isa = PBXBuildFile; fileRef = 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */; }; + 42F68DAF2EE9BEE900639F48 /* ComplicationTemplateRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */; }; 42F73F562E259A0900B704A9 /* BaseSensorUpdateSignaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */; }; 42F73F572E259A0900B704A9 /* BaseSensorUpdateSignaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */; }; 42F73F5A2E264A9D00B704A9 /* WebViewControllerButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */; }; @@ -2704,6 +2706,8 @@ 42F1DA732B4FF9F8002729BC /* MaterialDesignIcons+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MaterialDesignIcons+CarPlay.swift"; sourceTree = ""; }; 42F3E1482E1D22B400F4E6FC /* HATextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HATextField.swift; sourceTree = ""; }; 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFixture.swift; sourceTree = ""; }; + 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationTemplateRenderer.swift; sourceTree = ""; }; + 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ARCHITECTURE_DIAGRAMS.md; sourceTree = ""; }; 42F73F552E259A0900B704A9 /* BaseSensorUpdateSignaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseSensorUpdateSignaler.swift; sourceTree = ""; }; 42F73F592E264A9D00B704A9 /* WebViewControllerButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewControllerButtons.swift; sourceTree = ""; }; 42F8D8672DC3B0500022DE43 /* GesturesSetupView.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GesturesSetupView.test.swift; sourceTree = ""; }; @@ -4479,6 +4483,8 @@ children = ( 423F451F2C19D88100766A99 /* Assist */, B6CC5D972159D10E00833E5D /* ComplicationController.swift */, + 42F68DA62EE9BBC200639F48 /* ComplicationTemplateRenderer.swift */, + 42F68DAA2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md */, ); path = Complication; sourceTree = ""; @@ -4609,6 +4615,7 @@ 426266432C11B0070081A818 /* Watch */ = { isa = PBXGroup; children = ( + 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */, 4251AAB82C6CE1B4004CCC9D /* WatchConfig.swift */, 426266442C11B02C0081A818 /* InteractiveImmediateMessages.swift */, 4278C9C02C8F226500A7B5F4 /* GuaranteedMessages.swift */, @@ -6414,7 +6421,6 @@ D03D891820E0A85300D4F28D /* Shared */ = { isa = PBXGroup; children = ( - 42CD571F2EE73A6B0032D7C5 /* WatchComplicationSyncMessages.swift */, 4245DC022EBA42C3005E0E04 /* AreasService.swift */, 420CFC612D3F9C15009A94F3 /* Database */, 4278CB822D01F09400CFAAC9 /* AppGesture.swift */, @@ -7651,6 +7657,7 @@ buildActionMask = 2147483647; files = ( 426490732C0F1F36002155CC /* Colors.xcassets in Resources */, + 42F68DAB2EE9BC5200639F48 /* ARCHITECTURE_DIAGRAMS.md in Resources */, B6CC5D9A2159D10F00833E5D /* Assets.xcassets in Resources */, FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */, 42CD571C2EE736250032D7C5 /* COMPLICATION_SYNC_PROTOCOL.md in Resources */, @@ -9100,6 +9107,7 @@ 426490682C0F1A49002155CC /* WatchAssistView.swift in Sources */, 4264907A2C0F3D97002155CC /* AudioPlayer.swift in Sources */, 429764F62E93B21E004C26EE /* CircularGlassOrLegacyBackground.swift in Sources */, + 42F68DAF2EE9BEE900639F48 /* ComplicationTemplateRenderer.swift in Sources */, B672AB582216B5E000175465 /* Date+ComplicationDivination.swift in Sources */, 423F44F02C17238200766A99 /* ChatBubbleView.swift in Sources */, B6CC5D982159D10E00833E5D /* ComplicationController.swift in Sources */, diff --git a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift index b66960a7bf..1ed9048cc5 100644 --- a/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift +++ b/Sources/App/Settings/AppleWatch/ComplicationEditViewController.swift @@ -54,11 +54,11 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy realm.add(config, update: .all) }.cauterize() - //.then(on: nil) { [server] in + // .then(on: nil) { [server] in // Current.api(for: server)? // .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) - // TODO: Send send complication to watch so it can save on database - //}.cauterize() + // TODO: Send send complication to watch so it can save on database + // }.cauterize() onDismissCallback?(self) } @@ -81,11 +81,11 @@ class ComplicationEditViewController: HAFormViewController, TypedRowControllerTy realm.reentrantWrite { realm.delete(config) }.cauterize() - //.then(on: nil) { + // .then(on: nil) { // Current.api(for: server)? // .updateComplications(passively: false) ?? .init(error: HomeAssistantAPI.APIError.noAPIAvailable) - // TODO: Send send complication to watch so it can remove from database - //}.cauterize() + // TODO: Send send complication to watch so it can remove from database + // }.cauterize() self.onDismissCallback?(self) } diff --git a/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md b/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md new file mode 100644 index 0000000000..8e626fc05a --- /dev/null +++ b/Sources/Extensions/Watch/Complication/ARCHITECTURE_DIAGRAMS.md @@ -0,0 +1,302 @@ +# Complication Template Rendering Architecture + +## Component Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ClockKit System │ +│ (Requests complication updates from ComplicationController) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ComplicationController │ +│ - CLKComplicationDataSource implementation │ +│ - Manages complication descriptors and timeline │ +│ - Delegates rendering to ComplicationTemplateRenderer │ +│ │ +│ Properties: │ +│ • templateRenderer: ComplicationTemplateRendering │ +│ │ +│ Methods (simplified): │ +│ • getCurrentTimelineEntry(...) - Main ClockKit callback │ +│ • renderTemplatesAndProvideEntry(...) - Delegates to renderer │ +│ • template(for:) - Generates templates │ +└────────────────────────────┬────────────────────────────────────┘ + │ delegates to + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ <> ComplicationTemplateRendering │ +│ • renderAndProvideEntry(for:model:date:completion:) │ +└────────────────────────────┬────────────────────────────────────┘ + │ implemented by + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ComplicationTemplateRenderer │ +│ Handles complete template rendering lifecycle │ +│ │ +│ Orchestration: │ +│ • renderAndProvideEntry(...) - Main entry point │ +│ • syncNetworkInformation(...) - Network sync │ +│ • validateServerAndConnection(...) - Validation │ +│ • renderTemplates(...) - Rendering coordinator │ +│ │ +│ Template Processing: │ +│ • createCombinedTemplateString(...) - Batch preparation │ +│ • sendRenderRequest(...) - API communication │ +│ • setupTimeout(...) - Timeout handling │ +│ │ +│ Response Handling: │ +│ • handleRenderResponse(...) - Response router │ +│ • handleSuccessResponse(...) - Success processor │ +│ • parseRenderedTemplates(...) - Parse response │ +│ │ +│ Database & Generation: │ +│ • updateDatabaseAndProvideEntry(...) - DB update │ +│ • provideEntry(...) - Generate timeline entry │ +│ • provideFallbackEntry(...) - Fallback on error │ +└────────────────────────────┬────────────────────────────────────┘ + │ uses + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ <> ComplicationTemplateProvider │ +│ • template(for: CLKComplication) -> CLKComplicationTemplate │ +└────────────────────────────┬────────────────────────────────────┘ + │ implemented by + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DefaultComplicationTemplateProvider │ +│ Generates ClockKit templates from complication models │ +│ │ +│ Methods: │ +│ • template(for:) - Main template generation │ +│ • fetchComplicationModel(for:) - Database fetch │ +│ │ +│ Fallback Chain: │ +│ 1. Model from database → clkComplicationTemplate │ +│ 2. Assist default → AssistDefaultComplication.createTemplate │ +│ 3. Placeholder → ComplicationGroupMember.fallbackTemplate │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Sequence Diagram: Template Rendering Flow + +``` +ClockKit Controller Renderer Network HAConnection Database + │ │ │ │ │ │ + │ getCurrentTimelineEntry │ │ │ + ├─────────>│ │ │ │ │ + │ │ │ │ │ │ + │ │ renderAndProvideEntry │ │ + │ ├─────────>│ │ │ │ + │ │ │ │ │ │ + │ │ │ Validate server │ │ + │ │ │ identifier │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ syncNetworkInformation │ + │ │ ├─────────>│ │ │ + │ │ │ │ │ │ + │ │ │<─────────┤ │ │ + │ │ │ (complete) │ │ + │ │ │ │ │ │ + │ │ │ Validate server │ │ + │ │ │ & connection │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ Combine templates │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ send(template API) │ │ + │ │ ├─────────────────────> │ + │ │ │ │ │ │ + │ │ │ (5s timeout running) │ + │ │ │ │ │ │ + │ │ │<─────────────────────┤ │ + │ │ │ (response) │ │ + │ │ │ │ │ │ + │ │ │ Parse response │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │ │ Update database │ │ + │ │ ├─────────────────────────────────> + │ │ │ │ │ │ + │ │ │<─────────────────────────────────┤ + │ │ │ (saved) │ │ + │ │ │ │ │ │ + │ │ │ Generate template │ │ + │ │ │────┐ │ │ │ + │ │ │ │ │ │ │ + │ │ │<───┘ │ │ │ + │ │ │ │ │ │ + │ │<─────────┤ │ │ │ + │ │ (entry) │ │ │ │ + │ │ │ │ │ │ + │<─────────┤ │ │ │ │ + │ (entry) │ │ │ │ │ + │ │ │ │ │ │ +``` + +## Error Handling Flow + +``` +┌──────────────────────────┐ +│ Start Rendering │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ ┌─────────────────────┐ +│ Check Server Identifier │────>│ Missing Identifier │ +└────────────┬─────────────┘ └──────────┬──────────┘ + │ ✓ │ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────┐ +│ Sync Network Info │ │ Fallback │ +└────────────┬─────────────┘ │ Entry │ + │ └──────────────┘ + ▼ ▲ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Validate Server & API │────>│ Server/API Missing │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Send API Request │────>│ Timeout (5s) │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Parse Response │────>│ Parse Error │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ ┌─────────┴──────────┐ +│ Update Database │────>│ DB Error │ +└────────────┬─────────────┘ └────────────────────┘ + │ ✓ │ + ▼ │ +┌──────────────────────────┐ │ +│ Generate Template │ │ +└────────────┬─────────────┘ │ + │ │ + ▼ │ +┌──────────────────────────┐ │ +│ Provide Entry │<──────────────┘ +└──────────────────────────┘ +``` + +## Dependency Graph + +``` +ComplicationController + │ + ├─► ComplicationTemplateRendering (protocol) + │ └─► ComplicationTemplateRenderer (implementation) + │ │ + │ ├─► Current.connectivity (network sync) + │ ├─► Current.servers (server management) + │ ├─► Current.api(for:) (API access) + │ ├─► HAConnection (Home Assistant API) + │ ├─► Current.database() (GRDB database) + │ └─► ComplicationTemplateProvider (protocol) + │ └─► DefaultComplicationTemplateProvider + │ │ + │ ├─► AppWatchComplication (model) + │ ├─► MaterialDesignIcons (icons) + │ ├─► AssistDefaultComplication (special) + │ └─► ComplicationGroupMember (fallback) + │ + └─► Direct Dependencies: + ├─► Current.database() (model fetching) + ├─► AppWatchComplication (data model) + ├─► CLKComplicationDataSource (protocol) + └─► ComplicationGroupMember (helpers) +``` + +## Key Responsibilities + +### ComplicationController +- **Primary Role**: CLKComplicationDataSource implementation +- **Responsibilities**: + - Respond to ClockKit callbacks + - Fetch complication models from database + - Provide complication descriptors + - Manage privacy settings + - Delegate rendering to renderer + +### ComplicationTemplateRenderer +- **Primary Role**: Template rendering orchestration +- **Responsibilities**: + - Sync network information + - Validate server availability + - Call Home Assistant API + - Parse and cache rendered values + - Handle timeouts and errors + - Generate final templates + +### ComplicationTemplateProvider +- **Primary Role**: Template generation abstraction +- **Responsibilities**: + - Fetch models from database + - Generate ClockKit templates + - Provide fallback templates + - Handle special cases (Assist, placeholders) + +## Benefits Visualization + +``` +Before: +┌───────────────────────────────────────┐ +│ ComplicationController │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ renderTemplatesAndProvideEntry │ │ +│ │ │ │ +│ │ • Network sync │ │ +│ │ • Server validation │ │ +│ │ • Template combining │ │ +│ │ • API communication │ │ +│ │ • Response parsing │ │ +│ │ • Database updates │ │ +│ │ • Template generation │ │ +│ │ • Error handling │ │ +│ │ • Timeout management │ │ +│ │ │ │ +│ │ (~150 lines, mixed concerns) │ │ +│ └──────────────────────────────────┘ │ +└───────────────────────────────────────┘ + +After: +┌──────────────────────┐ ┌────────────────────────────┐ +│ ComplicationController│───>│ ComplicationTemplateRenderer│ +│ │ │ │ +│ • ClockKit callbacks │ │ ┌────────────────────────┐ │ +│ • Model fetching │ │ │ Network Sync │ │ +│ • Descriptors │ │ └────────────────────────┘ │ +│ • Privacy settings │ │ ┌────────────────────────┐ │ +│ │ │ │ Server Validation │ │ +│ (~20 lines rendering)│ │ └────────────────────────┘ │ +└──────────────────────┘ │ ┌────────────────────────┐ │ + │ │ Template Processing │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ API Communication │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ Response Parsing │ │ + │ └────────────────────────┘ │ + │ ┌────────────────────────┐ │ + │ │ Database Updates │ │ + │ └────────────────────────┘ │ + │ │ + │ (13 focused methods) │ + └────────────────────────────┘ +``` diff --git a/Sources/Extensions/Watch/Complication/ComplicationController.swift b/Sources/Extensions/Watch/Complication/ComplicationController.swift index 1ed9908311..2d26c57ca3 100644 --- a/Sources/Extensions/Watch/Complication/ComplicationController.swift +++ b/Sources/Extensions/Watch/Complication/ComplicationController.swift @@ -42,6 +42,8 @@ class ComplicationController: NSObject, CLKComplicationDataSource { // https://github.com/LoopKit/Loop/issues/816 // https://crunchybagel.com/detecting-which-complication-was-tapped/ + private var renderers: [ComplicationTemplateRenderer] = [] + // MARK: - Private Helper Methods /// Fetches the complication model from GRDB database @@ -287,110 +289,35 @@ class ComplicationController: NSObject, CLKComplicationDataSource { /// Renders templates in real-time and provides the complication entry /// - /// This method: - /// 1. Extracts templates from the complication that need rendering - /// 2. Sends them to iPhone for rendering via send/reply message - /// 3. Updates the database with rendered values - /// 4. Generates and returns the updated template - /// - /// If rendering fails, it falls back to cached values. + /// This method delegates to the `ComplicationTemplateRenderer` from the environment which handles: + /// 1. Network synchronization + /// 2. API communication with Home Assistant + /// 3. Template parsing and database updates + /// 4. Error handling and fallbacks /// /// - Parameters: /// - complication: The complication to render /// - model: The complication model from database /// - date: The date for the timeline entry /// - handler: Completion handler to call with the entry + /// + /// - SeeAlso: `ComplicationTemplateRenderer` for implementation details private func renderTemplatesAndProvideEntry( for complication: CLKComplication, model: AppWatchComplication, date: Date, handler: @escaping (CLKComplicationTimelineEntry?) -> Void ) { - guard let serverIdentifier = model.serverIdentifier else { - Current.Log.warning("No server identifier for complication, using cached values") - handler(.init(date: date, complicationTemplate: template(for: complication))) - return - } - - // Check if iPhone is reachable - guard Communicator.shared.currentReachability != .notReachable else { - Current.Log.warning("iPhone not reachable, using cached template values") - handler(.init(date: date, complicationTemplate: template(for: complication))) - return - } - - let rawTemplates = model.rawRendered() - - #if DEBUG - let timeoutSeconds: TimeInterval = 32.0 - #else - let timeoutSeconds: TimeInterval = 10.0 - #endif - - var hasCompleted = false - - // Set a timeout fallback - DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { [weak self] in - guard let self else { return } - if !hasCompleted { - hasCompleted = true - Current.Log.warning("Template rendering timed out after \(timeoutSeconds)s, using cached values") - handler(.init(date: date, complicationTemplate: template(for: complication))) - } + let renderer = ComplicationTemplateRenderer() + renderers.append(renderer) + renderer.renderAndProvideEntry( + for: complication, + model: model, + date: date + ) { [weak self] entry in + handler(entry) + self?.renderers.removeAll { $0 === renderer } } - - Current.Log.info("Requesting template rendering from iPhone for complication \(complication.identifier)") - - // Send render request to iPhone via Communicator - Communicator.shared.send(.init( - identifier: InteractiveImmediateMessages.renderTemplates.rawValue, - content: [ - "templates": rawTemplates, - "serverIdentifier": serverIdentifier, - ], - reply: { [weak self] replyMessage in - guard let self else { return } - guard !hasCompleted else { return } - hasCompleted = true - - // Check for error - if let error = replyMessage.content["error"] as? String { - Current.Log.error("Template rendering failed: \(error), using cached values") - handler(.init(date: date, complicationTemplate: template(for: complication))) - return - } - - // Extract rendered values - guard let renderedValues = replyMessage.content["rendered"] as? [String: Any] else { - Current.Log.error("No rendered values in response, using cached values") - handler(.init(date: date, complicationTemplate: template(for: complication))) - return - } - - Current.Log.info("Successfully received \(renderedValues.count) rendered templates from iPhone") - - // Update the database with rendered values - do { - try Current.database().write { db in - var updatedModel = model - updatedModel.updateRenderedValues(from: renderedValues) - try updatedModel.update(db) - } - - // Generate template with fresh rendered values - handler(.init(date: date, complicationTemplate: template(for: complication))) - } catch { - Current.Log.error("Failed to update complication with rendered values: \(error)") - // Still try to provide the template with cached values - handler(.init(date: date, complicationTemplate: template(for: complication))) - } - } - ), errorHandler: { error in - guard !hasCompleted else { return } - hasCompleted = true - Current.Log.error("Failed to send render request to iPhone: \(error), using cached values") - handler(.init(date: date, complicationTemplate: self.template(for: complication))) - }) } // MARK: - Placeholder Templates diff --git a/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift b/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift new file mode 100644 index 0000000000..3ed6f41d8f --- /dev/null +++ b/Sources/Extensions/Watch/Complication/ComplicationTemplateRenderer.swift @@ -0,0 +1,575 @@ +import ClockKit +import Foundation +import HAKit +import Shared + +// MARK: - Protocol + +/// Protocol for rendering complication templates with real-time data from Home Assistant +/// +/// This protocol defines the contract for services that can render Jinja2 templates +/// stored in complication models by calling the Home Assistant API. +/// +/// ## Purpose +/// Separates the concerns of template rendering from complication data source logic, +/// making the code more testable, maintainable, and focused. +/// +/// ## Implementation Requirements +/// Implementers must: +/// 1. Sync network information before rendering +/// 2. Call Home Assistant's template API endpoint +/// 3. Parse and update the database with rendered values +/// 4. Handle timeouts and errors gracefully +/// 5. Always call the completion handler (even on failure) +/// +/// ## Example Usage +/// ```swift +/// let renderer: ComplicationTemplateRendering = ComplicationTemplateRenderer() +/// +/// renderer.renderAndProvideEntry( +/// for: complication, +/// model: watchComplication, +/// date: Date() +/// ) { entry in +/// handler(entry) +/// } +/// ``` +protocol ComplicationTemplateRendering { + /// Renders templates for a complication and provides a timeline entry + /// + /// This method orchestrates the entire template rendering flow: + /// 1. Validates preconditions (server identifier exists) + /// 2. Syncs network information for accurate URL selection + /// 3. Fetches server and API connection + /// 4. Renders templates via Home Assistant API + /// 5. Updates database with rendered values + /// 6. Generates final ClockKit template + /// + /// - Parameters: + /// - complication: The ClockKit complication to render + /// - model: The complication model containing templates to render + /// - date: The date to use for the timeline entry + /// - completion: Handler called with the timeline entry (never nil, uses fallback on error) + /// + /// - Note: Always calls completion, even if rendering fails (uses cached values as fallback) + /// - Important: Completion may be called on a background queue + func renderAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) +} + +// MARK: - Implementation + +/// Default implementation of template rendering for watch complications +/// +/// This class handles the complete lifecycle of rendering Jinja2 templates +/// from Home Assistant and converting them into ClockKit complication templates. +/// +/// ## Architecture +/// The renderer breaks down the rendering process into focused, single-responsibility methods: +/// - `syncNetwork`: Ensures network information is current +/// - `validateServer`: Confirms server and API are available +/// - `renderTemplates`: Calls Home Assistant API with combined template string +/// - `parseResponse`: Extracts rendered values from API response +/// - `updateDatabase`: Persists rendered values for caching +/// - `generateTemplate`: Creates ClockKit template from model +/// +/// ## Timeout Handling +/// A 5-second timeout is enforced to prevent blocking the complication system. +/// If rendering takes too long, the cached values are used instead. +/// +/// ## Error Handling +/// All errors result in graceful fallback to cached values. The complication +/// is never left in a broken state. +/// +/// - SeeAlso: `ComplicationTemplateRendering` for protocol contract +final class ComplicationTemplateRenderer: ComplicationTemplateRendering { + // MARK: - Properties + + /// Timeout duration for template rendering requests + private let timeoutSeconds: TimeInterval = 5 + + /// Fallback template provider for generating templates when data isn't available + private let templateProvider: ComplicationTemplateProvider + + // MARK: - Initialization + + /// Creates a new template renderer + /// + /// - Parameter templateProvider: Provider for generating fallback templates + init(templateProvider: ComplicationTemplateProvider = DefaultComplicationTemplateProvider()) { + self.templateProvider = templateProvider + } + + // MARK: - ComplicationTemplateRendering + + func renderAndProvideEntry( + for complication: CLKComplication, + model: AppWatchComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + // Validate server identifier exists + guard let serverIdentifier = model.serverIdentifier else { + Current.Log.warning("No server identifier for complication, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + Current.Log.info("Starting template rendering for complication \(complication.identifier)") + + // Step 1: Sync network information to ensure accurate URL selection + syncNetworkInformation { [weak self] in + guard let self else { return } + + // Step 2: Validate server and API connection are available + guard let (server, connection) = validateServerAndConnection( + serverIdentifier: serverIdentifier, + complication: complication, + date: date, + completion: completion + ) else { + return // Validation failed, fallback already provided + } + + // Step 3: Render templates via Home Assistant API + renderTemplates( + for: complication, + model: model, + server: server, + connection: connection, + date: date, + completion: completion + ) + } + } + + // MARK: - Private Methods - Orchestration + + /// Syncs network information before proceeding with rendering + /// + /// Network sync is critical for accurate URL selection (internal vs external). + /// It updates SSID information and connectivity state. + /// + /// - Parameter completion: Called when sync completes + private func syncNetworkInformation(completion: @escaping () -> Void) { + Current.Log.info("Syncing network information before rendering templates") + + Current.connectivity.syncNetworkInformation { + Current.Log.info("Network information sync completed") + completion() + } + } + + /// Validates that server and API connection are available + /// + /// This method checks that: + /// 1. Server exists for the given identifier + /// 2. API instance is available for the server + /// 3. API has an active connection + /// + /// If validation fails, it logs an error and provides a fallback entry. + /// + /// - Parameters: + /// - serverIdentifier: The server identifier from the complication model + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Completion handler to call with fallback if validation fails + /// - Returns: Tuple of (Server, HAConnection) if valid, nil if validation fails + private func validateServerAndConnection( + serverIdentifier: String, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) -> (Server, HAConnection)? { + guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverIdentifier }), + let api = Current.api(for: server) else { + Current.Log.error("No API available for server \(serverIdentifier), using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return nil + } + + return (server, api.connection) + } + + /// Renders templates by calling the Home Assistant API + /// + /// This method: + /// 1. Extracts raw templates from the model + /// 2. Combines them into a single API request + /// 3. Sends the request with timeout handling + /// 4. Processes the response and updates the database + /// 5. Generates the final ClockKit template + /// + /// ## Template Format + /// Templates are combined using special separators: + /// - `:::` separates key from template + /// - `|||` separates different templates + /// + /// Example: `"key1:::{{template1}}|||key2:::{{template2}}"` + /// + /// - Parameters: + /// - complication: The complication being rendered + /// - model: The complication model with templates + /// - server: The Home Assistant server + /// - connection: The HAKit connection to use + /// - date: Date for the timeline entry + /// - completion: Called with the final entry + private func renderTemplates( + for complication: CLKComplication, + model: AppWatchComplication, + server: Server, + connection: HAConnection, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let rawTemplates = model.rawRendered() + + // Use a class wrapper to share state between closures + let completionState = CompletionState() + + // Set up timeout fallback + setupTimeout( + for: complication, + date: date, + completionState: completionState, + completion: completion + ) + + Current.Log.info("Rendering templates directly from watch API for complication \(complication.identifier)") + + // Combine templates into single request string + let combinedTemplate = createCombinedTemplateString(from: rawTemplates) + + // Send API request + sendRenderRequest( + combinedTemplate: combinedTemplate, + connection: connection, + onComplete: { [weak self] result in + guard let self else { return } + guard !completionState.hasCompleted else { return } + completionState.hasCompleted = true + + handleRenderResponse( + result: result, + rawTemplates: rawTemplates, + model: model, + complication: complication, + date: date, + completion: completion + ) + } + ) + } + + // MARK: - Private Methods - Template Processing + + /// Creates a combined template string for batch rendering + /// + /// Combines multiple templates into a single string that can be sent + /// in one API request, improving performance. + /// + /// - Parameter templates: Dictionary of template keys to template strings + /// - Returns: Combined template string with separators + /// + /// ## Format + /// ``` + /// key1:::{{template1}}|||key2:::{{template2}}|||key3:::{{template3}} + /// ``` + private func createCombinedTemplateString(from templates: [String: String]) -> String { + templates + .map { key, template in "\(key):::\(template)" } + .joined(separator: "|||") + } + + /// Sends the template render request to Home Assistant + /// + /// - Parameters: + /// - combinedTemplate: The combined template string + /// - connection: HAKit connection to use + /// - onComplete: Handler called with the result + private func sendRenderRequest( + combinedTemplate: String, + connection: HAConnection, + onComplete: @escaping (Result) -> Void + ) { + connection.send(.init( + type: .rest(.post, "template"), + data: ["template": combinedTemplate], + shouldRetry: true + ), completion: onComplete) + } + + /// Sets up a timeout fallback for template rendering + /// + /// If rendering takes longer than `timeoutSeconds`, the completion + /// is called with a fallback template using cached values. + /// + /// - Parameters: + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completionState: Wrapper object to track completion status + /// - completion: Handler to call on timeout + private func setupTimeout( + for complication: CLKComplication, + date: Date, + completionState: CompletionState, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { [weak self] in + guard let self else { return } + if !completionState.hasCompleted { + completionState.hasCompleted = true + Current.Log.warning("Template rendering timed out after \(timeoutSeconds)s, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + } + + /// Helper class to track completion state across multiple closures + private final class CompletionState { + var hasCompleted = false + } + + // MARK: - Private Methods - Response Handling + + /// Handles the response from the template render API + /// + /// - Parameters: + /// - result: The result from HAKit + /// - rawTemplates: Original template dictionary for validation + /// - model: Complication model to update + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func handleRenderResponse( + result: Result, + rawTemplates: [String: String], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + switch result { + case let .success(data): + handleSuccessResponse( + data: data, + rawTemplates: rawTemplates, + model: model, + complication: complication, + date: date, + completion: completion + ) + + case let .failure(error): + Current.Log.error("Failed to render templates: \(error), using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + + /// Handles successful API response + /// + /// - Parameters: + /// - data: The HAData from the API + /// - rawTemplates: Original templates for validation + /// - model: Model to update with rendered values + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func handleSuccessResponse( + data: HAData, + rawTemplates: [String: String], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + guard case let .primitive(response) = data, + let renderedString = response as? String else { + Current.Log.error("Template rendering returned non-string response, using cached values") + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + let renderedResults = parseRenderedTemplates(from: renderedString) + + guard renderedResults.count == rawTemplates.count else { + Current.Log.error( + "Rendered count mismatch: expected \(rawTemplates.count), got \(renderedResults.count), using cached values" + ) + provideFallbackEntry(for: complication, date: date, completion: completion) + return + } + + Current.Log.info("Successfully rendered \(renderedResults.count) templates") + + updateDatabaseAndProvideEntry( + renderedResults: renderedResults, + model: model, + complication: complication, + date: date, + completion: completion + ) + } + + /// Parses rendered templates from the API response string + /// + /// Splits the combined response back into individual key-value pairs. + /// + /// - Parameter renderedString: The combined response from Home Assistant + /// - Returns: Dictionary of template keys to rendered values + /// + /// ## Expected Format + /// ``` + /// key1:::rendered_value1|||key2:::rendered_value2 + /// ``` + private func parseRenderedTemplates(from renderedString: String) -> [String: Any] { + var renderedResults: [String: Any] = [:] + + let parts = renderedString.components(separatedBy: "|||") + + for part in parts { + let keyValue = part.components(separatedBy: ":::") + if keyValue.count == 2 { + let key = keyValue[0] + let value = keyValue[1] + renderedResults[key] = value + } + } + + return renderedResults + } + + // MARK: - Private Methods - Database & Template Generation + + /// Updates the database with rendered values and provides the entry + /// + /// - Parameters: + /// - renderedResults: Rendered template values + /// - model: Model to update + /// - complication: The complication being rendered + /// - date: Date for the timeline entry + /// - completion: Handler to call with final entry + private func updateDatabaseAndProvideEntry( + renderedResults: [String: Any], + model: AppWatchComplication, + complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + do { + try Current.database().write { db in + var updatedModel = model + updatedModel.updateRenderedValues(from: renderedResults) + try updatedModel.update(db) + } + + provideEntry(for: complication, date: date, completion: completion) + } catch { + Current.Log.error("Failed to update complication with rendered values: \(error)") + provideFallbackEntry(for: complication, date: date, completion: completion) + } + } + + /// Provides a timeline entry with the current template + /// + /// - Parameters: + /// - complication: The complication to generate a template for + /// - date: Date for the timeline entry + /// - completion: Handler to call with the entry + private func provideEntry( + for complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let template = templateProvider.template(for: complication) + let entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) + completion(entry) + } + + /// Provides a fallback timeline entry using cached values + /// + /// This is called when rendering fails or times out. + /// + /// - Parameters: + /// - complication: The complication to generate a template for + /// - date: Date for the timeline entry + /// - completion: Handler to call with the entry + private func provideFallbackEntry( + for complication: CLKComplication, + date: Date, + completion: @escaping (CLKComplicationTimelineEntry) -> Void + ) { + let template = templateProvider.template(for: complication) + let entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) + completion(entry) + } +} + +// MARK: - Template Provider Protocol + +/// Protocol for generating ClockKit templates from complication models +/// +/// This abstraction allows the renderer to generate templates without +/// needing to know about the ComplicationController's implementation details. +protocol ComplicationTemplateProvider { + /// Generates a ClockKit template for a complication + /// + /// - Parameter complication: The complication to generate a template for + /// - Returns: A valid ClockKit template (never nil) + func template(for complication: CLKComplication) -> CLKComplicationTemplate +} + +// MARK: - Default Template Provider + +/// Default implementation that uses the ComplicationController's template generation +final class DefaultComplicationTemplateProvider: ComplicationTemplateProvider { + func template(for complication: CLKComplication) -> CLKComplicationTemplate { + // Register icons before generating templates + MaterialDesignIcons.register() + + // Try to fetch model and generate template + if let model = fetchComplicationModel(for: complication), + let generated = model.clkComplicationTemplate(family: complication.family) { + return generated + } + + // Check for Assist default complication + if complication.identifier == AssistDefaultComplication.defaultComplicationId { + return AssistDefaultComplication.createAssistTemplate(for: complication.family) + } + + // Fallback to placeholder + Current.Log.info { + "no configured template for \(complication.identifier), providing placeholder" + } + + return ComplicationGroupMember(family: complication.family) + .fallbackTemplate(for: complication.identifier) + } + + /// Fetches the complication model from database + /// + /// - Parameter complication: The complication to fetch + /// - Returns: The model if found, nil otherwise + private func fetchComplicationModel(for complication: CLKComplication) -> AppWatchComplication? { + do { + if complication.identifier != CLKDefaultComplicationIdentifier { + return try Current.database().read { db in + try AppWatchComplication.fetch(identifier: complication.identifier, from: db) + } + } else { + let matchedFamily = ComplicationGroupMember(family: complication.family) + return try Current.database().read { db in + try AppWatchComplication.fetch(identifier: matchedFamily.rawValue, from: db) + } + } + } catch { + Current.Log.error("Failed to fetch complication from GRDB: \(error.localizedDescription)") + return nil + } + } +} diff --git a/Sources/Shared/WatchComplicationSyncMessages.swift b/Sources/Shared/Watch/WatchComplicationSyncMessages.swift similarity index 100% rename from Sources/Shared/WatchComplicationSyncMessages.swift rename to Sources/Shared/Watch/WatchComplicationSyncMessages.swift