Showing
25 changed files
with
985 additions
and
522 deletions
| @@ -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 (_) { |
-
Please register or login to post a comment