Commit 0520b64f38af86c701eb1ef622238f027d576bef

Authored by 权海
1 parent ef503062

feat(ui):修复watch应用主题报错

@@ -2,7 +2,7 @@ import Foundation @@ -2,7 +2,7 @@ import Foundation
2 import SwiftUI 2 import SwiftUI
3 import HealthKit 3 import HealthKit
4 4
5 -extension WatchThemeModel{ 5 +extension WatchLocalTheme{
6 6
7 func description(for state: HRVStressState) -> (title: String?, image: Image?){ 7 func description(for state: HRVStressState) -> (title: String?, image: Image?){
8 switch state { 8 switch state {
@@ -10,36 +10,32 @@ extension WatchThemeModel{ @@ -10,36 +10,32 @@ extension WatchThemeModel{
10 return (state.name, state.defaultThemeImage) 10 return (state.name, state.defaultThemeImage)
11 case .stressful: 11 case .stressful:
12 var image: Image? 12 var image: Image?
13 - if let imageUrl = stressfulImageURL,  
14 - let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),  
15 - let uiImage = UIImage(data: imageData){ 13 + if let stressfulImage,
  14 + let uiImage = UIImage(data: stressfulImage){
16 image = Image(uiImage: uiImage) 15 image = Image(uiImage: uiImage)
17 } 16 }
18 - return (stressfulTitle, image) 17 + return (stressfulDescription, image)
19 case .slightStress: 18 case .slightStress:
20 var image: Image? 19 var image: Image?
21 - if let imageUrl = slightStressImageURL,  
22 - let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),  
23 - let uiImage = UIImage(data: imageData){ 20 + if let slightStressfulImage,
  21 + let uiImage = UIImage(data: slightStressfulImage){
24 image = Image(uiImage: uiImage) 22 image = Image(uiImage: uiImage)
25 } 23 }
26 - return (slightStressTitle, image) 24 + return (slightStressfulDescription, image)
27 case .normal: 25 case .normal:
28 var image: Image? 26 var image: Image?
29 - if let imageUrl = normalImageURL,  
30 - let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),  
31 - let uiImage = UIImage(data: imageData){ 27 + if let normalImage,
  28 + let uiImage = UIImage(data: normalImage){
32 image = Image(uiImage: uiImage) 29 image = Image(uiImage: uiImage)
33 } 30 }
34 - return (normalTitle, image) 31 + return (normalDescription, image)
35 case .energetic: 32 case .energetic:
36 var image: Image? 33 var image: Image?
37 - if let imageUrl = energeticImageURL,  
38 - let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),  
39 - let uiImage = UIImage(data: imageData){ 34 + if let energeticImage,
  35 + let uiImage = UIImage(data: energeticImage){
40 image = Image(uiImage: uiImage) 36 image = Image(uiImage: uiImage)
41 } 37 }
42 - return (energeticTitle, image) 38 + return (energeticDescription, image)
43 } 39 }
44 } 40 }
45 } 41 }
@@ -247,4 +243,3 @@ private struct DynamicCodingKey: CodingKey { @@ -247,4 +243,3 @@ private struct DynamicCodingKey: CodingKey {
247 self.intValue = intValue 243 self.intValue = intValue
248 } 244 }
249 } 245 }
250 -  
@@ -5,6 +5,183 @@ @@ -5,6 +5,183 @@
5 // Created by 权海 on 2026/6/26. 5 // Created by 权海 on 2026/6/26.
6 // 6 //
7 7
  8 +import Foundation
  9 +
  10 +enum WatchThemeLocalBuilder {
  11 + static func makeLocalTheme(from model: WatchThemeModel) async -> WatchLocalTheme {
  12 + async let energeticImage = WatchThemeImageCache.data(for: model.energeticImageURL)
  13 + async let normalImage = WatchThemeImageCache.data(for: model.normalImageURL)
  14 + async let slightStressfulImage = WatchThemeImageCache.data(for: model.slightStressImageURL)
  15 + async let stressfulImage = WatchThemeImageCache.data(for: model.stressfulImageURL)
  16 +
  17 + return await WatchLocalTheme(
  18 + themeId: model.id ?? 0,
  19 + themeName: model.themeName ?? "",
  20 + energeticDescription: model.energeticTitle ?? "",
  21 + energeticImage: energeticImage,
  22 + normalDescription: model.normalTitle ?? "",
  23 + normalImage: normalImage,
  24 + slightStressfulDescription: model.slightStressTitle ?? "",
  25 + slightStressfulImage: slightStressfulImage,
  26 + stressfulDescription: model.stressfulTitle ?? "",
  27 + stressfulImage: stressfulImage
  28 + )
  29 + }
  30 +}
  31 +
  32 +private enum WatchThemeImageCache {
  33 + static func data(for urlString: String?) async -> Data? {
  34 + guard let urlString, !urlString.isEmpty else { return nil }
  35 + if let cachedData = cachedData(for: urlString) {
  36 + return cachedData
  37 + }
  38 + if let defaultsData = AppGroupConstants.defaults?.data(forKey: urlString) {
  39 + save(defaultsData, for: urlString)
  40 + return defaultsData
  41 + }
  42 + guard let url = URL(string: urlString) else { return nil }
  43 +
  44 + do {
  45 + let (data, response) = try await URLSession.shared.data(from: url)
  46 + guard let httpResponse = response as? HTTPURLResponse,
  47 + (200..<300).contains(httpResponse.statusCode) else {
  48 + return nil
  49 + }
  50 + save(data, for: urlString)
  51 + return data
  52 + } catch {
  53 + return nil
  54 + }
  55 + }
  56 +
  57 + private static func cachedData(for urlString: String) -> Data? {
  58 + let url = cacheURL(for: urlString)
  59 + return try? Data(contentsOf: url)
  60 + }
  61 +
  62 + private static func save(_ data: Data, for urlString: String) {
  63 + let url = cacheURL(for: urlString)
  64 + do {
  65 + try FileManager.default.createDirectory(
  66 + at: url.deletingLastPathComponent(),
  67 + withIntermediateDirectories: true
  68 + )
  69 + try data.write(to: url, options: [.atomic])
  70 + } catch {
  71 + }
  72 + }
  73 +
  74 + private static func cacheURL(for urlString: String) -> URL {
  75 + let baseURL = FileManager.default.containerURL(
  76 + forSecurityApplicationGroupIdentifier: AppGroupConstants.identifier
  77 + ) ?? FileManager.default.temporaryDirectory
  78 + return baseURL
  79 + .appendingPathComponent("watch_theme_image_cache", isDirectory: true)
  80 + .appendingPathComponent(stableHash(urlString))
  81 + }
  82 +
  83 + private static func stableHash(_ value: String) -> String {
  84 + var hash: UInt64 = 14_695_981_039_346_656_037
  85 + for byte in value.utf8 {
  86 + hash ^= UInt64(byte)
  87 + hash &*= 1_099_511_628_211
  88 + }
  89 + return String(format: "%016llx", hash)
  90 + }
  91 +}
  92 +
  93 +struct WatchLocalTheme: Codable, Equatable {
  94 + var themeId: Int
  95 + var themeName: String
  96 + var energeticDescription: String
  97 + var energeticImage: Data?
  98 + var normalDescription: String
  99 + var normalImage: Data?
  100 + var slightStressfulDescription: String
  101 + var slightStressfulImage: Data?
  102 + var stressfulDescription: String
  103 + var stressfulImage: Data?
  104 +
  105 + init(
  106 + themeId: Int,
  107 + themeName: String,
  108 + energeticDescription: String,
  109 + energeticImage: Data?,
  110 + normalDescription: String,
  111 + normalImage: Data?,
  112 + slightStressfulDescription: String,
  113 + slightStressfulImage: Data?,
  114 + stressfulDescription: String,
  115 + stressfulImage: Data?
  116 + ) {
  117 + self.themeId = themeId
  118 + self.themeName = themeName
  119 + self.energeticDescription = energeticDescription
  120 + self.energeticImage = energeticImage
  121 + self.normalDescription = normalDescription
  122 + self.normalImage = normalImage
  123 + self.slightStressfulDescription = slightStressfulDescription
  124 + self.slightStressfulImage = slightStressfulImage
  125 + self.stressfulDescription = stressfulDescription
  126 + self.stressfulImage = stressfulImage
  127 + }
  128 +
  129 + init(model: WatchThemeModel) {
  130 + self.init(
  131 + themeId: model.id ?? 0,
  132 + themeName: model.themeName ?? "",
  133 + energeticDescription: model.energeticTitle ?? "",
  134 + energeticImage: nil,
  135 + normalDescription: model.normalTitle ?? "",
  136 + normalImage: nil,
  137 + slightStressfulDescription: model.slightStressTitle ?? "",
  138 + slightStressfulImage: nil,
  139 + stressfulDescription: model.stressfulTitle ?? "",
  140 + stressfulImage: nil
  141 + )
  142 + }
  143 +
  144 + init?(payload: [String: Any]) {
  145 + let rawThemeId = payload["theme_id"]
  146 + let themeId: Int
  147 + if let value = rawThemeId as? Int {
  148 + themeId = value
  149 + } else if let value = rawThemeId as? Int64 {
  150 + themeId = Int(value)
  151 + } else if let value = rawThemeId as? NSNumber {
  152 + themeId = value.intValue
  153 + } else {
  154 + return nil
  155 + }
  156 +
  157 + self.init(
  158 + themeId: themeId,
  159 + themeName: payload["theme_name"] as? String ?? "",
  160 + energeticDescription: payload["energetic_description"] as? String ?? "",
  161 + energeticImage: Self.imageData(from: payload["energetic_image"]),
  162 + normalDescription: payload["normal_description"] as? String ?? "",
  163 + normalImage: Self.imageData(from: payload["normal_image"]),
  164 + slightStressfulDescription: payload["slight_stressful_description"] as? String ?? "",
  165 + slightStressfulImage: Self.imageData(from: payload["slight_stressful_image"]),
  166 + stressfulDescription: payload["stressful_description"] as? String ?? "",
  167 + stressfulImage: Self.imageData(from: payload["stressful_image"])
  168 + )
  169 + }
  170 +
  171 + private static func imageData(from value: Any?) -> Data? {
  172 + if let data = value as? Data { return data }
  173 + if let data = value as? NSData { return data as Data }
  174 + return nil
  175 + }
  176 +
  177 + var hasAllImages: Bool {
  178 + energeticImage != nil &&
  179 + normalImage != nil &&
  180 + slightStressfulImage != nil &&
  181 + stressfulImage != nil
  182 + }
  183 +}
  184 +
8 struct WatchThemeModel: Codable, Equatable { 185 struct WatchThemeModel: Codable, Equatable {
9 var id: Int? 186 var id: Int?
10 var userId: Int? 187 var userId: Int?
@@ -45,4 +222,3 @@ struct WatchThemeModel: Codable, Equatable { @@ -45,4 +222,3 @@ struct WatchThemeModel: Codable, Equatable {
45 case stressfulImageURL = "stressful_image" 222 case stressfulImageURL = "stressful_image"
46 } 223 }
47 } 224 }
48 -  
@@ -14,8 +14,8 @@ import Combine @@ -14,8 +14,8 @@ import Combine
14 class StatusComparisonViewModel: ObservableObject { 14 class StatusComparisonViewModel: ObservableObject {
15 15
16 @Published var currentInteractionAction: InteractionActionType? 16 @Published var currentInteractionAction: InteractionActionType?
17 - @Published var myWatchTheme: WatchThemeModel?  
18 - @Published var otherWatchTheme: WatchThemeModel? 17 + @Published var myWatchTheme: WatchLocalTheme?
  18 + @Published var otherWatchTheme: WatchLocalTheme?
19 19
20 init() { 20 init() {
21 refreshTheme() 21 refreshTheme()
@@ -48,33 +48,25 @@ class StatusComparisonViewModel: ObservableObject { @@ -48,33 +48,25 @@ class StatusComparisonViewModel: ObservableObject {
48 } 48 }
49 49
50 private func getMyWatchTheme() { 50 private func getMyWatchTheme() {
51 - // 1️⃣ 先从 UserDefaults 读取缓存  
52 let defaults = AppGroupConstants.defaults 51 let defaults = AppGroupConstants.defaults
53 - if let jsonString = defaults?.string(forKey: AppGroupConstants.Key.myWatchTheme),  
54 - let jsonData = jsonString.data(using: .utf8),  
55 - let cachedModel = try? JSONDecoder().decode(WatchThemeModel.self, from: jsonData) {  
56 - // 先展示缓存数据  
57 - myWatchTheme = cachedModel 52 + if let data = defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),
  53 + let cachedTheme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
  54 + myWatchTheme = cachedTheme
58 } 55 }
59 56
60 - // 2️⃣ 再异步调用接口刷新  
61 Task { 57 Task {
62 if let result = try? await ThemeService().getCurrentTheme(isOther: false) { 58 if let result = try? await ThemeService().getCurrentTheme(isOther: false) {
  59 + let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: result)
63 await MainActor.run { 60 await MainActor.run {
64 - self.myWatchTheme = result 61 + self.myWatchTheme = localTheme
65 } 62 }
66 - await saveImageToSharedContainer()  
67 await MainActor.run { 63 await MainActor.run {
68 reloadAllComplications() 64 reloadAllComplications()
69 } 65 }
70 66
71 - // 3️⃣ 将最新数据写入 UserDefaults  
72 do { 67 do {
73 - let data = try JSONEncoder().encode(result)  
74 - if let jsonString = String(data: data, encoding: .utf8) {  
75 - print(jsonString)  
76 - defaults?.set(jsonString, forKey: "myWatchTheme")  
77 - } 68 + let data = try JSONEncoder().encode(localTheme)
  69 + defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
78 }catch { 70 }catch {
79 print(error.getToastErrorDescription()) 71 print(error.getToastErrorDescription())
80 } 72 }
@@ -86,29 +78,22 @@ class StatusComparisonViewModel: ObservableObject { @@ -86,29 +78,22 @@ class StatusComparisonViewModel: ObservableObject {
86 private func getOtherWatchTheme() { 78 private func getOtherWatchTheme() {
87 guard WatchUserinfoManager.share.myUserinfo?.isPaired == true else { return } 79 guard WatchUserinfoManager.share.myUserinfo?.isPaired == true else { return }
88 80
89 - // 1️⃣ 先从 UserDefaults 读取缓存  
90 let defaults = AppGroupConstants.defaults 81 let defaults = AppGroupConstants.defaults
91 - if let jsonString = defaults?.string(forKey: AppGroupConstants.Key.otherWatchTheme),  
92 - let jsonData = jsonString.data(using: .utf8),  
93 - let cachedModel = try? JSONDecoder().decode(WatchThemeModel.self, from: jsonData) {  
94 - // 先展示缓存数据  
95 - otherWatchTheme = cachedModel 82 + if let data = defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),
  83 + let cachedTheme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
  84 + otherWatchTheme = cachedTheme
96 } 85 }
97 86
98 - // 2️⃣ 再异步调用接口刷新  
99 Task { 87 Task {
100 if let result = try? await ThemeService().getCurrentTheme(isOther: true) { 88 if let result = try? await ThemeService().getCurrentTheme(isOther: true) {
  89 + let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: result)
101 await MainActor.run { 90 await MainActor.run {
102 - self.otherWatchTheme = result 91 + self.otherWatchTheme = localTheme
103 } 92 }
104 93
105 - // 3️⃣ 将最新数据写入 UserDefaults  
106 do { 94 do {
107 - let data = try JSONEncoder().encode(result)  
108 - if let jsonString = String(data: data, encoding: .utf8) {  
109 - print(jsonString)  
110 - defaults?.set(jsonString, forKey: "otherWatchTheme")  
111 - } 95 + let data = try JSONEncoder().encode(localTheme)
  96 + defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)
112 }catch { 97 }catch {
113 print(error.getToastErrorDescription()) 98 print(error.getToastErrorDescription())
114 } 99 }
@@ -127,79 +112,4 @@ class StatusComparisonViewModel: ObservableObject { @@ -127,79 +112,4 @@ class StatusComparisonViewModel: ObservableObject {
127 #endif 112 #endif
128 } 113 }
129 114
130 - private func saveImageToSharedContainer() async {  
131 - guard let myWatchTheme, myWatchTheme.isDefaultTheme != true else { return }  
132 -  
133 - // 将下载逻辑封装成 async 方法  
134 - func downloadAndSave(_ url: URL?) async {  
135 -// await withCheckedContinuation { continuation in  
136 -// SDWebImageDownloader.shared.downloadImage(with: url) { image, data, error, _ in  
137 -// if let data {  
138 -// self.saveImageToAppGroup(data, fileName: url?.lastPathComponent)  
139 -// }  
140 -// continuation.resume()  
141 -// }  
142 -// }  
143 - }  
144 -  
145 - await withTaskGroup(of: Void.self) { group in  
146 - if let positiveImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .fullOfEnergy) {  
147 - group.addTask {  
148 - await downloadAndSave(positiveImage.url)  
149 - }  
150 - }  
151 - if let normalImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .normal) {  
152 - group.addTask {  
153 - await downloadAndSave(normalImage.url)  
154 - }  
155 - }  
156 - if let negativeImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .overpressure) {  
157 - group.addTask {  
158 - await downloadAndSave(negativeImage.url)  
159 - }  
160 - }  
161 - }  
162 -  
163 - print("✅ 所有图片已下载完成并写入共享容器")  
164 - }  
165 -  
166 - private func saveImageToAppGroup(_ data: Data, fileName: String?) {  
167 - guard let fileName else {return}  
168 -  
169 - guard let containerURL = FileManager.default.containerURL(  
170 - forSecurityApplicationGroupIdentifier: AppGroupConstants.identifier  
171 - ) else {  
172 - print("❌ App Group 容器不存在,请检查配置")  
173 - return  
174 - }  
175 -  
176 - // 清理文件名,移除可能的路径分隔符  
177 - let cleanFileName = fileName.replacingOccurrences(of: "/", with: "_")  
178 - let fileURL = containerURL.appendingPathComponent(cleanFileName)  
179 -  
180 - do {  
181 - // 1. 确保容器目录存在  
182 - if !FileManager.default.fileExists(atPath: containerURL.path) {  
183 - try FileManager.default.createDirectory(  
184 - at: containerURL,  
185 - withIntermediateDirectories: true,  
186 - attributes: nil  
187 - )  
188 - }  
189 -  
190 - // 2. 如果文件已存在,先删除  
191 - if FileManager.default.fileExists(atPath: fileURL.path) {  
192 -// try FileManager.default.removeItem(at: fileURL)  
193 - return  
194 - }  
195 -  
196 - // 3. 写入文件  
197 - try data.write(to: fileURL, options: [.atomic])  
198 -  
199 - print("✅ 图片保存成功: \(fileURL.lastPathComponent)")  
200 -  
201 - } catch {  
202 - print("❌ 写入报错: \(error.localizedDescription)")  
203 - }  
204 - }  
205 } 115 }
@@ -104,7 +104,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject { @@ -104,7 +104,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
104 } 104 }
105 } 105 }
106 106
107 - private func handleCommand(command: String, json: String?){ 107 + private func handleCommand(command: String, json: String?, payload: [String: Any]? = nil){
108 if command == AppGroupMessageKey.login{ 108 if command == AppGroupMessageKey.login{
109 // 登录, 刷新所有数据 109 // 登录, 刷新所有数据
110 guard let data = json?.data(using: .utf8) else{ 110 guard let data = json?.data(using: .utf8) else{
@@ -115,7 +115,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject { @@ -115,7 +115,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
115 }else if command == AppGroupMessageKey.reloadTheme { 115 }else if command == AppGroupMessageKey.reloadTheme {
116 // 刷新表盘等 116 // 刷新表盘等
117 Task { @MainActor in 117 Task { @MainActor in
118 - WatchUserinfoManager.share.reloadTheme() 118 + if let payload, let theme = WatchLocalTheme(payload: payload) {
  119 + WatchUserinfoManager.share.applySyncedTheme(theme)
  120 + } else {
  121 + WatchUserinfoManager.share.reloadTheme()
  122 + }
  123 + }
  124 + }else if command == AppGroupMessageKey.deleteTheme{
  125 + Task { @MainActor in
  126 + WatchUserinfoManager.share.deleteLocalTheme()
119 } 127 }
120 }else if command == AppGroupMessageKey.reloadAll{ 128 }else if command == AppGroupMessageKey.reloadAll{
121 // 刷新数据 129 // 刷新数据
@@ -142,24 +150,49 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject { @@ -142,24 +150,49 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
142 func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { 150 func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
143 print("message-replyHandler: \(message)") 151 print("message-replyHandler: \(message)")
144 if let command = message["command"] as? String { 152 if let command = message["command"] as? String {
145 - handleCommand(command: command, json: message["json"] as? String) 153 + handleCommand(
  154 + command: command,
  155 + json: message["json"] as? String,
  156 + payload: message["payload"] as? [String: Any]
  157 + )
146 } 158 }
147 replyHandler(["success": true]) 159 replyHandler(["success": true])
148 } 160 }
149 161
150 func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { 162 func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
151 print("received userInfo transform: \(userInfo)") 163 print("received userInfo transform: \(userInfo)")
  164 + if let command = userInfo["command"] as? String {
  165 + handleCommand(
  166 + command: command,
  167 + json: userInfo["json"] as? String,
  168 + payload: userInfo["payload"] as? [String: Any]
  169 + )
  170 + }
152 } 171 }
153 172
154 func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { 173 func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
155 print("message: \(message)") 174 print("message: \(message)")
156 if let command = message["command"] as? String { 175 if let command = message["command"] as? String {
157 - handleCommand(command: command, json: message["json"] as? String) 176 + handleCommand(
  177 + command: command,
  178 + json: message["json"] as? String,
  179 + payload: message["payload"] as? [String: Any]
  180 + )
158 } 181 }
159 } 182 }
160 183
161 func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { 184 func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
162 print("received applicationContext: \(applicationContext)") 185 print("received applicationContext: \(applicationContext)")
  186 + if let command = applicationContext["command"] as? String {
  187 + if command != AppGroupMessageKey.login || applicationContext["json"] != nil {
  188 + handleCommand(
  189 + command: command,
  190 + json: applicationContext["json"] as? String,
  191 + payload: applicationContext["payload"] as? [String: Any]
  192 + )
  193 + return
  194 + }
  195 + }
163 do{ 196 do{
164 let data = try JSONSerialization.data(withJSONObject: applicationContext) 197 let data = try JSONSerialization.data(withJSONObject: applicationContext)
165 let model = try JSONDecoder().decode(UserInfoModel.self, from: data) 198 let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
@@ -9,23 +9,6 @@ import Foundation @@ -9,23 +9,6 @@ import Foundation
9 import WidgetKit 9 import WidgetKit
10 import Combine 10 import Combine
11 11
12 -extension WatchThemeModel{  
13 - func cleanCache() {  
14 - if let energeticImageURL{  
15 - AppGroupConstants.defaults?.removeObject(forKey: energeticImageURL)  
16 - }  
17 - if let normalImageURL{  
18 - AppGroupConstants.defaults?.removeObject(forKey: normalImageURL)  
19 - }  
20 - if let slightStressImageURL{  
21 - AppGroupConstants.defaults?.removeObject(forKey: slightStressImageURL)  
22 - }  
23 - if let stressfulImageURL{  
24 - AppGroupConstants.defaults?.removeObject(forKey: stressfulImageURL)  
25 - }  
26 - }  
27 -}  
28 -  
29 @MainActor 12 @MainActor
30 final class WatchUserinfoManager: ObservableObject { 13 final class WatchUserinfoManager: ObservableObject {
31 static let share = WatchUserinfoManager() 14 static let share = WatchUserinfoManager()
@@ -60,8 +43,8 @@ final class WatchUserinfoManager: ObservableObject { @@ -60,8 +43,8 @@ final class WatchUserinfoManager: ObservableObject {
60 } 43 }
61 44
62 // 表盘主题 45 // 表盘主题
63 - @Published var myTheme: WatchThemeModel?  
64 - @Published var otherTheme: WatchThemeModel? 46 + @Published var myTheme: WatchLocalTheme?
  47 + @Published var otherTheme: WatchLocalTheme?
65 48
66 private var didSetup = false 49 private var didSetup = false
67 50
@@ -117,8 +100,6 @@ final class WatchUserinfoManager: ObservableObject { @@ -117,8 +100,6 @@ final class WatchUserinfoManager: ObservableObject {
117 } 100 }
118 @MainActor 101 @MainActor
119 func logout(){ 102 func logout(){
120 - myTheme?.cleanCache()  
121 - otherTheme?.cleanCache()  
122 myUserInfo = nil 103 myUserInfo = nil
123 displayFriend = nil 104 displayFriend = nil
124 vipInfo = nil 105 vipInfo = nil
@@ -143,110 +124,101 @@ final class WatchUserinfoManager: ObservableObject { @@ -143,110 +124,101 @@ final class WatchUserinfoManager: ObservableObject {
143 } 124 }
144 125
145 func reloadTheme(){ 126 func reloadTheme(){
146 - reloadFriend() 127 + loadLocalWatchTheme()
  128 + Task {
  129 + await loadRemoteWatchTheme()
  130 + }
  131 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
  132 + }
  133 +
  134 + func applySyncedTheme(_ theme: WatchLocalTheme) {
  135 + saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
  136 + myTheme = theme
  137 + objectWillChange.send()
  138 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
  139 + }
  140 +
  141 + func deleteLocalTheme(){
  142 + deleteTheme(key: AppGroupConstants.Key.myWatchTheme)
  143 + myTheme = nil
  144 + objectWillChange.send()
  145 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
147 } 146 }
148 //MARK: - 表盘主题 147 //MARK: - 表盘主题
149 @MainActor 148 @MainActor
150 private func loadLocalWatchTheme(){ 149 private func loadLocalWatchTheme(){
151 - if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),  
152 - let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {  
153 - self.myTheme = model  
154 - }else{  
155 - self.myTheme = nil  
156 - }  
157 -  
158 - if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),  
159 - let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {  
160 - self.otherTheme = model  
161 - }else{  
162 - self.otherTheme = nil  
163 - }  
164 - downloadThemeImageIfNeeded() 150 + myTheme = loadTheme(key: AppGroupConstants.Key.myWatchTheme)
  151 + otherTheme = loadTheme(key: AppGroupConstants.Key.otherWatchTheme)
  152 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
165 } 153 }
166 154
167 private func loadRemoteWatchTheme() async{ 155 private func loadRemoteWatchTheme() async{
168 - let myNewTheme = try? await themeService.getCurrentTheme(userId: nil)  
169 - var otherNewTheme: WatchThemeModel? 156 + let myRemoteTheme = try? await themeService.getCurrentTheme(userId: nil)
  157 + var otherRemoteTheme: WatchThemeModel?
170 if let userId = displayFriend?.user_id{ 158 if let userId = displayFriend?.user_id{
171 - otherNewTheme = try? await themeService.getCurrentTheme(userId: userId) 159 + otherRemoteTheme = try? await themeService.getCurrentTheme(userId: userId)
172 } 160 }
173 - if let myNewTheme{  
174 - if myNewTheme != myTheme{  
175 - // 清除缓存  
176 - myTheme?.cleanCache()  
177 - myTheme = myNewTheme  
178 - do{  
179 - let data = try JSONEncoder().encode(myNewTheme)  
180 - AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)  
181 - }catch{}  
182 - }  
183 - }else{  
184 - myTheme?.cleanCache()  
185 - myTheme = nil  
186 - AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)  
187 - }  
188 - if let otherNewTheme{  
189 - if otherNewTheme != otherTheme{  
190 - // 清除缓存  
191 - otherTheme?.cleanCache()  
192 - otherTheme = otherNewTheme  
193 - do{  
194 - let data = try JSONEncoder().encode(otherNewTheme)  
195 - AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)  
196 - }catch{}  
197 - }  
198 - }else{  
199 - otherTheme?.cleanCache()  
200 - otherTheme = nil  
201 - AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)  
202 - }  
203 161
204 - downloadThemeImageIfNeeded()  
205 - } 162 + if (myTheme?.hasAllImages != true), let myRemoteTheme {
  163 + let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: myRemoteTheme)
  164 + myTheme = theme
  165 + saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
  166 + } else if myTheme == nil,
  167 + let legacyTheme = legacyRemoteTheme(key: AppGroupConstants.Key.myWatchTheme) {
  168 + let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
  169 + myTheme = theme
  170 + saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
  171 + }
206 172
207 - @MainActor  
208 - private func downloadThemeImageIfNeeded(){  
209 - guard let defaults = AppGroupConstants.defaults else { return }  
210 -  
211 - let imageURLs = [myTheme, otherTheme]  
212 - .compactMap { $0 }  
213 - .flatMap {  
214 - [  
215 - $0.energeticImageURL,  
216 - $0.normalImageURL,  
217 - $0.slightStressImageURL,  
218 - $0.stressfulImageURL  
219 - ] 173 + if let otherRemoteTheme {
  174 + let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: otherRemoteTheme)
  175 + if theme != otherTheme {
  176 + otherTheme = theme
  177 + saveTheme(theme, key: AppGroupConstants.Key.otherWatchTheme)
220 } 178 }
221 - .compactMap { $0 }  
222 -  
223 - let downloadUrls = imageURLs.filter({ defaults.data(forKey: $0) == nil })  
224 -  
225 - let downloadGroup = DispatchGroup()  
226 - var hasDownloadTask = false 179 + } else if displayFriend?.user_id != nil,
  180 + otherTheme == nil,
  181 + let legacyTheme = legacyRemoteTheme(key: AppGroupConstants.Key.otherWatchTheme) {
  182 + let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
  183 + otherTheme = theme
  184 + saveTheme(theme, key: AppGroupConstants.Key.otherWatchTheme)
  185 + } else {
  186 + otherTheme = nil
  187 + AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)
  188 + }
227 189
228 - for urlString in Set(downloadUrls) {  
229 - guard let url = URL(string: urlString) else { continue } 190 + WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
  191 + }
230 192
231 - hasDownloadTask = true  
232 - downloadGroup.enter()  
233 - URLSession.shared.dataTask(with: url) { data, _, error in  
234 - defer { downloadGroup.leave() }  
235 - guard error == nil, let data else { return }  
236 - defaults.set(data, forKey: urlString)  
237 - }.resume() 193 + private func loadTheme(key: String) -> WatchLocalTheme? {
  194 + guard let data = AppGroupConstants.defaults?.data(forKey: key) else { return nil }
  195 + if let theme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
  196 + return theme
238 } 197 }
  198 + return nil
  199 + }
239 200
240 - guard hasDownloadTask else {  
241 - WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)  
242 - return 201 + private func legacyRemoteTheme(key: String) -> WatchThemeModel? {
  202 + if let data = AppGroupConstants.defaults?.data(forKey: key),
  203 + let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  204 + return theme
243 } 205 }
244 - downloadGroup.notify(queue: .main) { [weak self] in  
245 - // 通过published刷新了watch  
246 - self?.objectWillChange.send()  
247 - // 再刷新小组件  
248 - WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue) 206 + if let jsonString = AppGroupConstants.defaults?.string(forKey: key),
  207 + let data = jsonString.data(using: .utf8),
  208 + let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  209 + return theme
249 } 210 }
  211 + return nil
  212 + }
  213 +
  214 + private func saveTheme(_ theme: WatchLocalTheme, key: String) {
  215 + guard let data = try? JSONEncoder().encode(theme) else { return }
  216 + AppGroupConstants.defaults?.set(data, forKey: key)
  217 + }
  218 +
  219 + private func deleteTheme(key: String){
  220 + AppGroupConstants.defaults?.removeObject(forKey: key)
  221 + AppGroupConstants.defaults?.synchronize()
250 } 222 }
251 223
252 //MARK: - 我的信息 224 //MARK: - 我的信息
@@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
3 archiveVersion = 1; 3 archiveVersion = 1;
4 classes = { 4 classes = {
5 }; 5 };
6 - objectVersion = 54; 6 + objectVersion = 77;
7 objects = { 7 objects = {
8 8
9 /* Begin PBXBuildFile section */ 9 /* Begin PBXBuildFile section */
@@ -342,10 +342,14 @@ @@ -342,10 +342,14 @@
342 inputFileListPaths = ( 342 inputFileListPaths = (
343 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 343 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
344 ); 344 );
  345 + inputPaths = (
  346 + );
345 name = "[CP] Embed Pods Frameworks"; 347 name = "[CP] Embed Pods Frameworks";
346 outputFileListPaths = ( 348 outputFileListPaths = (
347 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 349 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
348 ); 350 );
  351 + outputPaths = (
  352 + );
349 runOnlyForDeploymentPostprocessing = 0; 353 runOnlyForDeploymentPostprocessing = 0;
350 shellPath = /bin/sh; 354 shellPath = /bin/sh;
351 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 355 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -358,8 +362,12 @@ @@ -358,8 +362,12 @@
358 ); 362 );
359 inputFileListPaths = ( 363 inputFileListPaths = (
360 ); 364 );
  365 + inputPaths = (
  366 + );
361 outputFileListPaths = ( 367 outputFileListPaths = (
362 ); 368 );
  369 + outputPaths = (
  370 + );
363 runOnlyForDeploymentPostprocessing = 0; 371 runOnlyForDeploymentPostprocessing = 0;
364 shellPath = /bin/sh; 372 shellPath = /bin/sh;
365 shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; 373 shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
@@ -412,10 +420,14 @@ @@ -412,10 +420,14 @@
412 inputFileListPaths = ( 420 inputFileListPaths = (
413 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", 421 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
414 ); 422 );
  423 + inputPaths = (
  424 + );
415 name = "[CP] Copy Pods Resources"; 425 name = "[CP] Copy Pods Resources";
416 outputFileListPaths = ( 426 outputFileListPaths = (
417 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", 427 "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
418 ); 428 );
  429 + outputPaths = (
  430 + );
419 runOnlyForDeploymentPostprocessing = 0; 431 runOnlyForDeploymentPostprocessing = 0;
420 shellPath = /bin/sh; 432 shellPath = /bin/sh;
421 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; 433 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -261,13 +261,17 @@ final class HealthDataReader { @@ -261,13 +261,17 @@ final class HealthDataReader {
261 } 261 }
262 262
263 func fetchActivityTargetData(startDate: Date, endDate: Date) async throws -> NativeActivityTarget? { 263 func fetchActivityTargetData(startDate: Date, endDate: Date) async throws -> NativeActivityTarget? {
  264 + try await fetchActivityTargetDataList(startDate: startDate, endDate: endDate).last
  265 + }
  266 +
  267 + func fetchActivityTargetDataList(startDate: Date, endDate: Date) async throws -> [NativeActivityTarget] {
264 let calendar = Calendar.current 268 let calendar = Calendar.current
265 - var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate) 269 + var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate)
266 var end = calendar.dateComponents([.era, .year, .month, .day], from: endDate) 270 var end = calendar.dateComponents([.era, .year, .month, .day], from: endDate)
267 -  
268 - start.calendar = calendar  
269 - end.calendar = calendar  
270 - 271 +
  272 + start.calendar = calendar
  273 + end.calendar = calendar
  274 +
271 let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end) 275 let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
272 276
273 return try await withCheckedThrowingContinuation { continuation in 277 return try await withCheckedThrowingContinuation { continuation in
@@ -276,30 +280,42 @@ final class HealthDataReader { @@ -276,30 +280,42 @@ final class HealthDataReader {
276 continuation.resume(throwing: error) 280 continuation.resume(throwing: error)
277 return 281 return
278 } 282 }
279 - guard let summary = summaries?.last else {  
280 - continuation.resume(returning: nil)  
281 - return  
282 - }  
283 - continuation.resume(  
284 - returning: NativeActivityTarget(  
285 - move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),  
286 - stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),  
287 - activityMoveMode: Self.activityMoveModeValue(from: summary),  
288 - activeEnergyBurned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),  
289 - activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),  
290 - appleMoveTime: Self.appleMoveTimeValue(from: summary),  
291 - appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),  
292 - appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),  
293 - exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),  
294 - appleStandHours: summary.appleStandHours.doubleValue(for: .count()),  
295 - standHoursGoal: Self.standHoursGoalValue(from: summary)  
296 - )  
297 - ) 283 + let targets = (summaries ?? [])
  284 + .sorted {
  285 + let lhsDate = calendar.date(from: $0.dateComponents(for: calendar)) ?? .distantPast
  286 + let rhsDate = calendar.date(from: $1.dateComponents(for: calendar)) ?? .distantPast
  287 + return lhsDate < rhsDate
  288 + }
  289 + .map { Self.activityTarget(from: $0, calendar: calendar) }
  290 + continuation.resume(returning: targets)
298 } 291 }
299 healthStore.execute(query) 292 healthStore.execute(query)
300 } 293 }
301 } 294 }
302 295
  296 + private static func activityTarget(
  297 + from summary: HKActivitySummary,
  298 + calendar: Calendar
  299 + ) -> NativeActivityTarget {
  300 + let day = calendar.date(from: summary.dateComponents(for: calendar))
  301 + let timestamp = day.map { calendar.startOfDay(for: $0).timeIntervalSince1970 }
  302 +
  303 + return NativeActivityTarget(
  304 + healthValueTimestamp: timestamp,
  305 + move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
  306 + stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
  307 + activityMoveMode: Self.activityMoveModeValue(from: summary),
  308 + activeEnergyBurned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
  309 + activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
  310 + appleMoveTime: Self.appleMoveTimeValue(from: summary),
  311 + appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
  312 + appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
  313 + exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
  314 + appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
  315 + standHoursGoal: Self.standHoursGoalValue(from: summary)
  316 + )
  317 + }
  318 +
303 private static func activityMoveModeValue(from summary: HKActivitySummary) -> Int? { 319 private static func activityMoveModeValue(from summary: HKActivitySummary) -> Int? {
304 if #available(iOS 14.0, *) { 320 if #available(iOS 14.0, *) {
305 return summary.activityMoveMode.rawValue 321 return summary.activityMoveMode.rawValue
@@ -225,6 +225,10 @@ final class HealthKitService { @@ -225,6 +225,10 @@ final class HealthKitService {
225 try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate) 225 try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate)
226 } 226 }
227 227
  228 + func fetchActivityTargetDataList(startDate: Date, endDate: Date) async throws -> [NativeActivityTarget] {
  229 + try await reader.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
  230 + }
  231 +
228 private func earliestStartDate() -> Date { 232 private func earliestStartDate() -> Date {
229 NativeHealthDataType.allCases 233 NativeHealthDataType.allCases
230 .filter { $0 != .unknown } 234 .filter { $0 != .unknown }
@@ -274,13 +278,16 @@ final class HealthKitService { @@ -274,13 +278,16 @@ final class HealthKitService {
274 } 278 }
275 279
276 guard !uploadTypes.isEmpty || includeActivityTarget else { return } 280 guard !uploadTypes.isEmpty || includeActivityTarget else { return }
277 - let success = await NativeHealthDataUploader.shared.uploadObservedChange( 281 + let result = await NativeHealthDataUploader.shared.uploadObservedChange(
278 types: uploadTypes, 282 types: uploadTypes,
279 includeActivityTarget: includeActivityTarget, 283 includeActivityTarget: includeActivityTarget,
  284 + observedSampleTypeIdentifier: sampleType.identifier,
280 service: self 285 service: self
281 ) 286 )
282 - if success {  
283 - uploadTypes.forEach { syncStore.save(date: Date(), for: $0) } 287 + if result.success {
  288 + result.latestTimestamps.forEach { type, timestamp in
  289 + syncStore.save(date: Date(timeIntervalSince1970: timestamp), for: type)
  290 + }
284 } 291 }
285 } 292 }
286 293
@@ -19,7 +19,7 @@ enum NativeHealthKitError: LocalizedError { @@ -19,7 +19,7 @@ enum NativeHealthKitError: LocalizedError {
19 } 19 }
20 20
21 /// Raw values match the original SwiftUI project server contract. 21 /// Raw values match the original SwiftUI project server contract.
22 -enum NativeHealthDataType: Int, Codable, CaseIterable { 22 +enum NativeHealthDataType: Int, Codable, CaseIterable, Hashable {
23 case unknown = 0 23 case unknown = 0
24 case hrv = 1 24 case hrv = 1
25 case heartRate = 2 25 case heartRate = 2
@@ -50,6 +50,7 @@ struct NativeSleepInterval: Codable { @@ -50,6 +50,7 @@ struct NativeSleepInterval: Codable {
50 } 50 }
51 51
52 struct NativeActivityTarget { 52 struct NativeActivityTarget {
  53 + let healthValueTimestamp: TimeInterval?
53 let move: Int? 54 let move: Int?
54 let stand: Int? 55 let stand: Int?
55 let activityMoveMode: Int? 56 let activityMoveMode: Int?
@@ -71,7 +72,9 @@ extension NativeActivityTarget { @@ -71,7 +72,9 @@ extension NativeActivityTarget {
71 var uploadBody: [String: Any] { 72 var uploadBody: [String: Any] {
72 var body: [String: Any] = [:] 73 var body: [String: Any] = [:]
73 for child in Mirror(reflecting: self).children { 74 for child in Mirror(reflecting: self).children {
74 - guard let label = child.label, let value = Self.unwrapOptional(child.value) else { 75 + guard let label = child.label,
  76 + label != "healthValueTimestamp",
  77 + let value = Self.unwrapOptional(child.value) else {
75 continue 78 continue
76 } 79 }
77 body[label.camelCaseToSnakeCase] = value 80 body[label.camelCaseToSnakeCase] = value
1 import Foundation 1 import Foundation
  2 +import HealthKit
2 3
3 enum NativeHealthUploadConfiguration { 4 enum NativeHealthUploadConfiguration {
4 /// Process-lifetime throttle. Each health data type can start at most one 5 /// Process-lifetime throttle. Each health data type can start at most one
5 /// upload request during this interval. 6 /// upload request during this interval.
6 static let minimumTriggerInterval: TimeInterval = 60 7 static let minimumTriggerInterval: TimeInterval = 60
  8 + static let firstUploadLookbackYears = 1
7 } 9 }
8 10
9 struct NativeHealthUploadSummary { 11 struct NativeHealthUploadSummary {
@@ -14,11 +16,21 @@ struct NativeHealthUploadSummary { @@ -14,11 +16,21 @@ struct NativeHealthUploadSummary {
14 let sleepCount: Int 16 let sleepCount: Int
15 } 17 }
16 18
  19 +struct NativeHealthDebugUploadedDataPoint {
  20 + let dataType: NativeHealthDataType
  21 + let dataTypeRawValue: Int
  22 + let dataTypeName: String
  23 + let value: Double
  24 + let timestamp: TimeInterval
  25 +}
  26 +
17 enum NativeHealthUploadError: LocalizedError { 27 enum NativeHealthUploadError: LocalizedError {
18 case invalidServerURL 28 case invalidServerURL
19 case missingAccessToken 29 case missingAccessToken
20 case invalidResponse 30 case invalidResponse
21 case requestFailed(path: String, statusCode: Int, body: String?) 31 case requestFailed(path: String, statusCode: Int, body: String?)
  32 + case missingUserId
  33 + case invalidQueryAnchor
22 34
23 var errorDescription: String? { 35 var errorDescription: String? {
24 switch self { 36 switch self {
@@ -30,6 +42,10 @@ enum NativeHealthUploadError: LocalizedError { @@ -30,6 +42,10 @@ enum NativeHealthUploadError: LocalizedError {
30 return "健康数据上传接口响应无效" 42 return "健康数据上传接口响应无效"
31 case .requestFailed(let path, let statusCode, let body): 43 case .requestFailed(let path, let statusCode, let body):
32 return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")" 44 return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")"
  45 + case .missingUserId:
  46 + return "缺少用户 ID,无法读取本地 HealthKit Anchor"
  47 + case .invalidQueryAnchor:
  48 + return "HealthKit Anchor 编解码失败"
33 } 49 }
34 } 50 }
35 } 51 }
@@ -41,12 +57,17 @@ enum NativeHealthUploadError: LocalizedError { @@ -41,12 +57,17 @@ enum NativeHealthUploadError: LocalizedError {
41 /// `/data_upload/common/` response. 57 /// `/data_upload/common/` response.
42 actor NativeHealthDataUploader { 58 actor NativeHealthDataUploader {
43 static let shared = NativeHealthDataUploader() 59 static let shared = NativeHealthDataUploader()
  60 + private nonisolated static let debugUploadedDataStore = NativeHealthDebugUploadedDataStore()
  61 +
  62 + struct ObservedUploadResult {
  63 + let success: Bool
  64 + let latestTimestamps: [NativeHealthDataType: TimeInterval]
  65 + }
44 66
45 private let session: URLSession 67 private let session: URLSession
46 private let defaultUploadTimeWeek = 1 68 private let defaultUploadTimeWeek = 1
47 - private let firstUploadYear = 1  
48 private let uploadBatchSize = 500 69 private let uploadBatchSize = 500
49 - private let sleepUploadLookback: TimeInterval = 24 * 60 * 60 70 + private let syncStore = HealthSyncStateStore()
50 private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:] 71 private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
51 72
52 init(session: URLSession = .shared) { 73 init(session: URLSession = .shared) {
@@ -56,7 +77,6 @@ actor NativeHealthDataUploader { @@ -56,7 +77,6 @@ actor NativeHealthDataUploader {
56 func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary { 77 func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
57 guard AppShared.shared.token?.isEmpty == false else { 78 guard AppShared.shared.token?.isEmpty == false else {
58 let message = NativeHealthUploadError.missingAccessToken.localizedDescription 79 let message = NativeHealthUploadError.missingAccessToken.localizedDescription
59 - DebugLogger.debugLog("uploadAll skipped, user is not logged in")  
60 return NativeHealthUploadSummary( 80 return NativeHealthUploadSummary(
61 commonUploadSuccess: false, 81 commonUploadSuccess: false,
62 sleepUploadSuccess: false, 82 sleepUploadSuccess: false,
@@ -71,9 +91,8 @@ actor NativeHealthDataUploader { @@ -71,9 +91,8 @@ actor NativeHealthDataUploader {
71 var sleepUploadSuccess = true 91 var sleepUploadSuccess = true
72 var errorMessages: [String] = [] 92 var errorMessages: [String] = []
73 93
74 - DebugLogger.debugLog("uploadAll started ")  
75 do { 94 do {
76 - let uploadTimeList = try await processLastUploadTime() 95 + let uploadTimeList = try await resolvedUploadTimeList()
77 96
78 for type in Self.commonUploadTypes { 97 for type in Self.commonUploadTypes {
79 let result = await upload( 98 let result = await upload(
@@ -82,6 +101,7 @@ actor NativeHealthDataUploader { @@ -82,6 +101,7 @@ actor NativeHealthDataUploader {
82 trigger: .full, 101 trigger: .full,
83 service: service 102 service: service
84 ) 103 )
  104 + commitUploadTimestamps(result.latestUploadedTimestamps)
85 commonCount += result.uploadedCount 105 commonCount += result.uploadedCount
86 if !result.success { 106 if !result.success {
87 commonUploadSuccess = false 107 commonUploadSuccess = false
@@ -89,6 +109,7 @@ actor NativeHealthDataUploader { @@ -89,6 +109,7 @@ actor NativeHealthDataUploader {
89 errorMessages.append(errorMessage) 109 errorMessages.append(errorMessage)
90 } 110 }
91 } 111 }
  112 +
92 } 113 }
93 114
94 let sleepResult = await upload( 115 let sleepResult = await upload(
@@ -97,6 +118,7 @@ actor NativeHealthDataUploader { @@ -97,6 +118,7 @@ actor NativeHealthDataUploader {
97 trigger: .full, 118 trigger: .full,
98 service: service 119 service: service
99 ) 120 )
  121 + commitUploadTimestamps(sleepResult.latestUploadedTimestamps)
100 sleepCount = sleepResult.uploadedCount 122 sleepCount = sleepResult.uploadedCount
101 sleepUploadSuccess = sleepResult.success 123 sleepUploadSuccess = sleepResult.success
102 if let errorMessage = sleepResult.errorMessage { 124 if let errorMessage = sleepResult.errorMessage {
@@ -107,6 +129,7 @@ actor NativeHealthDataUploader { @@ -107,6 +129,7 @@ actor NativeHealthDataUploader {
107 uploadTimeList: uploadTimeList, 129 uploadTimeList: uploadTimeList,
108 service: service 130 service: service
109 ) 131 )
  132 + commitUploadTimestamps(activityTargetUploaded.latestUploadedTimestamps)
110 if !activityTargetUploaded.success { 133 if !activityTargetUploaded.success {
111 commonUploadSuccess = false 134 commonUploadSuccess = false
112 if let errorMessage = activityTargetUploaded.errorMessage { 135 if let errorMessage = activityTargetUploaded.errorMessage {
@@ -117,11 +140,9 @@ actor NativeHealthDataUploader { @@ -117,11 +140,9 @@ actor NativeHealthDataUploader {
117 commonUploadSuccess = false 140 commonUploadSuccess = false
118 sleepUploadSuccess = false 141 sleepUploadSuccess = false
119 errorMessages.append(error.localizedDescription) 142 errorMessages.append(error.localizedDescription)
120 - DebugLogger.debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)") 143 + DebugLogger.debugLog("[iOS][upload][failed] type=uploadAll error=\(error.localizedDescription)")
121 } 144 }
122 145
123 - DebugLogger.debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")  
124 -  
125 return NativeHealthUploadSummary( 146 return NativeHealthUploadSummary(
126 commonUploadSuccess: commonUploadSuccess, 147 commonUploadSuccess: commonUploadSuccess,
127 sleepUploadSuccess: sleepUploadSuccess, 148 sleepUploadSuccess: sleepUploadSuccess,
@@ -133,20 +154,20 @@ actor NativeHealthDataUploader { @@ -133,20 +154,20 @@ actor NativeHealthDataUploader {
133 154
134 func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool { 155 func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
135 guard AppShared.shared.token?.isEmpty == false else { 156 guard AppShared.shared.token?.isEmpty == false else {
136 - DebugLogger.debugLog("upload(\(type.debugName)) skipped, user is not logged in")  
137 return false 157 return false
138 } 158 }
139 do { 159 do {
140 - let uploadTimeList = try await processLastUploadTime()  
141 - return await upload( 160 + let uploadTimeList = try await resolvedUploadTimeList()
  161 + let result = await upload(
142 type: type, 162 type: type,
143 uploadTimeList: uploadTimeList, 163 uploadTimeList: uploadTimeList,
144 trigger: .manual, 164 trigger: .manual,
145 service: service 165 service: service
146 - ).success 166 + )
  167 + commitUploadTimestamps(result.latestUploadedTimestamps)
  168 + return result.success
147 } catch { 169 } catch {
148 - DebugLogger.debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")  
149 - DebugLogger.debugLog("Health upload failed to process last upload time: \(error.localizedDescription)") 170 + DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
150 return false 171 return false
151 } 172 }
152 } 173 }
@@ -154,17 +175,18 @@ actor NativeHealthDataUploader { @@ -154,17 +175,18 @@ actor NativeHealthDataUploader {
154 func uploadObservedChange( 175 func uploadObservedChange(
155 types: [NativeHealthDataType], 176 types: [NativeHealthDataType],
156 includeActivityTarget: Bool, 177 includeActivityTarget: Bool,
  178 + observedSampleTypeIdentifier: String? = nil,
157 service: HealthKitService = .shared 179 service: HealthKitService = .shared
158 - ) async -> Bool { 180 + ) async -> ObservedUploadResult {
159 guard AppShared.shared.token?.isEmpty == false else { 181 guard AppShared.shared.token?.isEmpty == false else {
160 - DebugLogger.debugLog("observed change skipped, user is not logged in")  
161 - return false 182 + return ObservedUploadResult(success: false, latestTimestamps: [:])
162 } 183 }
163 184
164 do { 185 do {
165 - let uploadTimeList = try await processLastUploadTime() 186 + let uploadTimeList = try await resolvedUploadTimeList()
166 var success = true 187 var success = true
167 var uploadedNewData = false 188 var uploadedNewData = false
  189 + var latestTimestamps: [NativeHealthDataType: TimeInterval] = [:]
168 for type in types { 190 for type in types {
169 let result = await upload( 191 let result = await upload(
170 type: type, 192 type: type,
@@ -172,22 +194,38 @@ actor NativeHealthDataUploader { @@ -172,22 +194,38 @@ actor NativeHealthDataUploader {
172 trigger: .observer, 194 trigger: .observer,
173 service: service 195 service: service
174 ) 196 )
  197 + commitUploadTimestamps(result.latestUploadedTimestamps)
175 success = success && result.success 198 success = success && result.success
176 uploadedNewData = uploadedNewData || result.uploadedCount > 0 199 uploadedNewData = uploadedNewData || result.uploadedCount > 0
  200 + if !result.latestUploadedTimestamps.isEmpty {
  201 + result.latestUploadedTimestamps.forEach { type, timestamp in
  202 + latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
  203 + }
  204 + } else if let latestUploadedTimestamp = result.latestUploadedTimestamp {
  205 + latestTimestamps[type] = latestUploadedTimestamp
  206 + }
177 } 207 }
178 - if includeActivityTarget && uploadedNewData { 208 + if includeActivityTarget && (uploadedNewData || types.isEmpty) {
179 let result = await uploadActivityTargetIfNeeded( 209 let result = await uploadActivityTargetIfNeeded(
180 uploadTimeList: uploadTimeList, 210 uploadTimeList: uploadTimeList,
181 service: service 211 service: service
182 ) 212 )
  213 + commitUploadTimestamps(result.latestUploadedTimestamps)
183 success = success && result.success 214 success = success && result.success
  215 + result.latestUploadedTimestamps.forEach { type, timestamp in
  216 + latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
  217 + }
184 } 218 }
185 - return success 219 + return ObservedUploadResult(success: success, latestTimestamps: latestTimestamps)
186 } catch { 220 } catch {
187 - DebugLogger.debugLog("observed change upload failed, error=\(error.localizedDescription)")  
188 - return false 221 + DebugLogger.debugLog("[iOS][upload][failed] type=observer error=\(error.localizedDescription)")
  222 + return ObservedUploadResult(success: false, latestTimestamps: [:])
189 } 223 }
190 } 224 }
  225 +
  226 + nonisolated func getDebugCurrentUploadedData() -> [NativeHealthDebugUploadedDataPoint] {
  227 + Self.debugUploadedDataStore.snapshot()
  228 + }
191 } 229 }
192 230
193 private extension NativeHealthDataUploader { 231 private extension NativeHealthDataUploader {
@@ -195,6 +233,9 @@ private extension NativeHealthDataUploader { @@ -195,6 +233,9 @@ private extension NativeHealthDataUploader {
195 let success: Bool 233 let success: Bool
196 let uploadedCount: Int 234 let uploadedCount: Int
197 let errorMessage: String? 235 let errorMessage: String?
  236 + var latestUploadedTimestamp: TimeInterval?
  237 + var latestUploadedTimestamps: [NativeHealthDataType: TimeInterval] = [:]
  238 + var wasThrottled = false
198 } 239 }
199 240
200 enum UploadTrigger: String { 241 enum UploadTrigger: String {
@@ -218,6 +259,85 @@ private extension NativeHealthDataUploader { @@ -218,6 +259,85 @@ private extension NativeHealthDataUploader {
218 .respiratoryRate, 259 .respiratoryRate,
219 .irregularHeartRhythm, 260 .irregularHeartRhythm,
220 ] 261 ]
  262 + static let activityTargetAnchorType: NativeHealthDataType = .activeEnergy
  263 + static let activityTargetTimestampTypes: [NativeHealthDataType] = [
  264 + .activeEnergy,
  265 + .exercise,
  266 + .stand,
  267 + .steps,
  268 + ]
  269 + static let uploadAnchorTypes: [NativeHealthDataType] = [
  270 + .hrv,
  271 + .heartRate,
  272 + .walkingHeartRate,
  273 + .restingHeartRate,
  274 + .oxygenSaturation,
  275 + .activeEnergy,
  276 + .sleepingWristTemperature,
  277 + .respiratoryRate,
  278 + .irregularHeartRhythm,
  279 + .sleep,
  280 + ]
  281 +
  282 + static func uploadAnchorType(for type: NativeHealthDataType) -> NativeHealthDataType {
  283 + switch type {
  284 + case .sleepingHeartRate:
  285 + return .heartRate
  286 + case .exercise, .stand, .steps:
  287 + return .activeEnergy
  288 + default:
  289 + return type
  290 + }
  291 + }
  292 +
  293 + static func uploadTypes(forAnchorType anchorType: NativeHealthDataType) -> [NativeHealthDataType] {
  294 + NativeHealthDataType.allCases.filter {
  295 + $0 != .unknown && uploadAnchorType(for: $0) == anchorType
  296 + }
  297 + }
  298 +
  299 + func resolvedUploadTimeList() async throws -> NativeHealthUploadTimeList {
  300 + switch HealthUploadCursorConfiguration.mode {
  301 + case .serverTime:
  302 + return try await processLastUploadTime()
  303 + case .localAnchor:
  304 + let timestamp = firstUploadStartDate().timeIntervalSince1970
  305 + return NativeHealthUploadTimeList(latestDataTimeList: Self.uploadAnchorTypes.map { anchorType in
  306 + let date = syncStore.lastSyncDate(for: anchorType) ?? Date(timeIntervalSince1970: timestamp)
  307 + return .init(dataType: anchorType, latestDataTime: date.timeIntervalSince1970)
  308 + })
  309 + }
  310 + }
  311 +
  312 + func firstUploadStartDate() -> Date {
  313 + let years = NativeHealthUploadConfiguration.firstUploadLookbackYears
  314 + let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())
  315 + ?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))
  316 + return Calendar.current.startOfDay(for: date)
  317 + }
  318 +
  319 + func resolvedAnchorSourceIdentifier(
  320 + for type: NativeHealthDataType,
  321 + override: String?
  322 + ) -> String {
  323 + if let override { return override }
  324 + switch type {
  325 + case .hrv: return HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue
  326 + case .heartRate, .sleepingHeartRate: return HKQuantityTypeIdentifier.heartRate.rawValue
  327 + case .walkingHeartRate: return HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue
  328 + case .restingHeartRate: return HKQuantityTypeIdentifier.restingHeartRate.rawValue
  329 + case .oxygenSaturation: return HKQuantityTypeIdentifier.oxygenSaturation.rawValue
  330 + case .activeEnergy: return HKQuantityTypeIdentifier.activeEnergyBurned.rawValue
  331 + case .exercise: return HKQuantityTypeIdentifier.appleExerciseTime.rawValue
  332 + case .stand: return HKQuantityTypeIdentifier.appleStandTime.rawValue
  333 + case .steps: return HKQuantityTypeIdentifier.stepCount.rawValue
  334 + case .sleepingWristTemperature: return HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue
  335 + case .respiratoryRate: return HKQuantityTypeIdentifier.respiratoryRate.rawValue
  336 + case .irregularHeartRhythm: return HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue
  337 + case .sleep: return HKCategoryTypeIdentifier.sleepAnalysis.rawValue
  338 + case .unknown: return "unknown"
  339 + }
  340 + }
221 341
222 func upload( 342 func upload(
223 type: NativeHealthDataType, 343 type: NativeHealthDataType,
@@ -227,16 +347,13 @@ private extension NativeHealthDataUploader { @@ -227,16 +347,13 @@ private extension NativeHealthDataUploader {
227 ) async -> UploadTaskResult { 347 ) async -> UploadTaskResult {
228 guard type != .unknown, 348 guard type != .unknown,
229 let startUploadDate = uploadTimeList.latestDataTime(for: type) else { 349 let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
230 - DebugLogger.debugLog("[\(type.debugName)] skipped, no upload start date") 350 + DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=missing upload start date")
231 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 351 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
232 } 352 }
233 353
234 let endUploadDate = Date() 354 let endUploadDate = Date()
235 - DebugLogger.debugLog(  
236 - "[\(type.debugName)] upload started, trigger=\(trigger.rawValue), range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"  
237 - )  
238 guard endUploadDate >= startUploadDate else { 355 guard endUploadDate >= startUploadDate else {
239 - DebugLogger.debugLog("[\(type.debugName)] upload failed, start date is later than end date") 356 + DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=start date is later than end date")
240 return UploadTaskResult( 357 return UploadTaskResult(
241 success: false, 358 success: false,
242 uploadedCount: 0, 359 uploadedCount: 0,
@@ -247,33 +364,29 @@ private extension NativeHealthDataUploader { @@ -247,33 +364,29 @@ private extension NativeHealthDataUploader {
247 do { 364 do {
248 switch type { 365 switch type {
249 case .sleep: 366 case .sleep:
250 - DebugLogger.debugLog("[\(type.debugName)] reading sleep data")  
251 let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate) 367 let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
252 .filter { $0.toTime > startUploadDate.timeIntervalSince1970 } 368 .filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
253 .sorted { $0.toTime < $1.toTime } 369 .sorted { $0.toTime < $1.toTime }
254 - debugLogTimeComparison(  
255 - type: type,  
256 - serverTime: startUploadDate.timeIntervalSince1970,  
257 - newestTime: data.map(\.toTime).max()  
258 - )  
259 - DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")  
260 guard !data.isEmpty else { 370 guard !data.isEmpty else {
261 - DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")  
262 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 371 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
263 } 372 }
264 guard allowUploadTrigger(for: type) else { 373 guard allowUploadTrigger(for: type) else {
265 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 374 + return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
266 } 375 }
267 - DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")  
268 try await uploadSleep(data) 376 try await uploadSleep(data)
  377 + let latestUploadedTimestamp = data.map(\.toTime).max()
269 notifyFlutterUpload( 378 notifyFlutterUpload(
270 type: type, 379 type: type,
271 - timestamp: data.map(\.toTime).max() ?? endUploadDate.timeIntervalSince1970 380 + timestamp: latestUploadedTimestamp!
  381 + )
  382 + return UploadTaskResult(
  383 + success: true,
  384 + uploadedCount: data.count,
  385 + errorMessage: nil,
  386 + latestUploadedTimestamp: latestUploadedTimestamp,
  387 + latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
272 ) 388 )
273 - DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")  
274 - return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)  
275 default: 389 default:
276 - DebugLogger.debugLog("[\(type.debugName)] reading common data")  
277 let data = try await fetchCommonData( 390 let data = try await fetchCommonData(
278 type: type, 391 type: type,
279 startDate: startUploadDate, 392 startDate: startUploadDate,
@@ -282,30 +395,28 @@ private extension NativeHealthDataUploader { @@ -282,30 +395,28 @@ private extension NativeHealthDataUploader {
282 ) 395 )
283 .filter { $0.time > startUploadDate.timeIntervalSince1970 } 396 .filter { $0.time > startUploadDate.timeIntervalSince1970 }
284 .sorted { $0.time < $1.time } 397 .sorted { $0.time < $1.time }
285 - debugLogTimeComparison(  
286 - type: type,  
287 - serverTime: startUploadDate.timeIntervalSince1970,  
288 - newestTime: data.map(\.time).max()  
289 - )  
290 - DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")  
291 guard !data.isEmpty else { 398 guard !data.isEmpty else {
292 - DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")  
293 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 399 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
294 } 400 }
295 guard allowUploadTrigger(for: type) else { 401 guard allowUploadTrigger(for: type) else {
296 - return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 402 + return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
297 } 403 }
298 - DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")  
299 try await uploadCommon(data) 404 try await uploadCommon(data)
  405 + let latestUploadedTimestamp = data.map(\.time).max()
300 notifyFlutterUpload( 406 notifyFlutterUpload(
301 type: type, 407 type: type,
302 - timestamp: data.map(\.time).max() ?? endUploadDate.timeIntervalSince1970 408 + timestamp: latestUploadedTimestamp!
  409 + )
  410 + return UploadTaskResult(
  411 + success: true,
  412 + uploadedCount: data.count,
  413 + errorMessage: nil,
  414 + latestUploadedTimestamp: latestUploadedTimestamp,
  415 + latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
303 ) 416 )
304 - DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")  
305 - return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)  
306 } 417 }
307 } catch { 418 } catch {
308 - DebugLogger.debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)") 419 + DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
309 return UploadTaskResult( 420 return UploadTaskResult(
310 success: false, 421 success: false,
311 uploadedCount: 0, 422 uploadedCount: 0,
@@ -314,6 +425,17 @@ private extension NativeHealthDataUploader { @@ -314,6 +425,17 @@ private extension NativeHealthDataUploader {
314 } 425 }
315 } 426 }
316 427
  428 + func commitUploadTimestamps(_ timestamps: [NativeHealthDataType: TimeInterval]) {
  429 + let grouped = timestamps.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, item in
  430 + let anchorType = Self.uploadAnchorType(for: item.key)
  431 + result[anchorType] = max(result[anchorType] ?? 0, item.value)
  432 + }
  433 + grouped.forEach { anchorType, timestamp in
  434 + let currentTimestamp = syncStore.lastSyncDate(for: anchorType)?.timeIntervalSince1970 ?? 0
  435 + syncStore.save(date: Date(timeIntervalSince1970: max(currentTimestamp, timestamp)), for: anchorType)
  436 + }
  437 + }
  438 +
317 func fetchCommonData( 439 func fetchCommonData(
318 type: NativeHealthDataType, 440 type: NativeHealthDataType,
319 startDate: Date, 441 startDate: Date,
@@ -356,28 +478,38 @@ private extension NativeHealthDataUploader { @@ -356,28 +478,38 @@ private extension NativeHealthDataUploader {
356 uploadTimeList: NativeHealthUploadTimeList, 478 uploadTimeList: NativeHealthUploadTimeList,
357 service: HealthKitService 479 service: HealthKitService
358 ) async -> UploadTaskResult { 480 ) async -> UploadTaskResult {
359 - let startDate = uploadTimeList.latestDataTime(for: .activeEnergy)  
360 - ?? Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())  
361 - ?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60) 481 + let calendar = Calendar.current
  482 + let recentStartDate = calendar.startOfDay(
  483 + for: calendar.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
  484 + ?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
  485 + )
  486 + let startDate = uploadTimeList.latestDataTime(for: Self.activityTargetAnchorType) ?? recentStartDate
362 let endDate = Date() 487 let endDate = Date()
363 488
364 - DebugLogger.debugLog(  
365 - "[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))"  
366 - )  
367 do { 489 do {
368 - guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate),  
369 - target.move != nil || target.stand != nil else {  
370 - DebugLogger.debugLog("[activityTarget] no target data") 490 + let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
  491 + guard let target = targets.last,
  492 + targets.contains(where: { $0.move != nil || $0.stand != nil }) else {
371 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil) 493 return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
372 } 494 }
373 - DebugLogger.debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")  
374 try await uploadActivityTarget(target) 495 try await uploadActivityTarget(target)
375 - let uploadedTypes = try await uploadActivityTargetHealthValues(target) 496 + let uploadedTypes = try await uploadActivityTargetHealthValues(targets)
376 uploadedTypes.forEach { 497 uploadedTypes.forEach {
377 notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp) 498 notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp)
378 } 499 }
379 - DebugLogger.debugLog("[activityTarget] upload success")  
380 - return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil) 500 + let latestTimestamp = uploadedTypes.map(\.timestamp).max()
  501 + let latestUploadedTimestamps = latestTimestamp.map { timestamp in
  502 + Self.activityTargetTimestampTypes.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, type in
  503 + result[type] = timestamp
  504 + }
  505 + } ?? [:]
  506 + return UploadTaskResult(
  507 + success: true,
  508 + uploadedCount: uploadedTypes.isEmpty ? 0 : 1,
  509 + errorMessage: nil,
  510 + latestUploadedTimestamp: latestTimestamp,
  511 + latestUploadedTimestamps: latestUploadedTimestamps
  512 + )
381 } catch { 513 } catch {
382 DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)") 514 DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
383 return UploadTaskResult( 515 return UploadTaskResult(
@@ -391,35 +523,21 @@ private extension NativeHealthDataUploader { @@ -391,35 +523,21 @@ private extension NativeHealthDataUploader {
391 func processLastUploadTime() async throws -> NativeHealthUploadTimeList { 523 func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
392 let serverTimeList = try await fetchLastUploadTime() 524 let serverTimeList = try await fetchLastUploadTime()
393 var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = [] 525 var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
394 - DebugLogger.debugLog("processLastUploadTime started, source=server")  
395 -  
396 - let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date())  
397 - ?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60))  
398 - let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970  
399 -  
400 - for type in NativeHealthDataType.allCases where type != .unknown {  
401 - let serverTime = serverTimeList.latestTimeInterval(for: type)  
402 - let finalTime: TimeInterval  
403 - let sourceDescription: String  
404 - if type == .sleep {  
405 - if let serverTime {  
406 - finalTime = max(0, serverTime - sleepUploadLookback)  
407 - sourceDescription = "server minus 24h"  
408 - } else {  
409 - finalTime = twoYearsAgoMidnight  
410 - sourceDescription = "missing; first sleep upload uses two years"  
411 - }  
412 - } else {  
413 - finalTime = serverTime ?? twoYearsAgoMidnight  
414 - sourceDescription = serverTime == nil ? "missing; first upload uses two years" : "server"  
415 - }  
416 526
417 - DebugLogger.debugLog(  
418 - "[\(type.debugName)] server start time=\(serverTime.map { Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: $0)) } ?? "<missing>"), source=\(sourceDescription), resolved=\(Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: finalTime)))"  
419 - ) 527 + let lookbackYears = NativeHealthUploadConfiguration.firstUploadLookbackYears
  528 + let firstUploadStartDate = Calendar.current.date(byAdding: .year, value: -lookbackYears, to: Date())
  529 + ?? Date(timeIntervalSinceNow: -TimeInterval(lookbackYears * 365 * 24 * 60 * 60))
  530 + let firstUploadStartTimestamp = Calendar.current.startOfDay(for: firstUploadStartDate).timeIntervalSince1970
  531 +
  532 + for anchorType in Self.uploadAnchorTypes {
  533 + let serverTime = Self.uploadTypes(forAnchorType: anchorType)
  534 + .compactMap { serverTimeList.rawLatestTimeInterval(for: $0) }
  535 + .max()
  536 + let finalTime = serverTime ?? firstUploadStartTimestamp
  537 +
420 resultTimeList.append( 538 resultTimeList.append(
421 NativeHealthUploadTimeList.HealthUploadTime( 539 NativeHealthUploadTimeList.HealthUploadTime(
422 - dataType: type, 540 + dataType: anchorType,
423 latestDataTime: finalTime 541 latestDataTime: finalTime
424 ) 542 )
425 ) 543 )
@@ -443,15 +561,13 @@ private extension NativeHealthDataUploader { @@ -443,15 +561,13 @@ private extension NativeHealthDataUploader {
443 ] as [String: Any] 561 ] as [String: Any]
444 } 562 }
445 try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list]) 563 try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
  564 + recordDebugUploadedCommonData(batch)
446 } 565 }
447 } 566 }
448 567
449 func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool { 568 func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {
450 if let lastDate = lastUploadTriggerDates[type], 569 if let lastDate = lastUploadTriggerDates[type],
451 now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval { 570 now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {
452 - DebugLogger.debugLog(  
453 - "[\(type.debugName)] skipped by \(Int(NativeHealthUploadConfiguration.minimumTriggerInterval))s process throttle"  
454 - )  
455 return false 571 return false
456 } 572 }
457 lastUploadTriggerDates[type] = now 573 lastUploadTriggerDates[type] = now
@@ -460,7 +576,6 @@ private extension NativeHealthDataUploader { @@ -460,7 +576,6 @@ private extension NativeHealthDataUploader {
460 576
461 func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) { 577 func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
462 let seconds = Int64(timestamp) 578 let seconds = Int64(timestamp)
463 - DebugLogger.debugLog("[\(type.debugName)] notifying Flutter, dataType=\(type.rawValue), timestamp=\(seconds)")  
464 NotificationCenter.default.post( 579 NotificationCenter.default.post(
465 name: .nativeHealthDataDidUpload, 580 name: .nativeHealthDataDidUpload,
466 object: nil, 581 object: nil,
@@ -486,6 +601,7 @@ private extension NativeHealthDataUploader { @@ -486,6 +601,7 @@ private extension NativeHealthDataUploader {
486 ] as [String: Any] 601 ] as [String: Any]
487 } 602 }
488 try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list]) 603 try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
  604 + recordDebugUploadedSleepData(batch)
489 } 605 }
490 } 606 }
491 607
@@ -494,22 +610,35 @@ private extension NativeHealthDataUploader { @@ -494,22 +610,35 @@ private extension NativeHealthDataUploader {
494 } 610 }
495 611
496 func uploadActivityTargetHealthValues( 612 func uploadActivityTargetHealthValues(
497 - _ target: NativeActivityTarget,  
498 - timestamp: TimeInterval = Date().timeIntervalSince1970 613 + _ target: NativeActivityTarget
  614 + ) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
  615 + try await uploadActivityTargetHealthValues([target])
  616 + }
  617 +
  618 + func uploadActivityTargetHealthValues(
  619 + _ targets: [NativeActivityTarget]
499 ) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] { 620 ) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
500 - let values: [(NativeHealthDataType, Double?)] = [  
501 - (.activeEnergy, target.activeEnergyBurned),  
502 - (.exercise, target.appleExerciseTime),  
503 - (.stand, target.appleStandHours),  
504 - ]  
505 - let data = values.compactMap { item -> NativeHealthDataPoint? in  
506 - let (type, value) = item  
507 - guard let value else { return nil }  
508 - return NativeHealthDataPoint(dataType: type, time: timestamp, value: value) 621 + var data: [NativeHealthDataPoint] = []
  622 + for target in targets {
  623 + guard let timestamp = target.healthValueTimestamp else { continue }
  624 + let values: [(NativeHealthDataType, Double?)] = [
  625 + (.activeEnergy, target.activeEnergyBurned),
  626 + (.exercise, target.appleExerciseTime),
  627 + (.stand, target.appleStandHours),
  628 + ]
  629 + for (type, value) in values {
  630 + guard let value else { continue }
  631 + data.append(
  632 + NativeHealthDataPoint(
  633 + dataType: type,
  634 + time: timestamp,
  635 + value: value
  636 + )
  637 + )
  638 + }
509 } 639 }
510 guard !data.isEmpty else { return [] } 640 guard !data.isEmpty else { return [] }
511 641
512 - DebugLogger.debugLog("[activityTarget] uploading common values, count=\(data.count)")  
513 try await uploadCommon(data) 642 try await uploadCommon(data)
514 return data.map { ($0.dataType, $0.time) } 643 return data.map { ($0.dataType, $0.time) }
515 } 644 }
@@ -546,6 +675,11 @@ private extension NativeHealthDataUploader { @@ -546,6 +675,11 @@ private extension NativeHealthDataUploader {
546 throw NativeHealthUploadError.invalidResponse 675 throw NativeHealthUploadError.invalidResponse
547 } 676 }
548 guard (200..<300).contains(httpResponse.statusCode) else { 677 guard (200..<300).contains(httpResponse.statusCode) else {
  678 + if httpResponse.statusCode == 401 {
  679 + await MainActor.run {
  680 + AppShared.shared.logout()
  681 + }
  682 + }
549 throw NativeHealthUploadError.requestFailed( 683 throw NativeHealthUploadError.requestFailed(
550 path: path, 684 path: path,
551 statusCode: httpResponse.statusCode, 685 statusCode: httpResponse.statusCode,
@@ -566,17 +700,50 @@ private extension NativeHealthDataUploader { @@ -566,17 +700,50 @@ private extension NativeHealthDataUploader {
566 Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval)) 700 Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
567 } 701 }
568 702
569 - func debugLogTimeComparison(  
570 - type: NativeHealthDataType,  
571 - serverTime: TimeInterval,  
572 - newestTime: TimeInterval?  
573 - ) {  
574 - let newestDescription = newestTime.map {  
575 - "\(debugTimestamp($0)) (unix=\(Int64($0)))"  
576 - } ?? "<none>"  
577 - DebugLogger.debugLog(  
578 - "[iOS][time-check] dataType=\(type.rawValue)(\(type.debugName)), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"  
579 - ) 703 + func recordDebugUploadedCommonData(_ data: [NativeHealthDataPoint]) {
  704 + let points = data.map {
  705 + NativeHealthDebugUploadedDataPoint(
  706 + dataType: $0.dataType,
  707 + dataTypeRawValue: $0.dataType.rawValue,
  708 + dataTypeName: $0.dataType.debugName,
  709 + value: $0.value,
  710 + timestamp: $0.time
  711 + )
  712 + }
  713 + Self.debugUploadedDataStore.append(points)
  714 + }
  715 +
  716 + func recordDebugUploadedSleepData(_ data: [NativeSleepInterval]) {
  717 + let points = data.map {
  718 + NativeHealthDebugUploadedDataPoint(
  719 + dataType: .sleep,
  720 + dataTypeRawValue: NativeHealthDataType.sleep.rawValue,
  721 + dataTypeName: NativeHealthDataType.sleep.debugName,
  722 + value: Double($0.dataType),
  723 + timestamp: $0.toTime
  724 + )
  725 + }
  726 + Self.debugUploadedDataStore.append(points)
  727 + }
  728 +
  729 +}
  730 +
  731 +private final class NativeHealthDebugUploadedDataStore: @unchecked Sendable {
  732 + private let lock = NSLock()
  733 + private var points: [NativeHealthDebugUploadedDataPoint] = []
  734 +
  735 + func append(_ newPoints: [NativeHealthDebugUploadedDataPoint]) {
  736 + guard !newPoints.isEmpty else { return }
  737 + lock.lock()
  738 + points.append(contentsOf: newPoints)
  739 + lock.unlock()
  740 + }
  741 +
  742 + func snapshot() -> [NativeHealthDebugUploadedDataPoint] {
  743 + lock.lock()
  744 + let currentPoints = points
  745 + lock.unlock()
  746 + return currentPoints
580 } 747 }
581 } 748 }
582 749
@@ -602,6 +769,10 @@ struct NativeHealthUploadTimeList: Codable { @@ -602,6 +769,10 @@ struct NativeHealthUploadTimeList: Codable {
602 } 769 }
603 770
604 func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? { 771 func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
  772 + rawLatestTimeInterval(for: NativeHealthDataUploader.uploadAnchorType(for: type))
  773 + }
  774 +
  775 + func rawLatestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
605 latestDataTimeList.first { $0.dataType == type }?.latestDataTime 776 latestDataTimeList.first { $0.dataType == type }?.latestDataTime
606 } 777 }
607 778
@@ -43,17 +43,10 @@ final class WearEngineHostApiImpl: WearEngineHostApi { @@ -43,17 +43,10 @@ final class WearEngineHostApiImpl: WearEngineHostApi {
43 43
44 func syncWatchTheme(theme: WatchTransformTheme?, completion: @escaping (Result<Bool, any Error>) -> Void) { 44 func syncWatchTheme(theme: WatchTransformTheme?, completion: @escaping (Result<Bool, any Error>) -> Void) {
45 guard let theme else{ 45 guard let theme else{
46 - //TODO: - 删除主题  
47 - completion(.success(true)) 46 + watchService.deleteWatchTheme(completion)
48 return 47 return
49 } 48 }
50 - let success = WatchThemeStore.shared.saveTheme(theme)  
51 - guard success else {  
52 - completion(.success(false))  
53 - return  
54 - }  
55 - let notified = watchService.sendWatchThemeChangedMessage()  
56 - completion(.success(notified)) 49 + watchService.sendWatchThemeChangedMessage(theme: theme, completion: completion)
57 } 50 }
58 51
59 func removeBackground(originImagePath: String, completion: @escaping (Result<String?, any Error>) -> Void) { 52 func removeBackground(originImagePath: String, completion: @escaping (Result<String?, any Error>) -> Void) {
1 import Foundation 1 import Foundation
2 2
  3 +enum HealthUploadCursorMode: String {
  4 + /// Use the latest data time returned by the server.
  5 + case serverTime
  6 + /// Use a per-device, per-user HKQueryAnchor stored locally.
  7 + case localAnchor
  8 +}
  9 +
  10 +enum HealthUploadCursorConfiguration {
  11 + /// Change this single value to compare the two upload cursor strategies.
  12 + static var mode: HealthUploadCursorMode = .localAnchor
  13 +}
  14 +
3 /// Shared container used by the iPhone app, Watch app, and Widget extension. 15 /// Shared container used by the iPhone app, Watch app, and Widget extension.
4 /// Keep these keys aligned with `ios/Runner Watch App` and the watch extension. 16 /// Keep these keys aligned with `ios/Runner Watch App` and the watch extension.
5 enum AppGroupConstants { 17 enum AppGroupConstants {
@@ -74,6 +86,7 @@ struct AppGroupMessageKey{ @@ -74,6 +86,7 @@ struct AppGroupMessageKey{
74 static let reloadAll = "reloadAll" 86 static let reloadAll = "reloadAll"
75 static let reloadVip = "reloadVip" 87 static let reloadVip = "reloadVip"
76 static let reloadTheme = "reloadTheme" 88 static let reloadTheme = "reloadTheme"
  89 + static let deleteTheme = "deleteTheme"
77 90
78 // 手表通知app刷新连接状态 91 // 手表通知app刷新连接状态
79 static let statusPulseRefresh = "statusPulseRefresh" 92 static let statusPulseRefresh = "statusPulseRefresh"
@@ -112,8 +112,75 @@ final class WatchConnectivityService: NSObject { @@ -112,8 +112,75 @@ final class WatchConnectivityService: NSObject {
112 return true 112 return true
113 } 113 }
114 114
115 - func sendWatchThemeChangedMessage(json: String? = nil) -> Bool{  
116 - sendCommandMessage(AppGroupMessageKey.reloadTheme, json: json) 115 + func sendWatchThemeChangedMessage(
  116 + theme: WatchTransformTheme,
  117 + completion: @escaping (Result<Bool, Error>) -> Void
  118 + ) {
  119 + var payload: [String: Any] = [
  120 + "theme_id": Int(theme.themeId),
  121 + "theme_name": theme.themeName,
  122 + "energetic_description": theme.energeticDescription,
  123 + "normal_description": theme.normalDescription,
  124 + "slight_stressful_description": theme.slightStressfulDescription,
  125 + "stressful_description": theme.stressfulDescription,
  126 + ]
  127 + payload["energetic_image"] = theme.energeticImage?.data
  128 + payload["normal_image"] = theme.normalImage?.data
  129 + payload["slight_stressful_image"] = theme.slightStressfulImage?.data
  130 + payload["stressful_image"] = theme.stressfulImage?.data
  131 +
  132 + sendReliableCommandMessage(
  133 + AppGroupMessageKey.reloadTheme,
  134 + payload: payload,
  135 + completion: completion
  136 + )
  137 + }
  138 +
  139 + func deleteWatchTheme(_ completion: @escaping (Result<Bool, Error>) -> Void) {
  140 + sendReliableCommandMessage(
  141 + AppGroupMessageKey.deleteTheme,
  142 + completion: completion
  143 + )
  144 + }
  145 +
  146 + private func commandPayload(
  147 + _ message: String,
  148 + json: String? = nil,
  149 + payload: [String: Any]? = nil
  150 + ) -> [String: Any] {
  151 + var params: [String: Any] = [
  152 + "command": message
  153 + ]
  154 + params["json"] = json
  155 + if let payload {
  156 + params["payload"] = payload
  157 + }
  158 + return params
  159 + }
  160 +
  161 + private func sendReliableCommandMessage(
  162 + _ message: String,
  163 + json: String? = nil,
  164 + payload: [String: Any]? = nil,
  165 + completion: @escaping (Result<Bool, Error>) -> Void
  166 + ) {
  167 + guard activate() else {
  168 + completion(.success(false))
  169 + return
  170 + }
  171 +
  172 + let params = commandPayload(message, json: json, payload: payload)
  173 + WCSession.default.transferUserInfo(params)
  174 + if WCSession.default.isReachable {
  175 + WCSession.default.sendMessage(params, replyHandler: { _ in
  176 + completion(.success(true))
  177 + }) { error in
  178 + print("Watch reliable message send failed: \(error.localizedDescription)")
  179 + completion(.success(true))
  180 + }
  181 + } else {
  182 + completion(.success(true))
  183 + }
117 } 184 }
118 } 185 }
119 186
1 -import Foundation  
2 -  
3 -/// Shared Watch theme persistence.  
4 -/// The Watch app and Widget extension read this exact JSON data from App Group.  
5 -final class WatchThemeStore {  
6 - static let shared = WatchThemeStore()  
7 -  
8 - private init() {}  
9 -  
10 - @discardableResult  
11 - func saveThemeJSONString(_ json: String) -> Bool {  
12 - guard let data = json.data(using: .utf8) else { return false }  
13 - AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)  
14 - return true  
15 - }  
16 -  
17 - @discardableResult  
18 - func saveTheme(_ theme: WatchTransformTheme) -> Bool {  
19 - guard let defaults = AppGroupConstants.defaults else { return false }  
20 - let imageStore = SyncedWatchThemeImageStore(themeId: theme.themeId)  
21 - let model = SyncedWatchThemeModel(  
22 - id: Int(theme.themeId),  
23 - themeName: theme.themeName,  
24 - isOfficial: 0,  
25 - energeticTitle: theme.energeticDescription,  
26 - energeticImageURL: imageStore.save(  
27 - theme.energeticImage?.data,  
28 - status: "energetic",  
29 - defaults: defaults  
30 - ),  
31 - normalTitle: theme.normalDescription,  
32 - normalImageURL: imageStore.save(  
33 - theme.normalImage?.data,  
34 - status: "normal",  
35 - defaults: defaults  
36 - ),  
37 - slightStressTitle: theme.slightStressfulDescription,  
38 - slightStressImageURL: imageStore.save(  
39 - theme.slightStressfulImage?.data,  
40 - status: "slight_stressful",  
41 - defaults: defaults  
42 - ),  
43 - stressfulTitle: theme.stressfulDescription,  
44 - stressfulImageURL: imageStore.save(  
45 - theme.stressfulImage?.data,  
46 - status: "stressful",  
47 - defaults: defaults  
48 - )  
49 - )  
50 -  
51 - do {  
52 - let data = try JSONEncoder().encode(model)  
53 - defaults.set(data, forKey: AppGroupConstants.Key.myWatchTheme)  
54 - defaults.synchronize()  
55 - return true  
56 - } catch {  
57 - return false  
58 - }  
59 - }  
60 -  
61 - func clearTheme() {  
62 - AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)  
63 - }  
64 -}  
65 -  
66 -private struct SyncedWatchThemeImageStore {  
67 - let themeId: Int64  
68 -  
69 - func save(_ data: Data?, status: String, defaults: UserDefaults) -> String? {  
70 - guard let data else { return nil }  
71 - let key = "watch_theme_\(themeId)_\(status)"  
72 - defaults.set(data, forKey: key)  
73 - return key  
74 - }  
75 -}  
76 -  
77 -private struct SyncedWatchThemeModel: Codable {  
78 - let id: Int?  
79 - let themeName: String?  
80 - let isOfficial: Int?  
81 - let energeticTitle: String?  
82 - let energeticImageURL: String?  
83 - let normalTitle: String?  
84 - let normalImageURL: String?  
85 - let slightStressTitle: String?  
86 - let slightStressImageURL: String?  
87 - let stressfulTitle: String?  
88 - let stressfulImageURL: String?  
89 -  
90 - enum CodingKeys: String, CodingKey {  
91 - case id  
92 - case themeName = "theme_name"  
93 - case isOfficial = "is_official"  
94 - case energeticTitle = "energetic_description"  
95 - case energeticImageURL = "energetic_image"  
96 - case normalTitle = "normal_description"  
97 - case normalImageURL = "normal_image"  
98 - case slightStressTitle = "slight_stressful_description"  
99 - case slightStressImageURL = "slight_stressful_image"  
100 - case stressfulTitle = "stressful_description"  
101 - case stressfulImageURL = "stressful_image"  
102 - }  
103 -}  
@@ -9,13 +9,29 @@ struct DFCoupleMiniCard: View { @@ -9,13 +9,29 @@ struct DFCoupleMiniCard: View {
9 let name: String 9 let name: String
10 let status: DFStressStatus 10 let status: DFStressStatus
11 let value: String 11 let value: String
  12 + let themeImage: Image?
  13 + let title: String?
  14 +
  15 + init(
  16 + name: String,
  17 + status: DFStressStatus,
  18 + value: String,
  19 + themeImage: Image? = nil,
  20 + title: String? = nil
  21 + ) {
  22 + self.name = name
  23 + self.status = status
  24 + self.value = value
  25 + self.themeImage = themeImage
  26 + self.title = title
  27 + }
12 28
13 var body: some View { 29 var body: some View {
14 HStack(spacing: 5) { 30 HStack(spacing: 5) {
15 - DFStatusOrb(status: status, value: "") 31 + DFStatusOrb(status: status, value: "", themeImage: themeImage)
16 .frame(width: 30, height: 30) 32 .frame(width: 30, height: 30)
17 VStack(alignment: .leading, spacing: 1) { 33 VStack(alignment: .leading, spacing: 1) {
18 - Text(status.title) 34 + Text(title ?? status.title)
19 .font(.system(size: 10, weight: .semibold)) 35 .font(.system(size: 10, weight: .semibold))
20 .foregroundStyle(status.color) 36 .foregroundStyle(status.color)
21 .lineLimit(1) 37 .lineLimit(1)
@@ -11,6 +11,8 @@ struct DFCoupleStressFaceView: View { @@ -11,6 +11,8 @@ struct DFCoupleStressFaceView: View {
11 var body: some View { 11 var body: some View {
12 GeometryReader { proxy in 12 GeometryReader { proxy in
13 let scale = min(proxy.size.width / 172, proxy.size.height / 74) 13 let scale = min(proxy.size.width / 172, proxy.size.height / 74)
  14 + let partnerContent = entry.theme?.faceContent(for: entry.data.partnerStatus)
  15 + let myContent = entry.theme?.faceContent(for: entry.data.myStatus)
14 VStack(spacing: 5 * scale) { 16 VStack(spacing: 5 * scale) {
15 HStack { 17 HStack {
16 Text(entry.data.hasData ? "我们的状态 · HRV" : "我们的状态 · 实时") 18 Text(entry.data.hasData ? "我们的状态 · HRV" : "我们的状态 · 实时")
@@ -25,12 +27,16 @@ struct DFCoupleStressFaceView: View { @@ -25,12 +27,16 @@ struct DFCoupleStressFaceView: View {
25 DFCoupleMiniCard( 27 DFCoupleMiniCard(
26 name: entry.data.partnerName, 28 name: entry.data.partnerName,
27 status: entry.data.partnerStatus, 29 status: entry.data.partnerStatus,
28 - value: entry.data.hasData ? "\(entry.data.partnerHRV)ms" : "--" 30 + value: entry.data.hasData ? "\(entry.data.partnerHRV)ms" : "--",
  31 + themeImage: partnerContent?.image,
  32 + title: partnerContent?.title
29 ) 33 )
30 DFCoupleMiniCard( 34 DFCoupleMiniCard(
31 name: entry.data.myName, 35 name: entry.data.myName,
32 status: entry.data.myStatus, 36 status: entry.data.myStatus,
33 - value: entry.data.hasData ? "\(entry.data.myHRV)ms" : "--" 37 + value: entry.data.hasData ? "\(entry.data.myHRV)ms" : "--",
  38 + themeImage: myContent?.image,
  39 + title: myContent?.title
34 ) 40 )
35 } 41 }
36 } 42 }
@@ -11,6 +11,7 @@ struct DFDefaultHomeFaceView: View { @@ -11,6 +11,7 @@ struct DFDefaultHomeFaceView: View {
11 var body: some View { 11 var body: some View {
12 GeometryReader { proxy in 12 GeometryReader { proxy in
13 let scale = min(proxy.size.width / 172, proxy.size.height / 74) 13 let scale = min(proxy.size.width / 172, proxy.size.height / 74)
  14 + let content = entry.theme?.faceContent(for: entry.data.myStatus)
14 HStack(spacing: 8 * scale) { 15 HStack(spacing: 8 * scale) {
15 VStack(alignment: .leading, spacing: 2 * scale) { 16 VStack(alignment: .leading, spacing: 2 * scale) {
16 HStack(spacing: 4 * scale) { 17 HStack(spacing: 4 * scale) {
@@ -31,13 +32,20 @@ struct DFDefaultHomeFaceView: View { @@ -31,13 +32,20 @@ struct DFDefaultHomeFaceView: View {
31 .frame(height: 7 * scale) 32 .frame(height: 7 * scale)
32 } 33 }
33 34
  35 + if let image = content?.image {
  36 + image
  37 + .resizable()
  38 + .scaledToFit()
  39 + .frame(width: 30 * scale, height: 30 * scale)
  40 + }
  41 +
34 VStack(alignment: .trailing, spacing: 5 * scale) { 42 VStack(alignment: .trailing, spacing: 5 * scale) {
35 HStack(spacing: 5 * scale) { 43 HStack(spacing: 5 * scale) {
36 DFMiniMetric(value: entry.data.hasData ? "\(entry.data.heartRate)" : "0", icon: "heart.fill", tint: DFWatchFaceColor.red) 44 DFMiniMetric(value: entry.data.hasData ? "\(entry.data.heartRate)" : "0", icon: "heart.fill", tint: DFWatchFaceColor.red)
37 DFMiniMetric(value: "\(entry.data.closePercent)", icon: "sparkles", tint: DFWatchFaceColor.purple) 45 DFMiniMetric(value: "\(entry.data.closePercent)", icon: "sparkles", tint: DFWatchFaceColor.purple)
38 DFMiniMetric(value: entry.data.hasData ? shortSteps(entry.data.steps) : "0", icon: "shoeprints.fill", tint: DFWatchFaceColor.green) 46 DFMiniMetric(value: entry.data.hasData ? shortSteps(entry.data.steps) : "0", icon: "shoeprints.fill", tint: DFWatchFaceColor.green)
39 } 47 }
40 - Text(entry.data.hasData ? "\(entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "暂无数据 · --ms") 48 + Text(entry.data.hasData ? "\(content?.title ?? entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "暂无数据 · --ms")
41 .font(.system(size: 10 * scale, weight: .semibold)) 49 .font(.system(size: 10 * scale, weight: .semibold))
42 .foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText) 50 .foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText)
43 .lineLimit(1) 51 .lineLimit(1)
@@ -8,11 +8,25 @@ import SwiftUI @@ -8,11 +8,25 @@ import SwiftUI
8 struct DFStatusOrb: View { 8 struct DFStatusOrb: View {
9 let status: DFStressStatus 9 let status: DFStressStatus
10 let value: String 10 let value: String
  11 + let themeImage: Image?
  12 +
  13 + init(status: DFStressStatus, value: String, themeImage: Image? = nil) {
  14 + self.status = status
  15 + self.value = value
  16 + self.themeImage = themeImage
  17 + }
11 18
12 var body: some View { 19 var body: some View {
13 ZStack { 20 ZStack {
14 Circle() 21 Circle()
15 .fill(status.color.opacity(0.16)) 22 .fill(status.color.opacity(0.16))
  23 + if let themeImage {
  24 + themeImage
  25 + .resizable()
  26 + .scaledToFit()
  27 + .clipShape(Circle())
  28 + .padding(4)
  29 + }
16 Circle() 30 Circle()
17 .stroke(status.color.opacity(0.34), lineWidth: 5) 31 .stroke(status.color.opacity(0.34), lineWidth: 5)
18 Circle() 32 Circle()
@@ -20,10 +34,12 @@ struct DFStatusOrb: View { @@ -20,10 +34,12 @@ struct DFStatusOrb: View {
20 .stroke(status.color, style: StrokeStyle(lineWidth: 5, lineCap: .round)) 34 .stroke(status.color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
21 .rotationEffect(.degrees(110)) 35 .rotationEffect(.degrees(110))
22 if value.isEmpty { 36 if value.isEmpty {
23 - Image(systemName: "heart.text.square.fill")  
24 - .font(.system(size: 12, weight: .semibold))  
25 - .foregroundStyle(status.color)  
26 - } else { 37 + if themeImage == nil {
  38 + Image(systemName: "heart.text.square.fill")
  39 + .font(.system(size: 12, weight: .semibold))
  40 + .foregroundStyle(status.color)
  41 + }
  42 + } else if themeImage == nil {
27 VStack(spacing: -1) { 43 VStack(spacing: -1) {
28 Text(value) 44 Text(value)
29 .font(.system(size: 14, weight: .bold, design: .rounded)) 45 .font(.system(size: 14, weight: .bold, design: .rounded))
@@ -32,6 +48,13 @@ struct DFStatusOrb: View { @@ -32,6 +48,13 @@ struct DFStatusOrb: View {
32 .font(.system(size: 7, weight: .medium)) 48 .font(.system(size: 7, weight: .medium))
33 .foregroundStyle(.white.opacity(0.74)) 49 .foregroundStyle(.white.opacity(0.74))
34 } 50 }
  51 + } else {
  52 + Text(value)
  53 + .font(.system(size: 13, weight: .bold, design: .rounded))
  54 + .foregroundStyle(.white)
  55 + .padding(.horizontal, 4)
  56 + .padding(.vertical, 2)
  57 + .background(Color.black.opacity(0.45), in: Capsule())
35 } 58 }
36 } 59 }
37 } 60 }
@@ -9,4 +9,5 @@ struct DFWatchFaceEntry: TimelineEntry { @@ -9,4 +9,5 @@ struct DFWatchFaceEntry: TimelineEntry {
9 let date: Date 9 let date: Date
10 let face: DFWatchFaceKind 10 let face: DFWatchFaceKind
11 let data: DFWatchFaceData 11 let data: DFWatchFaceData
  12 + let theme: WatchLocalTheme?
12 } 13 }
@@ -9,17 +9,17 @@ import SwiftUI @@ -9,17 +9,17 @@ import SwiftUI
9 #Preview("默认表盘", as: .accessoryRectangular) { 9 #Preview("默认表盘", as: .accessoryRectangular) {
10 DFDefaultHomeWidget() 10 DFDefaultHomeWidget()
11 } timeline: { 11 } timeline: {
12 - DFWatchFaceEntry(date: .now, face: .defaultHome, data: .placeholder) 12 + DFWatchFaceEntry(date: .now, face: .defaultHome, data: .placeholder, theme: nil)
13 } 13 }
14 14
15 #Preview("单人压力表盘", as: .accessoryRectangular) { 15 #Preview("单人压力表盘", as: .accessoryRectangular) {
16 DFSingleStressWidget() 16 DFSingleStressWidget()
17 } timeline: { 17 } timeline: {
18 - DFWatchFaceEntry(date: .now, face: .singleStress, data: .placeholder) 18 + DFWatchFaceEntry(date: .now, face: .singleStress, data: .placeholder, theme: nil)
19 } 19 }
20 20
21 #Preview("双人压力表盘", as: .accessoryRectangular) { 21 #Preview("双人压力表盘", as: .accessoryRectangular) {
22 DFCoupleStressWidget() 22 DFCoupleStressWidget()
23 } timeline: { 23 } timeline: {
24 - DFWatchFaceEntry(date: .now, face: .coupleStress, data: .placeholder) 24 + DFWatchFaceEntry(date: .now, face: .coupleStress, data: .placeholder, theme: nil)
25 } 25 }
@@ -9,17 +9,18 @@ struct DFWatchFaceProvider: TimelineProvider { @@ -9,17 +9,18 @@ struct DFWatchFaceProvider: TimelineProvider {
9 let face: DFWatchFaceKind 9 let face: DFWatchFaceKind
10 10
11 func placeholder(in context: Context) -> DFWatchFaceEntry { 11 func placeholder(in context: Context) -> DFWatchFaceEntry {
12 - DFWatchFaceEntry(date: .now, face: face, data: .placeholder) 12 + DFWatchFaceEntry(date: .now, face: face, data: .placeholder, theme: nil)
13 } 13 }
14 14
15 func getSnapshot(in context: Context, completion: @escaping (DFWatchFaceEntry) -> Void) { 15 func getSnapshot(in context: Context, completion: @escaping (DFWatchFaceEntry) -> Void) {
16 - completion(DFWatchFaceEntry(date: .now, face: face, data: .placeholder)) 16 + completion(DFWatchFaceEntry(date: .now, face: face, data: .placeholder, theme: nil))
17 } 17 }
18 18
19 func getTimeline(in context: Context, completion: @escaping (Timeline<DFWatchFaceEntry>) -> Void) { 19 func getTimeline(in context: Context, completion: @escaping (Timeline<DFWatchFaceEntry>) -> Void) {
20 Task { 20 Task {
21 let data = await DFWatchFaceMockService.fetch(face: face) 21 let data = await DFWatchFaceMockService.fetch(face: face)
22 - let entry = DFWatchFaceEntry(date: .now, face: face, data: data) 22 + let theme = await DFWatchThemeStore.currentTheme()
  23 + let entry = DFWatchFaceEntry(date: .now, face: face, data: data, theme: theme)
23 let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900) 24 let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900)
24 completion(Timeline(entries: [entry], policy: .after(nextRefresh))) 25 completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
25 } 26 }
  1 +//
  2 +// DFWatchThemeStore.swift
  3 +// hippo-watch-extension
  4 +//
  5 +
  6 +import Foundation
  7 +import SwiftUI
  8 +
  9 +enum DFWatchThemeStore {
  10 + static func currentTheme() async -> WatchLocalTheme? {
  11 + if let localTheme = storedTheme() {
  12 + if localTheme.hasAllImages {
  13 + return localTheme
  14 + }
  15 + guard let remoteTheme = await remoteTheme() else {
  16 + return localTheme
  17 + }
  18 + let refreshedTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: remoteTheme)
  19 + save(refreshedTheme)
  20 + return refreshedTheme
  21 + }
  22 + if let remoteTheme = await remoteTheme() {
  23 + let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: remoteTheme)
  24 + save(localTheme)
  25 + return localTheme
  26 + }
  27 + guard let legacyTheme = legacyRemoteTheme() else {
  28 + return nil
  29 + }
  30 + let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
  31 + save(localTheme)
  32 + return localTheme
  33 + }
  34 +
  35 + private static func storedTheme() -> WatchLocalTheme? {
  36 + guard let data = AppGroupConstants.defaults?.data(
  37 + forKey: AppGroupConstants.Key.myWatchTheme
  38 + ) else {
  39 + return nil
  40 + }
  41 + if let theme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
  42 + return theme
  43 + }
  44 + return nil
  45 + }
  46 +
  47 + private static func legacyRemoteTheme() -> WatchThemeModel? {
  48 + if let data = AppGroupConstants.defaults?.data(
  49 + forKey: AppGroupConstants.Key.myWatchTheme
  50 + ),
  51 + let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  52 + return theme
  53 + }
  54 + if let jsonString = AppGroupConstants.defaults?.string(
  55 + forKey: AppGroupConstants.Key.myWatchTheme
  56 + ),
  57 + let data = jsonString.data(using: .utf8),
  58 + let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
  59 + return theme
  60 + }
  61 + return nil
  62 + }
  63 +
  64 + private static func save(_ theme: WatchLocalTheme) {
  65 + guard let data = try? JSONEncoder().encode(theme) else { return }
  66 + AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
  67 + }
  68 +
  69 + private static func remoteTheme() async -> WatchThemeModel? {
  70 + guard let login = loginInfo(),
  71 + let token = login.token,
  72 + !token.isEmpty else {
  73 + return nil
  74 + }
  75 +
  76 + let baseUrl = login.baseUrl ?? "https://api.doublefeel.cn"
  77 + guard let url = URL(
  78 + string: baseUrl + "/client/doublefeel/theme/v2/watch_theme/active/"
  79 + ) else {
  80 + return nil
  81 + }
  82 +
  83 + var request = URLRequest(url: url)
  84 + request.httpMethod = "GET"
  85 + request.setValue(token, forHTTPHeaderField: "access_token")
  86 + request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  87 +
  88 + do {
  89 + let (data, response) = try await URLSession.shared.data(for: request)
  90 + guard let httpResponse = response as? HTTPURLResponse,
  91 + (200..<300).contains(httpResponse.statusCode) else {
  92 + return nil
  93 + }
  94 + return try JSONDecoder().decode(WatchThemeModel.self, from: data)
  95 + } catch {
  96 + return nil
  97 + }
  98 + }
  99 +
  100 + private static func loginInfo() -> UserInfoModel? {
  101 + guard let data = AppGroupConstants.defaults?.data(
  102 + forKey: AppGroupConstants.Key.myUserInfo
  103 + ) else {
  104 + return nil
  105 + }
  106 + return try? JSONDecoder().decode(UserInfoModel.self, from: data)
  107 + }
  108 +
  109 +}
  110 +
  111 +extension WatchLocalTheme {
  112 + func faceContent(for status: DFStressStatus) -> (title: String, image: Image?) {
  113 + let title: String
  114 + let data: Data?
  115 + switch status {
  116 + case .excellent:
  117 + title = energeticDescription
  118 + data = energeticImage
  119 + case .normal:
  120 + title = normalDescription
  121 + data = normalImage
  122 + case .little:
  123 + title = slightStressfulDescription
  124 + data = slightStressfulImage
  125 + case .overloaded:
  126 + title = stressfulDescription
  127 + data = stressfulImage
  128 + }
  129 +
  130 + guard let data, let uiImage = UIImage(data: data) else {
  131 + return (title, nil)
  132 + }
  133 + return (title, Image(uiImage: uiImage))
  134 + }
  135 +}
@@ -11,8 +11,13 @@ struct DFSingleStressFaceView: View { @@ -11,8 +11,13 @@ struct DFSingleStressFaceView: View {
11 var body: some View { 11 var body: some View {
12 GeometryReader { proxy in 12 GeometryReader { proxy in
13 let scale = min(proxy.size.width / 172, proxy.size.height / 74) 13 let scale = min(proxy.size.width / 172, proxy.size.height / 74)
  14 + let content = entry.theme?.faceContent(for: entry.data.myStatus)
14 HStack(spacing: 10 * scale) { 15 HStack(spacing: 10 * scale) {
15 - DFStatusOrb(status: entry.data.myStatus, value: entry.data.hasData ? "\(entry.data.hrvValue)" : "--") 16 + DFStatusOrb(
  17 + status: entry.data.myStatus,
  18 + value: entry.data.hasData ? "\(entry.data.hrvValue)" : "--",
  19 + themeImage: content?.image
  20 + )
16 .frame(width: 54 * scale, height: 54 * scale) 21 .frame(width: 54 * scale, height: 54 * scale)
17 22
18 VStack(alignment: .leading, spacing: 5 * scale) { 23 VStack(alignment: .leading, spacing: 5 * scale) {
@@ -25,7 +30,7 @@ struct DFSingleStressFaceView: View { @@ -25,7 +30,7 @@ struct DFSingleStressFaceView: View {
25 .font(.system(size: 10 * scale, weight: .medium)) 30 .font(.system(size: 10 * scale, weight: .medium))
26 .foregroundStyle(.white) 31 .foregroundStyle(.white)
27 } 32 }
28 - Text(entry.data.hasData ? "\(entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "等待数据") 33 + Text(entry.data.hasData ? "\(content?.title ?? entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "等待数据")
29 .font(.system(size: 14 * scale, weight: .semibold)) 34 .font(.system(size: 14 * scale, weight: .semibold))
30 .foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText) 35 .foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText)
31 .lineLimit(1) 36 .lineLimit(1)
@@ -12,7 +12,6 @@ import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart'; @@ -12,7 +12,6 @@ import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart';
12 import 'package:flutter/material.dart'; 12 import 'package:flutter/material.dart';
13 import 'package:get/get.dart'; 13 import 'package:get/get.dart';
14 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 14 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
15 -import 'package:logger/logger.dart';  
16 15
17 import '../../friends/models/friend_health_data.dart'; 16 import '../../friends/models/friend_health_data.dart';
18 import '../../friends/views/select_friend_view.dart'; 17 import '../../friends/views/select_friend_view.dart';
@@ -164,7 +163,9 @@ class CustomWatchThemePreviewController extends GetxController { @@ -164,7 +163,9 @@ class CustomWatchThemePreviewController extends GetxController {
164 } 163 }
165 try { 164 try {
166 await WearEngineHostApi().addWatchSurface(); 165 await WearEngineHostApi().addWatchSurface();
167 - } catch (e) {} 166 + } catch (_) {
  167 + // Opening the Watch app still helps the user install the watch face.
  168 + }
168 await PlatformHostApi().nativeHandleUrl("itms-watchs://"); 169 await PlatformHostApi().nativeHandleUrl("itms-watchs://");
169 } 170 }
170 171
@@ -217,7 +218,8 @@ class CustomWatchThemePreviewController extends GetxController { @@ -217,7 +218,8 @@ class CustomWatchThemePreviewController extends GetxController {
217 } 218 }
218 syncSuccess = success; 219 syncSuccess = success;
219 selectedThemeItem.value = themeItem; 220 selectedThemeItem.value = themeItem;
220 - return false; 221 + isApplying.value = false;
  222 + return true;
221 } 223 }
222 syncSuccess = success; 224 syncSuccess = success;
223 } catch (_) { 225 } catch (_) {
@@ -135,7 +135,7 @@ class WatchThemePreviewController extends GetxController { @@ -135,7 +135,7 @@ class WatchThemePreviewController extends GetxController {
135 try { 135 try {
136 await WearEngineHostApi().addWatchSurface(); 136 await WearEngineHostApi().addWatchSurface();
137 } catch (e) { 137 } catch (e) {
138 - 138 + // Opening the Watch app still helps the user install the watch face.
139 } 139 }
140 await PlatformHostApi().nativeHandleUrl("itms-watchs://"); 140 await PlatformHostApi().nativeHandleUrl("itms-watchs://");
141 } 141 }
@@ -189,7 +189,8 @@ class WatchThemePreviewController extends GetxController { @@ -189,7 +189,8 @@ class WatchThemePreviewController extends GetxController {
189 } 189 }
190 syncSuccess = success; 190 syncSuccess = success;
191 selectedThemeItem.value = themeItem; 191 selectedThemeItem.value = themeItem;
192 - return false; 192 + isApplying.value = false;
  193 + return true;
193 } 194 }
194 syncSuccess = success; 195 syncSuccess = success;
195 } catch (_) { 196 } catch (_) {