Commit 0520b64f38af86c701eb1ef622238f027d576bef

Authored by 权海
1 parent ef503062

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

... ... @@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import HealthKit
extension WatchThemeModel{
extension WatchLocalTheme{
func description(for state: HRVStressState) -> (title: String?, image: Image?){
switch state {
... ... @@ -10,36 +10,32 @@ extension WatchThemeModel{
return (state.name, state.defaultThemeImage)
case .stressful:
var image: Image?
if let imageUrl = stressfulImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
if let stressfulImage,
let uiImage = UIImage(data: stressfulImage){
image = Image(uiImage: uiImage)
}
return (stressfulTitle, image)
return (stressfulDescription, image)
case .slightStress:
var image: Image?
if let imageUrl = slightStressImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
if let slightStressfulImage,
let uiImage = UIImage(data: slightStressfulImage){
image = Image(uiImage: uiImage)
}
return (slightStressTitle, image)
return (slightStressfulDescription, image)
case .normal:
var image: Image?
if let imageUrl = normalImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
if let normalImage,
let uiImage = UIImage(data: normalImage){
image = Image(uiImage: uiImage)
}
return (normalTitle, image)
return (normalDescription, image)
case .energetic:
var image: Image?
if let imageUrl = energeticImageURL,
let imageData = AppGroupConstants.defaults?.data(forKey: imageUrl),
let uiImage = UIImage(data: imageData){
if let energeticImage,
let uiImage = UIImage(data: energeticImage){
image = Image(uiImage: uiImage)
}
return (energeticTitle, image)
return (energeticDescription, image)
}
}
}
... ... @@ -247,4 +243,3 @@ private struct DynamicCodingKey: CodingKey {
self.intValue = intValue
}
}
... ...
... ... @@ -5,6 +5,183 @@
// Created by 权海 on 2026/6/26.
//
import Foundation
enum WatchThemeLocalBuilder {
static func makeLocalTheme(from model: WatchThemeModel) async -> WatchLocalTheme {
async let energeticImage = WatchThemeImageCache.data(for: model.energeticImageURL)
async let normalImage = WatchThemeImageCache.data(for: model.normalImageURL)
async let slightStressfulImage = WatchThemeImageCache.data(for: model.slightStressImageURL)
async let stressfulImage = WatchThemeImageCache.data(for: model.stressfulImageURL)
return await WatchLocalTheme(
themeId: model.id ?? 0,
themeName: model.themeName ?? "",
energeticDescription: model.energeticTitle ?? "",
energeticImage: energeticImage,
normalDescription: model.normalTitle ?? "",
normalImage: normalImage,
slightStressfulDescription: model.slightStressTitle ?? "",
slightStressfulImage: slightStressfulImage,
stressfulDescription: model.stressfulTitle ?? "",
stressfulImage: stressfulImage
)
}
}
private enum WatchThemeImageCache {
static func data(for urlString: String?) async -> Data? {
guard let urlString, !urlString.isEmpty else { return nil }
if let cachedData = cachedData(for: urlString) {
return cachedData
}
if let defaultsData = AppGroupConstants.defaults?.data(forKey: urlString) {
save(defaultsData, for: urlString)
return defaultsData
}
guard let url = URL(string: urlString) else { return nil }
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
return nil
}
save(data, for: urlString)
return data
} catch {
return nil
}
}
private static func cachedData(for urlString: String) -> Data? {
let url = cacheURL(for: urlString)
return try? Data(contentsOf: url)
}
private static func save(_ data: Data, for urlString: String) {
let url = cacheURL(for: urlString)
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: url, options: [.atomic])
} catch {
}
}
private static func cacheURL(for urlString: String) -> URL {
let baseURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroupConstants.identifier
) ?? FileManager.default.temporaryDirectory
return baseURL
.appendingPathComponent("watch_theme_image_cache", isDirectory: true)
.appendingPathComponent(stableHash(urlString))
}
private static func stableHash(_ value: String) -> String {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in value.utf8 {
hash ^= UInt64(byte)
hash &*= 1_099_511_628_211
}
return String(format: "%016llx", hash)
}
}
struct WatchLocalTheme: Codable, Equatable {
var themeId: Int
var themeName: String
var energeticDescription: String
var energeticImage: Data?
var normalDescription: String
var normalImage: Data?
var slightStressfulDescription: String
var slightStressfulImage: Data?
var stressfulDescription: String
var stressfulImage: Data?
init(
themeId: Int,
themeName: String,
energeticDescription: String,
energeticImage: Data?,
normalDescription: String,
normalImage: Data?,
slightStressfulDescription: String,
slightStressfulImage: Data?,
stressfulDescription: String,
stressfulImage: Data?
) {
self.themeId = themeId
self.themeName = themeName
self.energeticDescription = energeticDescription
self.energeticImage = energeticImage
self.normalDescription = normalDescription
self.normalImage = normalImage
self.slightStressfulDescription = slightStressfulDescription
self.slightStressfulImage = slightStressfulImage
self.stressfulDescription = stressfulDescription
self.stressfulImage = stressfulImage
}
init(model: WatchThemeModel) {
self.init(
themeId: model.id ?? 0,
themeName: model.themeName ?? "",
energeticDescription: model.energeticTitle ?? "",
energeticImage: nil,
normalDescription: model.normalTitle ?? "",
normalImage: nil,
slightStressfulDescription: model.slightStressTitle ?? "",
slightStressfulImage: nil,
stressfulDescription: model.stressfulTitle ?? "",
stressfulImage: nil
)
}
init?(payload: [String: Any]) {
let rawThemeId = payload["theme_id"]
let themeId: Int
if let value = rawThemeId as? Int {
themeId = value
} else if let value = rawThemeId as? Int64 {
themeId = Int(value)
} else if let value = rawThemeId as? NSNumber {
themeId = value.intValue
} else {
return nil
}
self.init(
themeId: themeId,
themeName: payload["theme_name"] as? String ?? "",
energeticDescription: payload["energetic_description"] as? String ?? "",
energeticImage: Self.imageData(from: payload["energetic_image"]),
normalDescription: payload["normal_description"] as? String ?? "",
normalImage: Self.imageData(from: payload["normal_image"]),
slightStressfulDescription: payload["slight_stressful_description"] as? String ?? "",
slightStressfulImage: Self.imageData(from: payload["slight_stressful_image"]),
stressfulDescription: payload["stressful_description"] as? String ?? "",
stressfulImage: Self.imageData(from: payload["stressful_image"])
)
}
private static func imageData(from value: Any?) -> Data? {
if let data = value as? Data { return data }
if let data = value as? NSData { return data as Data }
return nil
}
var hasAllImages: Bool {
energeticImage != nil &&
normalImage != nil &&
slightStressfulImage != nil &&
stressfulImage != nil
}
}
struct WatchThemeModel: Codable, Equatable {
var id: Int?
var userId: Int?
... ... @@ -45,4 +222,3 @@ struct WatchThemeModel: Codable, Equatable {
case stressfulImageURL = "stressful_image"
}
}
... ...
... ... @@ -14,8 +14,8 @@ import Combine
class StatusComparisonViewModel: ObservableObject {
@Published var currentInteractionAction: InteractionActionType?
@Published var myWatchTheme: WatchThemeModel?
@Published var otherWatchTheme: WatchThemeModel?
@Published var myWatchTheme: WatchLocalTheme?
@Published var otherWatchTheme: WatchLocalTheme?
init() {
refreshTheme()
... ... @@ -48,33 +48,25 @@ class StatusComparisonViewModel: ObservableObject {
}
private func getMyWatchTheme() {
// 1️⃣ 先从 UserDefaults 读取缓存
let defaults = AppGroupConstants.defaults
if let jsonString = defaults?.string(forKey: AppGroupConstants.Key.myWatchTheme),
let jsonData = jsonString.data(using: .utf8),
let cachedModel = try? JSONDecoder().decode(WatchThemeModel.self, from: jsonData) {
// 先展示缓存数据
myWatchTheme = cachedModel
if let data = defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),
let cachedTheme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
myWatchTheme = cachedTheme
}
// 2️⃣ 再异步调用接口刷新
Task {
if let result = try? await ThemeService().getCurrentTheme(isOther: false) {
let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: result)
await MainActor.run {
self.myWatchTheme = result
self.myWatchTheme = localTheme
}
await saveImageToSharedContainer()
await MainActor.run {
reloadAllComplications()
}
// 3️⃣ 将最新数据写入 UserDefaults
do {
let data = try JSONEncoder().encode(result)
if let jsonString = String(data: data, encoding: .utf8) {
print(jsonString)
defaults?.set(jsonString, forKey: "myWatchTheme")
}
let data = try JSONEncoder().encode(localTheme)
defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
}catch {
print(error.getToastErrorDescription())
}
... ... @@ -86,29 +78,22 @@ class StatusComparisonViewModel: ObservableObject {
private func getOtherWatchTheme() {
guard WatchUserinfoManager.share.myUserinfo?.isPaired == true else { return }
// 1️⃣ 先从 UserDefaults 读取缓存
let defaults = AppGroupConstants.defaults
if let jsonString = defaults?.string(forKey: AppGroupConstants.Key.otherWatchTheme),
let jsonData = jsonString.data(using: .utf8),
let cachedModel = try? JSONDecoder().decode(WatchThemeModel.self, from: jsonData) {
// 先展示缓存数据
otherWatchTheme = cachedModel
if let data = defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),
let cachedTheme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
otherWatchTheme = cachedTheme
}
// 2️⃣ 再异步调用接口刷新
Task {
if let result = try? await ThemeService().getCurrentTheme(isOther: true) {
let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: result)
await MainActor.run {
self.otherWatchTheme = result
self.otherWatchTheme = localTheme
}
// 3️⃣ 将最新数据写入 UserDefaults
do {
let data = try JSONEncoder().encode(result)
if let jsonString = String(data: data, encoding: .utf8) {
print(jsonString)
defaults?.set(jsonString, forKey: "otherWatchTheme")
}
let data = try JSONEncoder().encode(localTheme)
defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)
}catch {
print(error.getToastErrorDescription())
}
... ... @@ -127,79 +112,4 @@ class StatusComparisonViewModel: ObservableObject {
#endif
}
private func saveImageToSharedContainer() async {
guard let myWatchTheme, myWatchTheme.isDefaultTheme != true else { return }
// 将下载逻辑封装成 async 方法
func downloadAndSave(_ url: URL?) async {
// await withCheckedContinuation { continuation in
// SDWebImageDownloader.shared.downloadImage(with: url) { image, data, error, _ in
// if let data {
// self.saveImageToAppGroup(data, fileName: url?.lastPathComponent)
// }
// continuation.resume()
// }
// }
}
await withTaskGroup(of: Void.self) { group in
if let positiveImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .fullOfEnergy) {
group.addTask {
await downloadAndSave(positiveImage.url)
}
}
if let normalImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .normal) {
group.addTask {
await downloadAndSave(normalImage.url)
}
}
if let negativeImage = myWatchTheme.hrvStatusThumnailImageURL(hrvStatus: .overpressure) {
group.addTask {
await downloadAndSave(negativeImage.url)
}
}
}
print("✅ 所有图片已下载完成并写入共享容器")
}
private func saveImageToAppGroup(_ data: Data, fileName: String?) {
guard let fileName else {return}
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroupConstants.identifier
) else {
print("❌ App Group 容器不存在,请检查配置")
return
}
// 清理文件名,移除可能的路径分隔符
let cleanFileName = fileName.replacingOccurrences(of: "/", with: "_")
let fileURL = containerURL.appendingPathComponent(cleanFileName)
do {
// 1. 确保容器目录存在
if !FileManager.default.fileExists(atPath: containerURL.path) {
try FileManager.default.createDirectory(
at: containerURL,
withIntermediateDirectories: true,
attributes: nil
)
}
// 2. 如果文件已存在,先删除
if FileManager.default.fileExists(atPath: fileURL.path) {
// try FileManager.default.removeItem(at: fileURL)
return
}
// 3. 写入文件
try data.write(to: fileURL, options: [.atomic])
print("✅ 图片保存成功: \(fileURL.lastPathComponent)")
} catch {
print("❌ 写入报错: \(error.localizedDescription)")
}
}
}
... ...
... ... @@ -104,7 +104,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
}
}
private func handleCommand(command: String, json: String?){
private func handleCommand(command: String, json: String?, payload: [String: Any]? = nil){
if command == AppGroupMessageKey.login{
// 登录, 刷新所有数据
guard let data = json?.data(using: .utf8) else{
... ... @@ -115,7 +115,15 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
}else if command == AppGroupMessageKey.reloadTheme {
// 刷新表盘等
Task { @MainActor in
WatchUserinfoManager.share.reloadTheme()
if let payload, let theme = WatchLocalTheme(payload: payload) {
WatchUserinfoManager.share.applySyncedTheme(theme)
} else {
WatchUserinfoManager.share.reloadTheme()
}
}
}else if command == AppGroupMessageKey.deleteTheme{
Task { @MainActor in
WatchUserinfoManager.share.deleteLocalTheme()
}
}else if command == AppGroupMessageKey.reloadAll{
// 刷新数据
... ... @@ -142,24 +150,49 @@ class WatchSessionManager: NSObject, WCSessionDelegate, ObservableObject {
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
print("message-replyHandler: \(message)")
if let command = message["command"] as? String {
handleCommand(command: command, json: message["json"] as? String)
handleCommand(
command: command,
json: message["json"] as? String,
payload: message["payload"] as? [String: Any]
)
}
replyHandler(["success": true])
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
print("received userInfo transform: \(userInfo)")
if let command = userInfo["command"] as? String {
handleCommand(
command: command,
json: userInfo["json"] as? String,
payload: userInfo["payload"] as? [String: Any]
)
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
print("message: \(message)")
if let command = message["command"] as? String {
handleCommand(command: command, json: message["json"] as? String)
handleCommand(
command: command,
json: message["json"] as? String,
payload: message["payload"] as? [String: Any]
)
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
print("received applicationContext: \(applicationContext)")
if let command = applicationContext["command"] as? String {
if command != AppGroupMessageKey.login || applicationContext["json"] != nil {
handleCommand(
command: command,
json: applicationContext["json"] as? String,
payload: applicationContext["payload"] as? [String: Any]
)
return
}
}
do{
let data = try JSONSerialization.data(withJSONObject: applicationContext)
let model = try JSONDecoder().decode(UserInfoModel.self, from: data)
... ...
... ... @@ -9,23 +9,6 @@ import Foundation
import WidgetKit
import Combine
extension WatchThemeModel{
func cleanCache() {
if let energeticImageURL{
AppGroupConstants.defaults?.removeObject(forKey: energeticImageURL)
}
if let normalImageURL{
AppGroupConstants.defaults?.removeObject(forKey: normalImageURL)
}
if let slightStressImageURL{
AppGroupConstants.defaults?.removeObject(forKey: slightStressImageURL)
}
if let stressfulImageURL{
AppGroupConstants.defaults?.removeObject(forKey: stressfulImageURL)
}
}
}
@MainActor
final class WatchUserinfoManager: ObservableObject {
static let share = WatchUserinfoManager()
... ... @@ -60,8 +43,8 @@ final class WatchUserinfoManager: ObservableObject {
}
// 表盘主题
@Published var myTheme: WatchThemeModel?
@Published var otherTheme: WatchThemeModel?
@Published var myTheme: WatchLocalTheme?
@Published var otherTheme: WatchLocalTheme?
private var didSetup = false
... ... @@ -117,8 +100,6 @@ final class WatchUserinfoManager: ObservableObject {
}
@MainActor
func logout(){
myTheme?.cleanCache()
otherTheme?.cleanCache()
myUserInfo = nil
displayFriend = nil
vipInfo = nil
... ... @@ -143,110 +124,101 @@ final class WatchUserinfoManager: ObservableObject {
}
func reloadTheme(){
reloadFriend()
loadLocalWatchTheme()
Task {
await loadRemoteWatchTheme()
}
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
func applySyncedTheme(_ theme: WatchLocalTheme) {
saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
myTheme = theme
objectWillChange.send()
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
func deleteLocalTheme(){
deleteTheme(key: AppGroupConstants.Key.myWatchTheme)
myTheme = nil
objectWillChange.send()
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
//MARK: - 表盘主题
@MainActor
private func loadLocalWatchTheme(){
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.myWatchTheme),
let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
self.myTheme = model
}else{
self.myTheme = nil
}
if let data = AppGroupConstants.defaults?.data(forKey: AppGroupConstants.Key.otherWatchTheme),
let model = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
self.otherTheme = model
}else{
self.otherTheme = nil
}
downloadThemeImageIfNeeded()
myTheme = loadTheme(key: AppGroupConstants.Key.myWatchTheme)
otherTheme = loadTheme(key: AppGroupConstants.Key.otherWatchTheme)
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
private func loadRemoteWatchTheme() async{
let myNewTheme = try? await themeService.getCurrentTheme(userId: nil)
var otherNewTheme: WatchThemeModel?
let myRemoteTheme = try? await themeService.getCurrentTheme(userId: nil)
var otherRemoteTheme: WatchThemeModel?
if let userId = displayFriend?.user_id{
otherNewTheme = try? await themeService.getCurrentTheme(userId: userId)
otherRemoteTheme = try? await themeService.getCurrentTheme(userId: userId)
}
if let myNewTheme{
if myNewTheme != myTheme{
// 清除缓存
myTheme?.cleanCache()
myTheme = myNewTheme
do{
let data = try JSONEncoder().encode(myNewTheme)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
}catch{}
}
}else{
myTheme?.cleanCache()
myTheme = nil
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)
}
if let otherNewTheme{
if otherNewTheme != otherTheme{
// 清除缓存
otherTheme?.cleanCache()
otherTheme = otherNewTheme
do{
let data = try JSONEncoder().encode(otherNewTheme)
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.otherWatchTheme)
}catch{}
}
}else{
otherTheme?.cleanCache()
otherTheme = nil
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)
}
downloadThemeImageIfNeeded()
}
if (myTheme?.hasAllImages != true), let myRemoteTheme {
let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: myRemoteTheme)
myTheme = theme
saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
} else if myTheme == nil,
let legacyTheme = legacyRemoteTheme(key: AppGroupConstants.Key.myWatchTheme) {
let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
myTheme = theme
saveTheme(theme, key: AppGroupConstants.Key.myWatchTheme)
}
@MainActor
private func downloadThemeImageIfNeeded(){
guard let defaults = AppGroupConstants.defaults else { return }
let imageURLs = [myTheme, otherTheme]
.compactMap { $0 }
.flatMap {
[
$0.energeticImageURL,
$0.normalImageURL,
$0.slightStressImageURL,
$0.stressfulImageURL
]
if let otherRemoteTheme {
let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: otherRemoteTheme)
if theme != otherTheme {
otherTheme = theme
saveTheme(theme, key: AppGroupConstants.Key.otherWatchTheme)
}
.compactMap { $0 }
let downloadUrls = imageURLs.filter({ defaults.data(forKey: $0) == nil })
let downloadGroup = DispatchGroup()
var hasDownloadTask = false
} else if displayFriend?.user_id != nil,
otherTheme == nil,
let legacyTheme = legacyRemoteTheme(key: AppGroupConstants.Key.otherWatchTheme) {
let theme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
otherTheme = theme
saveTheme(theme, key: AppGroupConstants.Key.otherWatchTheme)
} else {
otherTheme = nil
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.otherWatchTheme)
}
for urlString in Set(downloadUrls) {
guard let url = URL(string: urlString) else { continue }
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
}
hasDownloadTask = true
downloadGroup.enter()
URLSession.shared.dataTask(with: url) { data, _, error in
defer { downloadGroup.leave() }
guard error == nil, let data else { return }
defaults.set(data, forKey: urlString)
}.resume()
private func loadTheme(key: String) -> WatchLocalTheme? {
guard let data = AppGroupConstants.defaults?.data(forKey: key) else { return nil }
if let theme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
return theme
}
return nil
}
guard hasDownloadTask else {
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
return
private func legacyRemoteTheme(key: String) -> WatchThemeModel? {
if let data = AppGroupConstants.defaults?.data(forKey: key),
let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
return theme
}
downloadGroup.notify(queue: .main) { [weak self] in
// 通过published刷新了watch
self?.objectWillChange.send()
// 再刷新小组件
WidgetCenter.shared.reloadTimelines(ofKind: HippoWidgetKind.hrv.rawValue)
if let jsonString = AppGroupConstants.defaults?.string(forKey: key),
let data = jsonString.data(using: .utf8),
let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
return theme
}
return nil
}
private func saveTheme(_ theme: WatchLocalTheme, key: String) {
guard let data = try? JSONEncoder().encode(theme) else { return }
AppGroupConstants.defaults?.set(data, forKey: key)
}
private func deleteTheme(key: String){
AppGroupConstants.defaults?.removeObject(forKey: key)
AppGroupConstants.defaults?.synchronize()
}
//MARK: - 我的信息
... ...
... ... @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
... ... @@ -342,10 +342,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
... ... @@ -358,8 +362,12 @@
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
... ...
... ... @@ -261,13 +261,17 @@ final class HealthDataReader {
}
func fetchActivityTargetData(startDate: Date, endDate: Date) async throws -> NativeActivityTarget? {
try await fetchActivityTargetDataList(startDate: startDate, endDate: endDate).last
}
func fetchActivityTargetDataList(startDate: Date, endDate: Date) async throws -> [NativeActivityTarget] {
let calendar = Calendar.current
var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate)
var start = calendar.dateComponents([.era, .year, .month, .day], from: startDate)
var end = calendar.dateComponents([.era, .year, .month, .day], from: endDate)
start.calendar = calendar
end.calendar = calendar
start.calendar = calendar
end.calendar = calendar
let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end)
return try await withCheckedThrowingContinuation { continuation in
... ... @@ -276,30 +280,42 @@ final class HealthDataReader {
continuation.resume(throwing: error)
return
}
guard let summary = summaries?.last else {
continuation.resume(returning: nil)
return
}
continuation.resume(
returning: NativeActivityTarget(
move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
activityMoveMode: Self.activityMoveModeValue(from: summary),
activeEnergyBurned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
appleMoveTime: Self.appleMoveTimeValue(from: summary),
appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
standHoursGoal: Self.standHoursGoalValue(from: summary)
)
)
let targets = (summaries ?? [])
.sorted {
let lhsDate = calendar.date(from: $0.dateComponents(for: calendar)) ?? .distantPast
let rhsDate = calendar.date(from: $1.dateComponents(for: calendar)) ?? .distantPast
return lhsDate < rhsDate
}
.map { Self.activityTarget(from: $0, calendar: calendar) }
continuation.resume(returning: targets)
}
healthStore.execute(query)
}
}
private static func activityTarget(
from summary: HKActivitySummary,
calendar: Calendar
) -> NativeActivityTarget {
let day = calendar.date(from: summary.dateComponents(for: calendar))
let timestamp = day.map { calendar.startOfDay(for: $0).timeIntervalSince1970 }
return NativeActivityTarget(
healthValueTimestamp: timestamp,
move: Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())),
stand: Int(summary.appleStandHoursGoal.doubleValue(for: .count())),
activityMoveMode: Self.activityMoveModeValue(from: summary),
activeEnergyBurned: summary.activeEnergyBurned.doubleValue(for: .kilocalorie()),
activeEnergyBurnedGoal: summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()),
appleMoveTime: Self.appleMoveTimeValue(from: summary),
appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
standHoursGoal: Self.standHoursGoalValue(from: summary)
)
}
private static func activityMoveModeValue(from summary: HKActivitySummary) -> Int? {
if #available(iOS 14.0, *) {
return summary.activityMoveMode.rawValue
... ...
... ... @@ -225,6 +225,10 @@ final class HealthKitService {
try await reader.fetchActivityTargetData(startDate: startDate, endDate: endDate)
}
func fetchActivityTargetDataList(startDate: Date, endDate: Date) async throws -> [NativeActivityTarget] {
try await reader.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
}
private func earliestStartDate() -> Date {
NativeHealthDataType.allCases
.filter { $0 != .unknown }
... ... @@ -274,13 +278,16 @@ final class HealthKitService {
}
guard !uploadTypes.isEmpty || includeActivityTarget else { return }
let success = await NativeHealthDataUploader.shared.uploadObservedChange(
let result = await NativeHealthDataUploader.shared.uploadObservedChange(
types: uploadTypes,
includeActivityTarget: includeActivityTarget,
observedSampleTypeIdentifier: sampleType.identifier,
service: self
)
if success {
uploadTypes.forEach { syncStore.save(date: Date(), for: $0) }
if result.success {
result.latestTimestamps.forEach { type, timestamp in
syncStore.save(date: Date(timeIntervalSince1970: timestamp), for: type)
}
}
}
... ...
... ... @@ -19,7 +19,7 @@ enum NativeHealthKitError: LocalizedError {
}
/// Raw values match the original SwiftUI project server contract.
enum NativeHealthDataType: Int, Codable, CaseIterable {
enum NativeHealthDataType: Int, Codable, CaseIterable, Hashable {
case unknown = 0
case hrv = 1
case heartRate = 2
... ... @@ -50,6 +50,7 @@ struct NativeSleepInterval: Codable {
}
struct NativeActivityTarget {
let healthValueTimestamp: TimeInterval?
let move: Int?
let stand: Int?
let activityMoveMode: Int?
... ... @@ -71,7 +72,9 @@ extension NativeActivityTarget {
var uploadBody: [String: Any] {
var body: [String: Any] = [:]
for child in Mirror(reflecting: self).children {
guard let label = child.label, let value = Self.unwrapOptional(child.value) else {
guard let label = child.label,
label != "healthValueTimestamp",
let value = Self.unwrapOptional(child.value) else {
continue
}
body[label.camelCaseToSnakeCase] = value
... ...
import Foundation
import HealthKit
enum NativeHealthUploadConfiguration {
/// Process-lifetime throttle. Each health data type can start at most one
/// upload request during this interval.
static let minimumTriggerInterval: TimeInterval = 60
static let firstUploadLookbackYears = 1
}
struct NativeHealthUploadSummary {
... ... @@ -14,11 +16,21 @@ struct NativeHealthUploadSummary {
let sleepCount: Int
}
struct NativeHealthDebugUploadedDataPoint {
let dataType: NativeHealthDataType
let dataTypeRawValue: Int
let dataTypeName: String
let value: Double
let timestamp: TimeInterval
}
enum NativeHealthUploadError: LocalizedError {
case invalidServerURL
case missingAccessToken
case invalidResponse
case requestFailed(path: String, statusCode: Int, body: String?)
case missingUserId
case invalidQueryAnchor
var errorDescription: String? {
switch self {
... ... @@ -30,6 +42,10 @@ enum NativeHealthUploadError: LocalizedError {
return "健康数据上传接口响应无效"
case .requestFailed(let path, let statusCode, let body):
return "健康数据接口请求失败:\(path), HTTP \(statusCode)\(body.map { ", \($0)" } ?? "")"
case .missingUserId:
return "缺少用户 ID,无法读取本地 HealthKit Anchor"
case .invalidQueryAnchor:
return "HealthKit Anchor 编解码失败"
}
}
}
... ... @@ -41,12 +57,17 @@ enum NativeHealthUploadError: LocalizedError {
/// `/data_upload/common/` response.
actor NativeHealthDataUploader {
static let shared = NativeHealthDataUploader()
private nonisolated static let debugUploadedDataStore = NativeHealthDebugUploadedDataStore()
struct ObservedUploadResult {
let success: Bool
let latestTimestamps: [NativeHealthDataType: TimeInterval]
}
private let session: URLSession
private let defaultUploadTimeWeek = 1
private let firstUploadYear = 1
private let uploadBatchSize = 500
private let sleepUploadLookback: TimeInterval = 24 * 60 * 60
private let syncStore = HealthSyncStateStore()
private var lastUploadTriggerDates: [NativeHealthDataType: Date] = [:]
init(session: URLSession = .shared) {
... ... @@ -56,7 +77,6 @@ actor NativeHealthDataUploader {
func uploadAll(service: HealthKitService = .shared) async -> NativeHealthUploadSummary {
guard AppShared.shared.token?.isEmpty == false else {
let message = NativeHealthUploadError.missingAccessToken.localizedDescription
DebugLogger.debugLog("uploadAll skipped, user is not logged in")
return NativeHealthUploadSummary(
commonUploadSuccess: false,
sleepUploadSuccess: false,
... ... @@ -71,9 +91,8 @@ actor NativeHealthDataUploader {
var sleepUploadSuccess = true
var errorMessages: [String] = []
DebugLogger.debugLog("uploadAll started ")
do {
let uploadTimeList = try await processLastUploadTime()
let uploadTimeList = try await resolvedUploadTimeList()
for type in Self.commonUploadTypes {
let result = await upload(
... ... @@ -82,6 +101,7 @@ actor NativeHealthDataUploader {
trigger: .full,
service: service
)
commitUploadTimestamps(result.latestUploadedTimestamps)
commonCount += result.uploadedCount
if !result.success {
commonUploadSuccess = false
... ... @@ -89,6 +109,7 @@ actor NativeHealthDataUploader {
errorMessages.append(errorMessage)
}
}
}
let sleepResult = await upload(
... ... @@ -97,6 +118,7 @@ actor NativeHealthDataUploader {
trigger: .full,
service: service
)
commitUploadTimestamps(sleepResult.latestUploadedTimestamps)
sleepCount = sleepResult.uploadedCount
sleepUploadSuccess = sleepResult.success
if let errorMessage = sleepResult.errorMessage {
... ... @@ -107,6 +129,7 @@ actor NativeHealthDataUploader {
uploadTimeList: uploadTimeList,
service: service
)
commitUploadTimestamps(activityTargetUploaded.latestUploadedTimestamps)
if !activityTargetUploaded.success {
commonUploadSuccess = false
if let errorMessage = activityTargetUploaded.errorMessage {
... ... @@ -117,11 +140,9 @@ actor NativeHealthDataUploader {
commonUploadSuccess = false
sleepUploadSuccess = false
errorMessages.append(error.localizedDescription)
DebugLogger.debugLog("uploadAll failed before per-type upload, error=\(error.localizedDescription)")
DebugLogger.debugLog("[iOS][upload][failed] type=uploadAll error=\(error.localizedDescription)")
}
DebugLogger.debugLog("uploadAll finished, commonSuccess=\(commonUploadSuccess), sleepSuccess=\(sleepUploadSuccess), commonCount=\(commonCount), sleepCount=\(sleepCount), error=\(errorMessages.isEmpty ? "<nil>" : errorMessages.joined(separator: " | "))")
return NativeHealthUploadSummary(
commonUploadSuccess: commonUploadSuccess,
sleepUploadSuccess: sleepUploadSuccess,
... ... @@ -133,20 +154,20 @@ actor NativeHealthDataUploader {
func upload(type: NativeHealthDataType, service: HealthKitService = .shared) async -> Bool {
guard AppShared.shared.token?.isEmpty == false else {
DebugLogger.debugLog("upload(\(type.debugName)) skipped, user is not logged in")
return false
}
do {
let uploadTimeList = try await processLastUploadTime()
return await upload(
let uploadTimeList = try await resolvedUploadTimeList()
let result = await upload(
type: type,
uploadTimeList: uploadTimeList,
trigger: .manual,
service: service
).success
)
commitUploadTimestamps(result.latestUploadedTimestamps)
return result.success
} catch {
DebugLogger.debugLog("upload(\(type.debugName)) failed to process last upload time, error=\(error.localizedDescription)")
DebugLogger.debugLog("Health upload failed to process last upload time: \(error.localizedDescription)")
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
return false
}
}
... ... @@ -154,17 +175,18 @@ actor NativeHealthDataUploader {
func uploadObservedChange(
types: [NativeHealthDataType],
includeActivityTarget: Bool,
observedSampleTypeIdentifier: String? = nil,
service: HealthKitService = .shared
) async -> Bool {
) async -> ObservedUploadResult {
guard AppShared.shared.token?.isEmpty == false else {
DebugLogger.debugLog("observed change skipped, user is not logged in")
return false
return ObservedUploadResult(success: false, latestTimestamps: [:])
}
do {
let uploadTimeList = try await processLastUploadTime()
let uploadTimeList = try await resolvedUploadTimeList()
var success = true
var uploadedNewData = false
var latestTimestamps: [NativeHealthDataType: TimeInterval] = [:]
for type in types {
let result = await upload(
type: type,
... ... @@ -172,22 +194,38 @@ actor NativeHealthDataUploader {
trigger: .observer,
service: service
)
commitUploadTimestamps(result.latestUploadedTimestamps)
success = success && result.success
uploadedNewData = uploadedNewData || result.uploadedCount > 0
if !result.latestUploadedTimestamps.isEmpty {
result.latestUploadedTimestamps.forEach { type, timestamp in
latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
}
} else if let latestUploadedTimestamp = result.latestUploadedTimestamp {
latestTimestamps[type] = latestUploadedTimestamp
}
}
if includeActivityTarget && uploadedNewData {
if includeActivityTarget && (uploadedNewData || types.isEmpty) {
let result = await uploadActivityTargetIfNeeded(
uploadTimeList: uploadTimeList,
service: service
)
commitUploadTimestamps(result.latestUploadedTimestamps)
success = success && result.success
result.latestUploadedTimestamps.forEach { type, timestamp in
latestTimestamps[type] = max(latestTimestamps[type] ?? 0, timestamp)
}
}
return success
return ObservedUploadResult(success: success, latestTimestamps: latestTimestamps)
} catch {
DebugLogger.debugLog("observed change upload failed, error=\(error.localizedDescription)")
return false
DebugLogger.debugLog("[iOS][upload][failed] type=observer error=\(error.localizedDescription)")
return ObservedUploadResult(success: false, latestTimestamps: [:])
}
}
nonisolated func getDebugCurrentUploadedData() -> [NativeHealthDebugUploadedDataPoint] {
Self.debugUploadedDataStore.snapshot()
}
}
private extension NativeHealthDataUploader {
... ... @@ -195,6 +233,9 @@ private extension NativeHealthDataUploader {
let success: Bool
let uploadedCount: Int
let errorMessage: String?
var latestUploadedTimestamp: TimeInterval?
var latestUploadedTimestamps: [NativeHealthDataType: TimeInterval] = [:]
var wasThrottled = false
}
enum UploadTrigger: String {
... ... @@ -218,6 +259,85 @@ private extension NativeHealthDataUploader {
.respiratoryRate,
.irregularHeartRhythm,
]
static let activityTargetAnchorType: NativeHealthDataType = .activeEnergy
static let activityTargetTimestampTypes: [NativeHealthDataType] = [
.activeEnergy,
.exercise,
.stand,
.steps,
]
static let uploadAnchorTypes: [NativeHealthDataType] = [
.hrv,
.heartRate,
.walkingHeartRate,
.restingHeartRate,
.oxygenSaturation,
.activeEnergy,
.sleepingWristTemperature,
.respiratoryRate,
.irregularHeartRhythm,
.sleep,
]
static func uploadAnchorType(for type: NativeHealthDataType) -> NativeHealthDataType {
switch type {
case .sleepingHeartRate:
return .heartRate
case .exercise, .stand, .steps:
return .activeEnergy
default:
return type
}
}
static func uploadTypes(forAnchorType anchorType: NativeHealthDataType) -> [NativeHealthDataType] {
NativeHealthDataType.allCases.filter {
$0 != .unknown && uploadAnchorType(for: $0) == anchorType
}
}
func resolvedUploadTimeList() async throws -> NativeHealthUploadTimeList {
switch HealthUploadCursorConfiguration.mode {
case .serverTime:
return try await processLastUploadTime()
case .localAnchor:
let timestamp = firstUploadStartDate().timeIntervalSince1970
return NativeHealthUploadTimeList(latestDataTimeList: Self.uploadAnchorTypes.map { anchorType in
let date = syncStore.lastSyncDate(for: anchorType) ?? Date(timeIntervalSince1970: timestamp)
return .init(dataType: anchorType, latestDataTime: date.timeIntervalSince1970)
})
}
}
func firstUploadStartDate() -> Date {
let years = NativeHealthUploadConfiguration.firstUploadLookbackYears
let date = Calendar.current.date(byAdding: .year, value: -years, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(years * 365 * 24 * 60 * 60))
return Calendar.current.startOfDay(for: date)
}
func resolvedAnchorSourceIdentifier(
for type: NativeHealthDataType,
override: String?
) -> String {
if let override { return override }
switch type {
case .hrv: return HKQuantityTypeIdentifier.heartRateVariabilitySDNN.rawValue
case .heartRate, .sleepingHeartRate: return HKQuantityTypeIdentifier.heartRate.rawValue
case .walkingHeartRate: return HKQuantityTypeIdentifier.walkingHeartRateAverage.rawValue
case .restingHeartRate: return HKQuantityTypeIdentifier.restingHeartRate.rawValue
case .oxygenSaturation: return HKQuantityTypeIdentifier.oxygenSaturation.rawValue
case .activeEnergy: return HKQuantityTypeIdentifier.activeEnergyBurned.rawValue
case .exercise: return HKQuantityTypeIdentifier.appleExerciseTime.rawValue
case .stand: return HKQuantityTypeIdentifier.appleStandTime.rawValue
case .steps: return HKQuantityTypeIdentifier.stepCount.rawValue
case .sleepingWristTemperature: return HKQuantityTypeIdentifier.appleSleepingWristTemperature.rawValue
case .respiratoryRate: return HKQuantityTypeIdentifier.respiratoryRate.rawValue
case .irregularHeartRhythm: return HKCategoryTypeIdentifier.irregularHeartRhythmEvent.rawValue
case .sleep: return HKCategoryTypeIdentifier.sleepAnalysis.rawValue
case .unknown: return "unknown"
}
}
func upload(
type: NativeHealthDataType,
... ... @@ -227,16 +347,13 @@ private extension NativeHealthDataUploader {
) async -> UploadTaskResult {
guard type != .unknown,
let startUploadDate = uploadTimeList.latestDataTime(for: type) else {
DebugLogger.debugLog("[\(type.debugName)] skipped, no upload start date")
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=missing upload start date")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
let endUploadDate = Date()
DebugLogger.debugLog(
"[\(type.debugName)] upload started, trigger=\(trigger.rawValue), range=\(Self.debugDateFormatter.string(from: startUploadDate)) -> \(Self.debugDateFormatter.string(from: endUploadDate))"
)
guard endUploadDate >= startUploadDate else {
DebugLogger.debugLog("[\(type.debugName)] upload failed, start date is later than end date")
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=start date is later than end date")
return UploadTaskResult(
success: false,
uploadedCount: 0,
... ... @@ -247,33 +364,29 @@ private extension NativeHealthDataUploader {
do {
switch type {
case .sleep:
DebugLogger.debugLog("[\(type.debugName)] reading sleep data")
let data = try await service.fetchSleepData(startDate: startUploadDate, endDate: endUploadDate)
.filter { $0.toTime > startUploadDate.timeIntervalSince1970 }
.sorted { $0.toTime < $1.toTime }
debugLogTimeComparison(
type: type,
serverTime: startUploadDate.timeIntervalSince1970,
newestTime: data.map(\.toTime).max()
)
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
guard !data.isEmpty else {
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
guard allowUploadTrigger(for: type) else {
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
}
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
try await uploadSleep(data)
let latestUploadedTimestamp = data.map(\.toTime).max()
notifyFlutterUpload(
type: type,
timestamp: data.map(\.toTime).max() ?? endUploadDate.timeIntervalSince1970
timestamp: latestUploadedTimestamp!
)
return UploadTaskResult(
success: true,
uploadedCount: data.count,
errorMessage: nil,
latestUploadedTimestamp: latestUploadedTimestamp,
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
)
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
default:
DebugLogger.debugLog("[\(type.debugName)] reading common data")
let data = try await fetchCommonData(
type: type,
startDate: startUploadDate,
... ... @@ -282,30 +395,28 @@ private extension NativeHealthDataUploader {
)
.filter { $0.time > startUploadDate.timeIntervalSince1970 }
.sorted { $0.time < $1.time }
debugLogTimeComparison(
type: type,
serverTime: startUploadDate.timeIntervalSince1970,
newestTime: data.map(\.time).max()
)
DebugLogger.debugLog("[\(type.debugName)] read finished, count=\(data.count)")
guard !data.isEmpty else {
DebugLogger.debugLog("[\(type.debugName)] no data; server upload time remains unchanged")
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
guard allowUploadTrigger(for: type) else {
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil, wasThrottled: true)
}
DebugLogger.debugLog("[\(type.debugName)] uploading, count=\(data.count)")
try await uploadCommon(data)
let latestUploadedTimestamp = data.map(\.time).max()
notifyFlutterUpload(
type: type,
timestamp: data.map(\.time).max() ?? endUploadDate.timeIntervalSince1970
timestamp: latestUploadedTimestamp!
)
return UploadTaskResult(
success: true,
uploadedCount: data.count,
errorMessage: nil,
latestUploadedTimestamp: latestUploadedTimestamp,
latestUploadedTimestamps: latestUploadedTimestamp.map { [type: $0] } ?? [:]
)
DebugLogger.debugLog("[\(type.debugName)] upload success, count=\(data.count); next boundary will come from server")
return UploadTaskResult(success: true, uploadedCount: data.count, errorMessage: nil)
}
} catch {
DebugLogger.debugLog("[\(type.debugName)] upload failed, error=\(error.localizedDescription)")
DebugLogger.debugLog("[iOS][upload][failed] type=\(type.rawValue)(\(type.debugName)) error=\(error.localizedDescription)")
return UploadTaskResult(
success: false,
uploadedCount: 0,
... ... @@ -314,6 +425,17 @@ private extension NativeHealthDataUploader {
}
}
func commitUploadTimestamps(_ timestamps: [NativeHealthDataType: TimeInterval]) {
let grouped = timestamps.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, item in
let anchorType = Self.uploadAnchorType(for: item.key)
result[anchorType] = max(result[anchorType] ?? 0, item.value)
}
grouped.forEach { anchorType, timestamp in
let currentTimestamp = syncStore.lastSyncDate(for: anchorType)?.timeIntervalSince1970 ?? 0
syncStore.save(date: Date(timeIntervalSince1970: max(currentTimestamp, timestamp)), for: anchorType)
}
}
func fetchCommonData(
type: NativeHealthDataType,
startDate: Date,
... ... @@ -356,28 +478,38 @@ private extension NativeHealthDataUploader {
uploadTimeList: NativeHealthUploadTimeList,
service: HealthKitService
) async -> UploadTaskResult {
let startDate = uploadTimeList.latestDataTime(for: .activeEnergy)
?? Calendar.current.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
let calendar = Calendar.current
let recentStartDate = calendar.startOfDay(
for: calendar.date(byAdding: .weekOfYear, value: -defaultUploadTimeWeek, to: Date())
?? Date(timeIntervalSinceNow: -7 * 24 * 60 * 60)
)
let startDate = uploadTimeList.latestDataTime(for: Self.activityTargetAnchorType) ?? recentStartDate
let endDate = Date()
DebugLogger.debugLog(
"[activityTarget] upload started, range=\(Self.debugDateFormatter.string(from: startDate)) -> \(Self.debugDateFormatter.string(from: endDate))"
)
do {
guard let target = try await service.fetchActivityTargetData(startDate: startDate, endDate: endDate),
target.move != nil || target.stand != nil else {
DebugLogger.debugLog("[activityTarget] no target data")
let targets = try await service.fetchActivityTargetDataList(startDate: startDate, endDate: endDate)
guard let target = targets.last,
targets.contains(where: { $0.move != nil || $0.stand != nil }) else {
return UploadTaskResult(success: true, uploadedCount: 0, errorMessage: nil)
}
DebugLogger.debugLog("[activityTarget] uploading, body=\(target.uploadBodyForDebug)")
try await uploadActivityTarget(target)
let uploadedTypes = try await uploadActivityTargetHealthValues(target)
let uploadedTypes = try await uploadActivityTargetHealthValues(targets)
uploadedTypes.forEach {
notifyFlutterUpload(type: $0.type, timestamp: $0.timestamp)
}
DebugLogger.debugLog("[activityTarget] upload success")
return UploadTaskResult(success: true, uploadedCount: 1, errorMessage: nil)
let latestTimestamp = uploadedTypes.map(\.timestamp).max()
let latestUploadedTimestamps = latestTimestamp.map { timestamp in
Self.activityTargetTimestampTypes.reduce(into: [NativeHealthDataType: TimeInterval]()) { result, type in
result[type] = timestamp
}
} ?? [:]
return UploadTaskResult(
success: true,
uploadedCount: uploadedTypes.isEmpty ? 0 : 1,
errorMessage: nil,
latestUploadedTimestamp: latestTimestamp,
latestUploadedTimestamps: latestUploadedTimestamps
)
} catch {
DebugLogger.debugLog("[activityTarget] upload failed, error=\(error.localizedDescription)")
return UploadTaskResult(
... ... @@ -391,35 +523,21 @@ private extension NativeHealthDataUploader {
func processLastUploadTime() async throws -> NativeHealthUploadTimeList {
let serverTimeList = try await fetchLastUploadTime()
var resultTimeList: [NativeHealthUploadTimeList.HealthUploadTime] = []
DebugLogger.debugLog("processLastUploadTime started, source=server")
let twoYearsAgo = Calendar.current.date(byAdding: .year, value: -firstUploadYear, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(firstUploadYear * 365 * 24 * 60 * 60))
let twoYearsAgoMidnight = Calendar.current.startOfDay(for: twoYearsAgo).timeIntervalSince1970
for type in NativeHealthDataType.allCases where type != .unknown {
let serverTime = serverTimeList.latestTimeInterval(for: type)
let finalTime: TimeInterval
let sourceDescription: String
if type == .sleep {
if let serverTime {
finalTime = max(0, serverTime - sleepUploadLookback)
sourceDescription = "server minus 24h"
} else {
finalTime = twoYearsAgoMidnight
sourceDescription = "missing; first sleep upload uses two years"
}
} else {
finalTime = serverTime ?? twoYearsAgoMidnight
sourceDescription = serverTime == nil ? "missing; first upload uses two years" : "server"
}
DebugLogger.debugLog(
"[\(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)))"
)
let lookbackYears = NativeHealthUploadConfiguration.firstUploadLookbackYears
let firstUploadStartDate = Calendar.current.date(byAdding: .year, value: -lookbackYears, to: Date())
?? Date(timeIntervalSinceNow: -TimeInterval(lookbackYears * 365 * 24 * 60 * 60))
let firstUploadStartTimestamp = Calendar.current.startOfDay(for: firstUploadStartDate).timeIntervalSince1970
for anchorType in Self.uploadAnchorTypes {
let serverTime = Self.uploadTypes(forAnchorType: anchorType)
.compactMap { serverTimeList.rawLatestTimeInterval(for: $0) }
.max()
let finalTime = serverTime ?? firstUploadStartTimestamp
resultTimeList.append(
NativeHealthUploadTimeList.HealthUploadTime(
dataType: type,
dataType: anchorType,
latestDataTime: finalTime
)
)
... ... @@ -443,15 +561,13 @@ private extension NativeHealthDataUploader {
] as [String: Any]
}
try await request(path: "/client/doublefeel/health/v2/data_upload/common/", method: "POST", body: ["data_list": list])
recordDebugUploadedCommonData(batch)
}
}
func allowUploadTrigger(for type: NativeHealthDataType, now: Date = Date()) -> Bool {
if let lastDate = lastUploadTriggerDates[type],
now.timeIntervalSince(lastDate) < NativeHealthUploadConfiguration.minimumTriggerInterval {
DebugLogger.debugLog(
"[\(type.debugName)] skipped by \(Int(NativeHealthUploadConfiguration.minimumTriggerInterval))s process throttle"
)
return false
}
lastUploadTriggerDates[type] = now
... ... @@ -460,7 +576,6 @@ private extension NativeHealthDataUploader {
func notifyFlutterUpload(type: NativeHealthDataType, timestamp: TimeInterval) {
let seconds = Int64(timestamp)
DebugLogger.debugLog("[\(type.debugName)] notifying Flutter, dataType=\(type.rawValue), timestamp=\(seconds)")
NotificationCenter.default.post(
name: .nativeHealthDataDidUpload,
object: nil,
... ... @@ -486,6 +601,7 @@ private extension NativeHealthDataUploader {
] as [String: Any]
}
try await request(path: "/client/doublefeel/health/v2/data_upload/sleep/", method: "POST", body: ["data_list": list])
recordDebugUploadedSleepData(batch)
}
}
... ... @@ -494,22 +610,35 @@ private extension NativeHealthDataUploader {
}
func uploadActivityTargetHealthValues(
_ target: NativeActivityTarget,
timestamp: TimeInterval = Date().timeIntervalSince1970
_ target: NativeActivityTarget
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
try await uploadActivityTargetHealthValues([target])
}
func uploadActivityTargetHealthValues(
_ targets: [NativeActivityTarget]
) async throws -> [(type: NativeHealthDataType, timestamp: TimeInterval)] {
let values: [(NativeHealthDataType, Double?)] = [
(.activeEnergy, target.activeEnergyBurned),
(.exercise, target.appleExerciseTime),
(.stand, target.appleStandHours),
]
let data = values.compactMap { item -> NativeHealthDataPoint? in
let (type, value) = item
guard let value else { return nil }
return NativeHealthDataPoint(dataType: type, time: timestamp, value: value)
var data: [NativeHealthDataPoint] = []
for target in targets {
guard let timestamp = target.healthValueTimestamp else { continue }
let values: [(NativeHealthDataType, Double?)] = [
(.activeEnergy, target.activeEnergyBurned),
(.exercise, target.appleExerciseTime),
(.stand, target.appleStandHours),
]
for (type, value) in values {
guard let value else { continue }
data.append(
NativeHealthDataPoint(
dataType: type,
time: timestamp,
value: value
)
)
}
}
guard !data.isEmpty else { return [] }
DebugLogger.debugLog("[activityTarget] uploading common values, count=\(data.count)")
try await uploadCommon(data)
return data.map { ($0.dataType, $0.time) }
}
... ... @@ -546,6 +675,11 @@ private extension NativeHealthDataUploader {
throw NativeHealthUploadError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
if httpResponse.statusCode == 401 {
await MainActor.run {
AppShared.shared.logout()
}
}
throw NativeHealthUploadError.requestFailed(
path: path,
statusCode: httpResponse.statusCode,
... ... @@ -566,17 +700,50 @@ private extension NativeHealthDataUploader {
Self.debugDateFormatter.string(from: Date(timeIntervalSince1970: timeInterval))
}
func debugLogTimeComparison(
type: NativeHealthDataType,
serverTime: TimeInterval,
newestTime: TimeInterval?
) {
let newestDescription = newestTime.map {
"\(debugTimestamp($0)) (unix=\(Int64($0)))"
} ?? "<none>"
DebugLogger.debugLog(
"[iOS][time-check] dataType=\(type.rawValue)(\(type.debugName)), server=\(debugTimestamp(serverTime)) (unix=\(Int64(serverTime))), newest=\(newestDescription), shouldUpload=\(newestTime.map { $0 > serverTime } ?? false)"
)
func recordDebugUploadedCommonData(_ data: [NativeHealthDataPoint]) {
let points = data.map {
NativeHealthDebugUploadedDataPoint(
dataType: $0.dataType,
dataTypeRawValue: $0.dataType.rawValue,
dataTypeName: $0.dataType.debugName,
value: $0.value,
timestamp: $0.time
)
}
Self.debugUploadedDataStore.append(points)
}
func recordDebugUploadedSleepData(_ data: [NativeSleepInterval]) {
let points = data.map {
NativeHealthDebugUploadedDataPoint(
dataType: .sleep,
dataTypeRawValue: NativeHealthDataType.sleep.rawValue,
dataTypeName: NativeHealthDataType.sleep.debugName,
value: Double($0.dataType),
timestamp: $0.toTime
)
}
Self.debugUploadedDataStore.append(points)
}
}
private final class NativeHealthDebugUploadedDataStore: @unchecked Sendable {
private let lock = NSLock()
private var points: [NativeHealthDebugUploadedDataPoint] = []
func append(_ newPoints: [NativeHealthDebugUploadedDataPoint]) {
guard !newPoints.isEmpty else { return }
lock.lock()
points.append(contentsOf: newPoints)
lock.unlock()
}
func snapshot() -> [NativeHealthDebugUploadedDataPoint] {
lock.lock()
let currentPoints = points
lock.unlock()
return currentPoints
}
}
... ... @@ -602,6 +769,10 @@ struct NativeHealthUploadTimeList: Codable {
}
func latestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
rawLatestTimeInterval(for: NativeHealthDataUploader.uploadAnchorType(for: type))
}
func rawLatestTimeInterval(for type: NativeHealthDataType) -> TimeInterval? {
latestDataTimeList.first { $0.dataType == type }?.latestDataTime
}
... ...
... ... @@ -43,17 +43,10 @@ final class WearEngineHostApiImpl: WearEngineHostApi {
func syncWatchTheme(theme: WatchTransformTheme?, completion: @escaping (Result<Bool, any Error>) -> Void) {
guard let theme else{
//TODO: - 删除主题
completion(.success(true))
watchService.deleteWatchTheme(completion)
return
}
let success = WatchThemeStore.shared.saveTheme(theme)
guard success else {
completion(.success(false))
return
}
let notified = watchService.sendWatchThemeChangedMessage()
completion(.success(notified))
watchService.sendWatchThemeChangedMessage(theme: theme, completion: completion)
}
func removeBackground(originImagePath: String, completion: @escaping (Result<String?, any Error>) -> Void) {
... ...
import Foundation
enum HealthUploadCursorMode: String {
/// Use the latest data time returned by the server.
case serverTime
/// Use a per-device, per-user HKQueryAnchor stored locally.
case localAnchor
}
enum HealthUploadCursorConfiguration {
/// Change this single value to compare the two upload cursor strategies.
static var mode: HealthUploadCursorMode = .localAnchor
}
/// Shared container used by the iPhone app, Watch app, and Widget extension.
/// Keep these keys aligned with `ios/Runner Watch App` and the watch extension.
enum AppGroupConstants {
... ... @@ -74,6 +86,7 @@ struct AppGroupMessageKey{
static let reloadAll = "reloadAll"
static let reloadVip = "reloadVip"
static let reloadTheme = "reloadTheme"
static let deleteTheme = "deleteTheme"
// 手表通知app刷新连接状态
static let statusPulseRefresh = "statusPulseRefresh"
... ...
... ... @@ -112,8 +112,75 @@ final class WatchConnectivityService: NSObject {
return true
}
func sendWatchThemeChangedMessage(json: String? = nil) -> Bool{
sendCommandMessage(AppGroupMessageKey.reloadTheme, json: json)
func sendWatchThemeChangedMessage(
theme: WatchTransformTheme,
completion: @escaping (Result<Bool, Error>) -> Void
) {
var payload: [String: Any] = [
"theme_id": Int(theme.themeId),
"theme_name": theme.themeName,
"energetic_description": theme.energeticDescription,
"normal_description": theme.normalDescription,
"slight_stressful_description": theme.slightStressfulDescription,
"stressful_description": theme.stressfulDescription,
]
payload["energetic_image"] = theme.energeticImage?.data
payload["normal_image"] = theme.normalImage?.data
payload["slight_stressful_image"] = theme.slightStressfulImage?.data
payload["stressful_image"] = theme.stressfulImage?.data
sendReliableCommandMessage(
AppGroupMessageKey.reloadTheme,
payload: payload,
completion: completion
)
}
func deleteWatchTheme(_ completion: @escaping (Result<Bool, Error>) -> Void) {
sendReliableCommandMessage(
AppGroupMessageKey.deleteTheme,
completion: completion
)
}
private func commandPayload(
_ message: String,
json: String? = nil,
payload: [String: Any]? = nil
) -> [String: Any] {
var params: [String: Any] = [
"command": message
]
params["json"] = json
if let payload {
params["payload"] = payload
}
return params
}
private func sendReliableCommandMessage(
_ message: String,
json: String? = nil,
payload: [String: Any]? = nil,
completion: @escaping (Result<Bool, Error>) -> Void
) {
guard activate() else {
completion(.success(false))
return
}
let params = commandPayload(message, json: json, payload: payload)
WCSession.default.transferUserInfo(params)
if WCSession.default.isReachable {
WCSession.default.sendMessage(params, replyHandler: { _ in
completion(.success(true))
}) { error in
print("Watch reliable message send failed: \(error.localizedDescription)")
completion(.success(true))
}
} else {
completion(.success(true))
}
}
}
... ...
import Foundation
/// Shared Watch theme persistence.
/// The Watch app and Widget extension read this exact JSON data from App Group.
final class WatchThemeStore {
static let shared = WatchThemeStore()
private init() {}
@discardableResult
func saveThemeJSONString(_ json: String) -> Bool {
guard let data = json.data(using: .utf8) else { return false }
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
return true
}
@discardableResult
func saveTheme(_ theme: WatchTransformTheme) -> Bool {
guard let defaults = AppGroupConstants.defaults else { return false }
let imageStore = SyncedWatchThemeImageStore(themeId: theme.themeId)
let model = SyncedWatchThemeModel(
id: Int(theme.themeId),
themeName: theme.themeName,
isOfficial: 0,
energeticTitle: theme.energeticDescription,
energeticImageURL: imageStore.save(
theme.energeticImage?.data,
status: "energetic",
defaults: defaults
),
normalTitle: theme.normalDescription,
normalImageURL: imageStore.save(
theme.normalImage?.data,
status: "normal",
defaults: defaults
),
slightStressTitle: theme.slightStressfulDescription,
slightStressImageURL: imageStore.save(
theme.slightStressfulImage?.data,
status: "slight_stressful",
defaults: defaults
),
stressfulTitle: theme.stressfulDescription,
stressfulImageURL: imageStore.save(
theme.stressfulImage?.data,
status: "stressful",
defaults: defaults
)
)
do {
let data = try JSONEncoder().encode(model)
defaults.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
defaults.synchronize()
return true
} catch {
return false
}
}
func clearTheme() {
AppGroupConstants.defaults?.removeObject(forKey: AppGroupConstants.Key.myWatchTheme)
}
}
private struct SyncedWatchThemeImageStore {
let themeId: Int64
func save(_ data: Data?, status: String, defaults: UserDefaults) -> String? {
guard let data else { return nil }
let key = "watch_theme_\(themeId)_\(status)"
defaults.set(data, forKey: key)
return key
}
}
private struct SyncedWatchThemeModel: Codable {
let id: Int?
let themeName: String?
let isOfficial: Int?
let energeticTitle: String?
let energeticImageURL: String?
let normalTitle: String?
let normalImageURL: String?
let slightStressTitle: String?
let slightStressImageURL: String?
let stressfulTitle: String?
let stressfulImageURL: String?
enum CodingKeys: String, CodingKey {
case id
case themeName = "theme_name"
case isOfficial = "is_official"
case energeticTitle = "energetic_description"
case energeticImageURL = "energetic_image"
case normalTitle = "normal_description"
case normalImageURL = "normal_image"
case slightStressTitle = "slight_stressful_description"
case slightStressImageURL = "slight_stressful_image"
case stressfulTitle = "stressful_description"
case stressfulImageURL = "stressful_image"
}
}
... ... @@ -9,13 +9,29 @@ struct DFCoupleMiniCard: View {
let name: String
let status: DFStressStatus
let value: String
let themeImage: Image?
let title: String?
init(
name: String,
status: DFStressStatus,
value: String,
themeImage: Image? = nil,
title: String? = nil
) {
self.name = name
self.status = status
self.value = value
self.themeImage = themeImage
self.title = title
}
var body: some View {
HStack(spacing: 5) {
DFStatusOrb(status: status, value: "")
DFStatusOrb(status: status, value: "", themeImage: themeImage)
.frame(width: 30, height: 30)
VStack(alignment: .leading, spacing: 1) {
Text(status.title)
Text(title ?? status.title)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(status.color)
.lineLimit(1)
... ...
... ... @@ -11,6 +11,8 @@ struct DFCoupleStressFaceView: View {
var body: some View {
GeometryReader { proxy in
let scale = min(proxy.size.width / 172, proxy.size.height / 74)
let partnerContent = entry.theme?.faceContent(for: entry.data.partnerStatus)
let myContent = entry.theme?.faceContent(for: entry.data.myStatus)
VStack(spacing: 5 * scale) {
HStack {
Text(entry.data.hasData ? "我们的状态 · HRV" : "我们的状态 · 实时")
... ... @@ -25,12 +27,16 @@ struct DFCoupleStressFaceView: View {
DFCoupleMiniCard(
name: entry.data.partnerName,
status: entry.data.partnerStatus,
value: entry.data.hasData ? "\(entry.data.partnerHRV)ms" : "--"
value: entry.data.hasData ? "\(entry.data.partnerHRV)ms" : "--",
themeImage: partnerContent?.image,
title: partnerContent?.title
)
DFCoupleMiniCard(
name: entry.data.myName,
status: entry.data.myStatus,
value: entry.data.hasData ? "\(entry.data.myHRV)ms" : "--"
value: entry.data.hasData ? "\(entry.data.myHRV)ms" : "--",
themeImage: myContent?.image,
title: myContent?.title
)
}
}
... ...
... ... @@ -11,6 +11,7 @@ struct DFDefaultHomeFaceView: View {
var body: some View {
GeometryReader { proxy in
let scale = min(proxy.size.width / 172, proxy.size.height / 74)
let content = entry.theme?.faceContent(for: entry.data.myStatus)
HStack(spacing: 8 * scale) {
VStack(alignment: .leading, spacing: 2 * scale) {
HStack(spacing: 4 * scale) {
... ... @@ -31,13 +32,20 @@ struct DFDefaultHomeFaceView: View {
.frame(height: 7 * scale)
}
if let image = content?.image {
image
.resizable()
.scaledToFit()
.frame(width: 30 * scale, height: 30 * scale)
}
VStack(alignment: .trailing, spacing: 5 * scale) {
HStack(spacing: 5 * scale) {
DFMiniMetric(value: entry.data.hasData ? "\(entry.data.heartRate)" : "0", icon: "heart.fill", tint: DFWatchFaceColor.red)
DFMiniMetric(value: "\(entry.data.closePercent)", icon: "sparkles", tint: DFWatchFaceColor.purple)
DFMiniMetric(value: entry.data.hasData ? shortSteps(entry.data.steps) : "0", icon: "shoeprints.fill", tint: DFWatchFaceColor.green)
}
Text(entry.data.hasData ? "\(entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "暂无数据 · --ms")
Text(entry.data.hasData ? "\(content?.title ?? entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "暂无数据 · --ms")
.font(.system(size: 10 * scale, weight: .semibold))
.foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText)
.lineLimit(1)
... ...
... ... @@ -8,11 +8,25 @@ import SwiftUI
struct DFStatusOrb: View {
let status: DFStressStatus
let value: String
let themeImage: Image?
init(status: DFStressStatus, value: String, themeImage: Image? = nil) {
self.status = status
self.value = value
self.themeImage = themeImage
}
var body: some View {
ZStack {
Circle()
.fill(status.color.opacity(0.16))
if let themeImage {
themeImage
.resizable()
.scaledToFit()
.clipShape(Circle())
.padding(4)
}
Circle()
.stroke(status.color.opacity(0.34), lineWidth: 5)
Circle()
... ... @@ -20,10 +34,12 @@ struct DFStatusOrb: View {
.stroke(status.color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(110))
if value.isEmpty {
Image(systemName: "heart.text.square.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(status.color)
} else {
if themeImage == nil {
Image(systemName: "heart.text.square.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(status.color)
}
} else if themeImage == nil {
VStack(spacing: -1) {
Text(value)
.font(.system(size: 14, weight: .bold, design: .rounded))
... ... @@ -32,6 +48,13 @@ struct DFStatusOrb: View {
.font(.system(size: 7, weight: .medium))
.foregroundStyle(.white.opacity(0.74))
}
} else {
Text(value)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.black.opacity(0.45), in: Capsule())
}
}
}
... ...
... ... @@ -9,4 +9,5 @@ struct DFWatchFaceEntry: TimelineEntry {
let date: Date
let face: DFWatchFaceKind
let data: DFWatchFaceData
let theme: WatchLocalTheme?
}
... ...
... ... @@ -9,17 +9,17 @@ import SwiftUI
#Preview("默认表盘", as: .accessoryRectangular) {
DFDefaultHomeWidget()
} timeline: {
DFWatchFaceEntry(date: .now, face: .defaultHome, data: .placeholder)
DFWatchFaceEntry(date: .now, face: .defaultHome, data: .placeholder, theme: nil)
}
#Preview("单人压力表盘", as: .accessoryRectangular) {
DFSingleStressWidget()
} timeline: {
DFWatchFaceEntry(date: .now, face: .singleStress, data: .placeholder)
DFWatchFaceEntry(date: .now, face: .singleStress, data: .placeholder, theme: nil)
}
#Preview("双人压力表盘", as: .accessoryRectangular) {
DFCoupleStressWidget()
} timeline: {
DFWatchFaceEntry(date: .now, face: .coupleStress, data: .placeholder)
DFWatchFaceEntry(date: .now, face: .coupleStress, data: .placeholder, theme: nil)
}
... ...
... ... @@ -9,17 +9,18 @@ struct DFWatchFaceProvider: TimelineProvider {
let face: DFWatchFaceKind
func placeholder(in context: Context) -> DFWatchFaceEntry {
DFWatchFaceEntry(date: .now, face: face, data: .placeholder)
DFWatchFaceEntry(date: .now, face: face, data: .placeholder, theme: nil)
}
func getSnapshot(in context: Context, completion: @escaping (DFWatchFaceEntry) -> Void) {
completion(DFWatchFaceEntry(date: .now, face: face, data: .placeholder))
completion(DFWatchFaceEntry(date: .now, face: face, data: .placeholder, theme: nil))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<DFWatchFaceEntry>) -> Void) {
Task {
let data = await DFWatchFaceMockService.fetch(face: face)
let entry = DFWatchFaceEntry(date: .now, face: face, data: data)
let theme = await DFWatchThemeStore.currentTheme()
let entry = DFWatchFaceEntry(date: .now, face: face, data: data, theme: theme)
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900)
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
}
... ...
//
// DFWatchThemeStore.swift
// hippo-watch-extension
//
import Foundation
import SwiftUI
enum DFWatchThemeStore {
static func currentTheme() async -> WatchLocalTheme? {
if let localTheme = storedTheme() {
if localTheme.hasAllImages {
return localTheme
}
guard let remoteTheme = await remoteTheme() else {
return localTheme
}
let refreshedTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: remoteTheme)
save(refreshedTheme)
return refreshedTheme
}
if let remoteTheme = await remoteTheme() {
let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: remoteTheme)
save(localTheme)
return localTheme
}
guard let legacyTheme = legacyRemoteTheme() else {
return nil
}
let localTheme = await WatchThemeLocalBuilder.makeLocalTheme(from: legacyTheme)
save(localTheme)
return localTheme
}
private static func storedTheme() -> WatchLocalTheme? {
guard let data = AppGroupConstants.defaults?.data(
forKey: AppGroupConstants.Key.myWatchTheme
) else {
return nil
}
if let theme = try? JSONDecoder().decode(WatchLocalTheme.self, from: data) {
return theme
}
return nil
}
private static func legacyRemoteTheme() -> WatchThemeModel? {
if let data = AppGroupConstants.defaults?.data(
forKey: AppGroupConstants.Key.myWatchTheme
),
let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
return theme
}
if let jsonString = AppGroupConstants.defaults?.string(
forKey: AppGroupConstants.Key.myWatchTheme
),
let data = jsonString.data(using: .utf8),
let theme = try? JSONDecoder().decode(WatchThemeModel.self, from: data) {
return theme
}
return nil
}
private static func save(_ theme: WatchLocalTheme) {
guard let data = try? JSONEncoder().encode(theme) else { return }
AppGroupConstants.defaults?.set(data, forKey: AppGroupConstants.Key.myWatchTheme)
}
private static func remoteTheme() async -> WatchThemeModel? {
guard let login = loginInfo(),
let token = login.token,
!token.isEmpty else {
return nil
}
let baseUrl = login.baseUrl ?? "https://api.doublefeel.cn"
guard let url = URL(
string: baseUrl + "/client/doublefeel/theme/v2/watch_theme/active/"
) else {
return nil
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue(token, forHTTPHeaderField: "access_token")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
return nil
}
return try JSONDecoder().decode(WatchThemeModel.self, from: data)
} catch {
return nil
}
}
private static func loginInfo() -> UserInfoModel? {
guard let data = AppGroupConstants.defaults?.data(
forKey: AppGroupConstants.Key.myUserInfo
) else {
return nil
}
return try? JSONDecoder().decode(UserInfoModel.self, from: data)
}
}
extension WatchLocalTheme {
func faceContent(for status: DFStressStatus) -> (title: String, image: Image?) {
let title: String
let data: Data?
switch status {
case .excellent:
title = energeticDescription
data = energeticImage
case .normal:
title = normalDescription
data = normalImage
case .little:
title = slightStressfulDescription
data = slightStressfulImage
case .overloaded:
title = stressfulDescription
data = stressfulImage
}
guard let data, let uiImage = UIImage(data: data) else {
return (title, nil)
}
return (title, Image(uiImage: uiImage))
}
}
... ...
... ... @@ -11,8 +11,13 @@ struct DFSingleStressFaceView: View {
var body: some View {
GeometryReader { proxy in
let scale = min(proxy.size.width / 172, proxy.size.height / 74)
let content = entry.theme?.faceContent(for: entry.data.myStatus)
HStack(spacing: 10 * scale) {
DFStatusOrb(status: entry.data.myStatus, value: entry.data.hasData ? "\(entry.data.hrvValue)" : "--")
DFStatusOrb(
status: entry.data.myStatus,
value: entry.data.hasData ? "\(entry.data.hrvValue)" : "--",
themeImage: content?.image
)
.frame(width: 54 * scale, height: 54 * scale)
VStack(alignment: .leading, spacing: 5 * scale) {
... ... @@ -25,7 +30,7 @@ struct DFSingleStressFaceView: View {
.font(.system(size: 10 * scale, weight: .medium))
.foregroundStyle(.white)
}
Text(entry.data.hasData ? "\(entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "等待数据")
Text(entry.data.hasData ? "\(content?.title ?? entry.data.myStatus.title) · \(entry.data.hrvValue)ms" : "等待数据")
.font(.system(size: 14 * scale, weight: .semibold))
.foregroundStyle(entry.data.hasData ? entry.data.myStatus.color : DFWatchFaceColor.grayText)
.lineLimit(1)
... ...
... ... @@ -12,7 +12,6 @@ import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:logger/logger.dart';
import '../../friends/models/friend_health_data.dart';
import '../../friends/views/select_friend_view.dart';
... ... @@ -164,7 +163,9 @@ class CustomWatchThemePreviewController extends GetxController {
}
try {
await WearEngineHostApi().addWatchSurface();
} catch (e) {}
} catch (_) {
// Opening the Watch app still helps the user install the watch face.
}
await PlatformHostApi().nativeHandleUrl("itms-watchs://");
}
... ... @@ -217,7 +218,8 @@ class CustomWatchThemePreviewController extends GetxController {
}
syncSuccess = success;
selectedThemeItem.value = themeItem;
return false;
isApplying.value = false;
return true;
}
syncSuccess = success;
} catch (_) {
... ...
... ... @@ -135,7 +135,7 @@ class WatchThemePreviewController extends GetxController {
try {
await WearEngineHostApi().addWatchSurface();
} catch (e) {
// Opening the Watch app still helps the user install the watch face.
}
await PlatformHostApi().nativeHandleUrl("itms-watchs://");
}
... ... @@ -189,7 +189,8 @@ class WatchThemePreviewController extends GetxController {
}
syncSuccess = success;
selectedThemeItem.value = themeItem;
return false;
isApplying.value = false;
return true;
}
syncSuccess = success;
} catch (_) {
... ...