From e56ebe363651978d59aa992d78d1a79888f21baa Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 9 Sep 2025 14:26:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E9=A5=AE=E6=B0=B4=20?= =?UTF-8?q?widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 33 ++ hooks/useWaterData.ts | 57 +++- ios/WaterWidget/AppIntent.swift | 68 ++++- ios/WaterWidget/WaterWidget.swift | 142 +++++++-- ios/digitalpilates/AppGroupUserDefaults.m | 39 +++ ios/digitalpilates/AppGroupUserDefaults.swift | 111 +++++++ ios/digitalpilates/WaterRecordManager.m | 16 + ios/digitalpilates/WaterRecordManager.swift | 81 +++++ ios/digitalpilates/WidgetManager.m | 18 ++ ios/digitalpilates/WidgetManager.swift | 46 +++ services/sleepService.ts | 50 ++- services/waterRecordBridge.ts | 101 +++++++ utils/widgetDataSync.ts | 284 ++++++++++++++++++ 13 files changed, 984 insertions(+), 62 deletions(-) create mode 100644 ios/digitalpilates/AppGroupUserDefaults.m create mode 100644 ios/digitalpilates/AppGroupUserDefaults.swift create mode 100644 ios/digitalpilates/WaterRecordManager.m create mode 100644 ios/digitalpilates/WaterRecordManager.swift create mode 100644 ios/digitalpilates/WidgetManager.m create mode 100644 ios/digitalpilates/WidgetManager.swift create mode 100644 services/waterRecordBridge.ts create mode 100644 utils/widgetDataSync.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 5b4830c..f601a76 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,9 +12,13 @@ import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { notificationService } from '@/services/notifications'; import { setupQuickActions } from '@/services/quickActions'; +import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; +import { WaterRecordSource } from '@/services/waterRecords'; import { store } from '@/store'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; +import { createWaterRecordAction } from '@/store/waterSlice'; import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; +import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React from 'react'; import RNExitApp from 'react-native-exit-app'; @@ -49,6 +53,35 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { // 初始化快捷动作 await setupQuickActions(); console.log('快捷动作初始化成功'); + + // 初始化喝水记录 bridge + initializeWaterRecordBridge(); + console.log('喝水记录 Bridge 初始化成功'); + + // 检查并同步Widget数据更改 + const widgetSync = await syncPendingWidgetChanges(); + if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) { + console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`); + + // 将待同步的记录添加到 Redux store + for (const record of widgetSync.pendingRecords) { + try { + await store.dispatch(createWaterRecordAction({ + amount: record.amount, + recordedAt: record.recordedAt, + source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget) + })).unwrap(); + + console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`); + } catch (error) { + console.error('同步水记录失败:', error); + } + } + + // 清除已同步的记录 + await clearPendingWaterRecords(); + console.log('所有待同步的水记录已处理完成'); + } } catch (error) { console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error); } diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts index 9d55420..02320de 100644 --- a/hooks/useWaterData.ts +++ b/hooks/useWaterData.ts @@ -12,6 +12,8 @@ import { } from '@/store/waterSlice'; import { Toast } from '@/utils/toast.utils'; import { saveWaterIntakeToHealthKit, deleteWaterIntakeFromHealthKit } from '@/utils/health'; +import { syncWaterDataToWidget, refreshWidget } from '@/utils/widgetDataSync'; +import { getQuickWaterAmount } from '@/utils/userPreferences'; import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -101,6 +103,25 @@ export const useWaterData = () => { // 重新获取今日统计 dispatch(fetchTodayWaterStats()); + + // 同步数据到Widget + try { + const newCurrentIntake = (todayStats?.totalAmount || 0) + amount; + const quickAddAmount = await getQuickWaterAmount(); + + await syncWaterDataToWidget({ + currentIntake: newCurrentIntake, + dailyGoal: dailyWaterGoal || 2000, + quickAddAmount, + }); + + // 刷新Widget + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 同步错误:', widgetError); + // Widget 同步失败不影响主要功能 + } + return true; } catch (error: any) { console.error('添加喝水记录失败:', error); @@ -200,6 +221,20 @@ export const useWaterData = () => { const updateWaterGoal = useCallback(async (goal: number) => { try { await dispatch(updateWaterGoalAction(goal)).unwrap(); + + // 同步目标到Widget + try { + const quickAddAmount = await getQuickWaterAmount(); + await syncWaterDataToWidget({ + dailyGoal: goal, + currentIntake: todayStats?.totalAmount || 0, + quickAddAmount, + }); + await refreshWidget(); + } catch (widgetError) { + console.error('Widget 目标同步错误:', widgetError); + } + return true; } catch (error: any) { console.error('更新喝水目标失败:', error); @@ -212,7 +247,7 @@ export const useWaterData = () => { Toast.error(errorMessage); return false; } - }, [dispatch]); + }, [dispatch, todayStats]); // 计算总喝水量 const getTotalAmount = useCallback((records: any[]) => { @@ -249,6 +284,26 @@ export const useWaterData = () => { loadTodayData(); }, [loadTodayData]); + // 同步初始数据到Widget + useEffect(() => { + const syncInitialDataToWidget = async () => { + if (todayStats && dailyWaterGoal) { + try { + const quickAddAmount = await getQuickWaterAmount(); + await syncWaterDataToWidget({ + currentIntake: todayStats.totalAmount, + dailyGoal: dailyWaterGoal, + quickAddAmount, + }); + } catch (error) { + console.error('初始Widget数据同步失败:', error); + } + } + }; + + syncInitialDataToWidget(); + }, [todayStats, dailyWaterGoal]); + return { // 数据 todayStats, diff --git a/ios/WaterWidget/AppIntent.swift b/ios/WaterWidget/AppIntent.swift index a98c787..9eae5c4 100644 --- a/ios/WaterWidget/AppIntent.swift +++ b/ios/WaterWidget/AppIntent.swift @@ -7,12 +7,68 @@ import WidgetKit import AppIntents +import Foundation struct ConfigurationAppIntent: WidgetConfigurationIntent { - static var title: LocalizedStringResource { "Configuration" } - static var description: IntentDescription { "This is an example widget." } - - // An example configurable parameter. - @Parameter(title: "Favorite Emoji", default: "😃") - var favoriteEmoji: String + static var title: LocalizedStringResource { "Water Widget Configuration" } + static var description: IntentDescription { "Configure water intake widget settings." } +} + +// Intent for adding water record +struct AddWaterIntent: AppIntent { + static var title: LocalizedStringResource { "Add Water" } + static var description: IntentDescription { "Add water intake record" } + + @Parameter(title: "Amount (ml)") + var amount: Int + + init(amount: Int) { + self.amount = amount + } + + init() { + self.amount = 150 // default value + } + + func perform() async throws -> some IntentResult { + // Add water directly through shared UserDefaults + guard let sharedDefaults = UserDefaults(suiteName: "group.com.anonymous.digitalpilates") else { + print("Failed to access App Group UserDefaults") + return .result() + } + + // Get current values + let currentIntake = sharedDefaults.object(forKey: "widget_current_water_intake") as? Int ?? 0 + let targetIntake = sharedDefaults.object(forKey: "widget_daily_water_goal") as? Int ?? 2000 + + // Update current intake + let newIntake = currentIntake + amount + sharedDefaults.set(newIntake, forKey: "widget_current_water_intake") + + // Create ISO8601 formatted date string + let formatter = ISO8601DateFormatter() + let isoString = formatter.string(from: Date()) + sharedDefaults.set(isoString, forKey: "widget_last_sync_time") + sharedDefaults.synchronize() + + print("Water added from widget: +\(amount)ml, New total: \(newIntake)ml") + + // 设置待同步标记,主应用启动时会检测并同步到 TypeScript/Redux + let pendingRecords = sharedDefaults.array(forKey: "widget_pending_water_records") as? [[String: Any]] ?? [] + let newRecord: [String: Any] = [ + "amount": amount, + "recordedAt": isoString, + "source": "auto", + "widgetAdded": true + ] + let updatedRecords = pendingRecords + [newRecord] + sharedDefaults.set(updatedRecords, forKey: "widget_pending_water_records") + + // Trigger widget refresh + WidgetCenter.shared.reloadTimelines(ofKind: "WaterWidget") + + print("水记录已添加到待同步队列,主应用启动时将同步到 Redux Store") + + return .result() + } } diff --git a/ios/WaterWidget/WaterWidget.swift b/ios/WaterWidget/WaterWidget.swift index 8cc571c..e02e5a8 100644 --- a/ios/WaterWidget/WaterWidget.swift +++ b/ios/WaterWidget/WaterWidget.swift @@ -8,50 +8,139 @@ import WidgetKit import SwiftUI +// Data model for water intake +struct WaterData { + let currentIntake: Int + let targetIntake: Int + let quickAddAmount: Int + let progressPercentage: Double + + init(currentIntake: Int = 0, targetIntake: Int = 2000, quickAddAmount: Int = 150) { + self.currentIntake = currentIntake + self.targetIntake = targetIntake + self.quickAddAmount = quickAddAmount + self.progressPercentage = min(Double(currentIntake) / Double(targetIntake), 1.0) + } +} + struct Provider: AppIntentTimelineProvider { func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), configuration: ConfigurationAppIntent()) + SimpleEntry(date: Date(), configuration: ConfigurationAppIntent(), waterData: WaterData()) } func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { - SimpleEntry(date: Date(), configuration: configuration) + // In a real app, you would fetch the actual water data here + let waterData = await fetchWaterData() + return SimpleEntry(date: Date(), configuration: configuration, waterData: waterData) } func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. let currentDate = Date() + + // Fetch current water data + let waterData = await fetchWaterData() + + // Generate timeline entries for every hour for the next 5 hours for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, configuration: configuration) + let entry = SimpleEntry(date: entryDate, configuration: configuration, waterData: waterData) entries.append(entry) } - return Timeline(entries: entries, policy: .atEnd) + // Refresh every 15 minutes to keep data current + return Timeline(entries: entries, policy: .after(Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!)) + } + + // Fetch water data from shared App Group storage + private func fetchWaterData() async -> WaterData { + guard let sharedDefaults = UserDefaults(suiteName: "group.com.anonymous.digitalpilates") else { + print("Failed to access App Group UserDefaults") + return WaterData() // Return default data + } + + // Read data using the same keys as defined in widgetDataSync.ts + let currentIntake = sharedDefaults.object(forKey: "widget_current_water_intake") as? Int ?? 0 + let targetIntake = sharedDefaults.object(forKey: "widget_daily_water_goal") as? Int ?? 2000 + let quickAddAmount = sharedDefaults.object(forKey: "widget_quick_add_amount") as? Int ?? 150 + + print("Widget data loaded - Current: \(currentIntake)ml, Target: \(targetIntake)ml, Quick: \(quickAddAmount)ml") + + return WaterData( + currentIntake: currentIntake, + targetIntake: targetIntake, + quickAddAmount: quickAddAmount + ) } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationAppIntent + let waterData: WaterData } struct WaterWidgetEntryView : View { var entry: Provider.Entry var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Favorite Emoji:") - Text(entry.configuration.favoriteEmoji) + VStack(spacing: 0) { + // Header with title and add button + HStack { + Text("喝水") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color(red: 0.098, green: 0.129, blue: 0.149)) + + Spacer() + + // Quick add water button + Button(intent: AddWaterIntent(amount: entry.waterData.quickAddAmount)) { + HStack(spacing: 2) { + Text("+") + Text("\(entry.waterData.quickAddAmount)ml") + } + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Color(red: 0.388, green: 0.4, blue: 0.945)) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background(Color(red: 0.882, green: 0.906, blue: 1.0)) + .cornerRadius(16) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.bottom, 8) + + Spacer() + + // Progress visualization - simplified bar chart representation + HStack(spacing: 2) { + ForEach(0..<12, id: \.self) { index in + RoundedRectangle(cornerRadius: 1) + .fill(index < Int(entry.waterData.progressPercentage * 12) ? + Color(red: 0.49, green: 0.827, blue: 0.988) : + Color(red: 0.941, green: 0.976, blue: 1.0)) + .frame(width: 3, height: 12) + } + } + .padding(.bottom, 8) + + // Water intake stats + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text("\(entry.waterData.currentIntake)") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color(red: 0.098, green: 0.129, blue: 0.149)) + + Text("ml") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color(red: 0.098, green: 0.129, blue: 0.149)) + + Text("/ \(entry.waterData.targetIntake)ml") + .font(.system(size: 12)) + .foregroundColor(Color(red: 0.42, green: 0.447, blue: 0.502)) + } } + .padding(16) + .containerBackground(.fill.tertiary, for: .widget) } } @@ -61,28 +150,23 @@ struct WaterWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in WaterWidgetEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) } + .configurationDisplayName("饮水记录") + .description("追踪你的每日饮水量,快速添加饮水记录") + .supportedFamilies([.systemSmall]) } } extension ConfigurationAppIntent { - fileprivate static var smiley: ConfigurationAppIntent { - let intent = ConfigurationAppIntent() - intent.favoriteEmoji = "😀" - return intent - } - - fileprivate static var starEyes: ConfigurationAppIntent { - let intent = ConfigurationAppIntent() - intent.favoriteEmoji = "🤩" - return intent + fileprivate static var defaultConfig: ConfigurationAppIntent { + return ConfigurationAppIntent() } } #Preview(as: .systemSmall) { WaterWidget() } timeline: { - SimpleEntry(date: .now, configuration: .smiley) - SimpleEntry(date: .now, configuration: .starEyes) + SimpleEntry(date: .now, configuration: .defaultConfig, waterData: WaterData(currentIntake: 500, targetIntake: 2000, quickAddAmount: 150)) + SimpleEntry(date: .now, configuration: .defaultConfig, waterData: WaterData(currentIntake: 1200, targetIntake: 2000, quickAddAmount: 200)) + SimpleEntry(date: .now, configuration: .defaultConfig, waterData: WaterData(currentIntake: 1800, targetIntake: 2000, quickAddAmount: 150)) } diff --git a/ios/digitalpilates/AppGroupUserDefaults.m b/ios/digitalpilates/AppGroupUserDefaults.m new file mode 100644 index 0000000..efd4a5d --- /dev/null +++ b/ios/digitalpilates/AppGroupUserDefaults.m @@ -0,0 +1,39 @@ +// +// AppGroupUserDefaults.m +// digitalpilates +// +// Objective-C bridge file for AppGroupUserDefaults Swift module +// + +#import + +@interface RCT_EXTERN_MODULE(AppGroupUserDefaults, NSObject) + +RCT_EXTERN_METHOD(setString:(NSString *)groupId + key:(NSString *)key + value:(NSString *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getString:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(setNumber:(NSString *)groupId + key:(NSString *)key + value:(NSNumber *)value + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getNumber:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(removeKey:(NSString *)groupId + key:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end \ No newline at end of file diff --git a/ios/digitalpilates/AppGroupUserDefaults.swift b/ios/digitalpilates/AppGroupUserDefaults.swift new file mode 100644 index 0000000..abf7010 --- /dev/null +++ b/ios/digitalpilates/AppGroupUserDefaults.swift @@ -0,0 +1,111 @@ +// +// AppGroupUserDefaults.swift +// digitalpilates +// +// Native module for accessing App Group UserDefaults +// Allows sharing data between main app and widget +// + +import Foundation +import React + +@objc(AppGroupUserDefaults) +class AppGroupUserDefaults: NSObject, RCTBridgeModule { + + static func moduleName() -> String! { + return "AppGroupUserDefaults" + } + + static func requiresMainQueueSetup() -> Bool { + return false + } + + // MARK: - String Methods + + @objc + func setString(_ groupId: String, key: String, value: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .background).async { + guard let userDefaults = UserDefaults(suiteName: groupId) else { + rejecter("ERROR", "Failed to create UserDefaults with suite name: \(groupId)", nil) + return + } + + userDefaults.set(value, forKey: key) + userDefaults.synchronize() + + DispatchQueue.main.async { + resolver(nil) + } + } + } + + @objc + func getString(_ groupId: String, key: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .background).async { + guard let userDefaults = UserDefaults(suiteName: groupId) else { + rejecter("ERROR", "Failed to create UserDefaults with suite name: \(groupId)", nil) + return + } + + let value = userDefaults.string(forKey: key) + + DispatchQueue.main.async { + resolver(value) + } + } + } + + // MARK: - Number Methods + + @objc + func setNumber(_ groupId: String, key: String, value: NSNumber, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .background).async { + guard let userDefaults = UserDefaults(suiteName: groupId) else { + rejecter("ERROR", "Failed to create UserDefaults with suite name: \(groupId)", nil) + return + } + + userDefaults.set(value, forKey: key) + userDefaults.synchronize() + + DispatchQueue.main.async { + resolver(nil) + } + } + } + + @objc + func getNumber(_ groupId: String, key: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .background).async { + guard let userDefaults = UserDefaults(suiteName: groupId) else { + rejecter("ERROR", "Failed to create UserDefaults with suite name: \(groupId)", nil) + return + } + + let value = userDefaults.object(forKey: key) as? NSNumber ?? NSNumber(value: 0) + + DispatchQueue.main.async { + resolver(value) + } + } + } + + // MARK: - Remove Key Method + + @objc + func removeKey(_ groupId: String, key: String, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .background).async { + guard let userDefaults = UserDefaults(suiteName: groupId) else { + rejecter("ERROR", "Failed to create UserDefaults with suite name: \(groupId)", nil) + return + } + + userDefaults.removeObject(forKey: key) + userDefaults.synchronize() + + DispatchQueue.main.async { + resolver(nil) + } + } + } +} \ No newline at end of file diff --git a/ios/digitalpilates/WaterRecordManager.m b/ios/digitalpilates/WaterRecordManager.m new file mode 100644 index 0000000..9bbd5f1 --- /dev/null +++ b/ios/digitalpilates/WaterRecordManager.m @@ -0,0 +1,16 @@ +// +// WaterRecordManager.m +// digitalpilates +// +// Objective-C bridge file for WaterRecordManager Swift module +// + +#import + +@interface RCT_EXTERN_MODULE(WaterRecordManager, NSObject) + +RCT_EXTERN_METHOD(addWaterRecord:(int)amount + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end \ No newline at end of file diff --git a/ios/digitalpilates/WaterRecordManager.swift b/ios/digitalpilates/WaterRecordManager.swift new file mode 100644 index 0000000..b29bef3 --- /dev/null +++ b/ios/digitalpilates/WaterRecordManager.swift @@ -0,0 +1,81 @@ +// +// WaterRecordManager.swift +// digitalpilates +// +// Native module for managing water records through React Native bridge +// + +import Foundation +import React +import WidgetKit + +@objc(WaterRecordManager) +class WaterRecordManager: NSObject, RCTBridgeModule { + + static func moduleName() -> String! { + return "WaterRecordManager" + } + + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func addWaterRecord(_ amount: Int, resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + + // 更新 App Group UserDefaults(用于 Widget 显示) + guard let sharedDefaults = UserDefaults(suiteName: "group.com.anonymous.digitalpilates") else { + rejecter("USERDEFAULTS_ERROR", "Failed to access App Group UserDefaults", nil) + return + } + + let currentIntake = sharedDefaults.object(forKey: "widget_current_water_intake") as? Int ?? 0 + let newIntake = currentIntake + amount + sharedDefaults.set(newIntake, forKey: "widget_current_water_intake") + + // 创建 ISO8601 格式的日期字符串 + let formatter = ISO8601DateFormatter() + let isoString = formatter.string(from: Date()) + sharedDefaults.set(isoString, forKey: "widget_last_sync_time") + sharedDefaults.synchronize() + + print("Water record added via bridge: +\(amount)ml, New total: \(newIntake)ml") + + // 通过 React Native bridge 调用 TypeScript 代码来创建记录 + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.bridge else { + rejecter("BRIDGE_ERROR", "React Native bridge is not available", nil) + return + } + + // 调用 TypeScript 中的 Redux action 来创建喝水记录 + let eventData = [ + "amount": amount, + "recordedAt": isoString, + "source": "auto" // 标记为来自 Widget/自动添加 + ] + + // 发送事件给 TypeScript 代码 + self?.sendEvent(withName: "WaterRecordAdded", body: eventData) + + // 刷新 Widget + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadTimelines(ofKind: "WaterWidget") + } + + resolver(["success": true, "amount": amount, "newTotal": newIntake]) + } + } + + // MARK: - Event Emitter + + override func supportedEvents() -> [String]! { + return ["WaterRecordAdded"] + } + + private func sendEvent(withName name: String, body: Any) { + if let bridge = self.bridge { + bridge.eventDispatcher().sendAppEvent(withName: name, body: body) + } + } +} \ No newline at end of file diff --git a/ios/digitalpilates/WidgetManager.m b/ios/digitalpilates/WidgetManager.m new file mode 100644 index 0000000..7057a94 --- /dev/null +++ b/ios/digitalpilates/WidgetManager.m @@ -0,0 +1,18 @@ +// +// WidgetManager.m +// digitalpilates +// +// Objective-C bridge file for WidgetManager Swift module +// + +#import + +@interface RCT_EXTERN_MODULE(WidgetManager, NSObject) + +RCT_EXTERN_METHOD(reloadTimelines:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(reloadAllTimelines:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end \ No newline at end of file diff --git a/ios/digitalpilates/WidgetManager.swift b/ios/digitalpilates/WidgetManager.swift new file mode 100644 index 0000000..e78d7de --- /dev/null +++ b/ios/digitalpilates/WidgetManager.swift @@ -0,0 +1,46 @@ +// +// WidgetManager.swift +// digitalpilates +// +// Native module for managing widget refresh +// + +import Foundation +import React +import WidgetKit + +@objc(WidgetManager) +class WidgetManager: NSObject, RCTBridgeModule { + + static func moduleName() -> String! { + return "WidgetManager" + } + + static func requiresMainQueueSetup() -> Bool { + return false + } + + @objc + func reloadTimelines(_ resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadTimelines(ofKind: "WaterWidget") + resolver(nil) + } else { + rejecter("UNSUPPORTED", "WidgetKit is only available on iOS 14.0 and later", nil) + } + } + } + + @objc + func reloadAllTimelines(_ resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + resolver(nil) + } else { + rejecter("UNSUPPORTED", "WidgetKit is only available on iOS 14.0 and later", nil) + } + } + } +} \ No newline at end of file diff --git a/services/sleepService.ts b/services/sleepService.ts index d44087e..4adaae9 100644 --- a/services/sleepService.ts +++ b/services/sleepService.ts @@ -101,17 +101,17 @@ async function fetchSleepSamples(date: Date): Promise { // 添加详细日志,了解实际获取到的数据类型 console.log('获取到睡眠样本:', results.length); - console.log('睡眠样本详情:', results.map(r => ({ - value: r.value, - start: r.startDate?.substring(11, 16), + console.log('睡眠样本详情:', results.map(r => ({ + value: r.value, + start: r.startDate?.substring(11, 16), end: r.endDate?.substring(11, 16), duration: `${Math.round((new Date(r.endDate).getTime() - new Date(r.startDate).getTime()) / 60000)}min` }))); - + // 检查可用的睡眠阶段类型 const uniqueValues = [...new Set(results.map(r => r.value))]; console.log('可用的睡眠阶段类型:', uniqueValues); - + resolve(results as unknown as SleepSample[]); }); }); @@ -165,7 +165,6 @@ function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] { // 计算实际睡眠时间(包括所有睡眠阶段,排除在床时间) const actualSleepTime = Array.from(stageMap.entries()) - .filter(([stage]) => stage !== SleepStage.InBed) .reduce((total, [, duration]) => total + duration, 0); // 生成统计数据,包含所有睡眠阶段(包括清醒时间) @@ -322,34 +321,34 @@ export async function fetchSleepDetailForDate(date: Date): Promise + const actualSleepSamples = sleepSamples.filter(sample => sample.value !== SleepStage.InBed && sample.value !== SleepStage.Awake ); - + // 入睡时间:第一个实际睡眠阶段的开始时间 // 起床时间:最后一个实际睡眠阶段的结束时间 let bedtime: string; let wakeupTime: string; - + if (actualSleepSamples.length > 0) { // 按开始时间排序 - const sortedSleepSamples = actualSleepSamples.sort((a, b) => + const sortedSleepSamples = actualSleepSamples.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() ); - + bedtime = sortedSleepSamples[0].startDate; wakeupTime = sortedSleepSamples[sortedSleepSamples.length - 1].endDate; - + console.log('计算入睡和起床时间:'); console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss')); console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss')); } else { // 如果没有实际睡眠数据,回退到使用所有样本数据 console.warn('没有找到实际睡眠阶段数据,使用所有样本数据'); - const sortedAllSamples = sleepSamples.sort((a, b) => + const sortedAllSamples = sleepSamples.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() ); - + bedtime = sortedAllSamples[0].startDate; wakeupTime = sortedAllSamples[sortedAllSamples.length - 1].endDate; } @@ -357,17 +356,17 @@ export async function fetchSleepDetailForDate(date: Date): Promise sample.value === SleepStage.InBed); - + if (inBedSamples.length > 0) { // 使用 INBED 样本计算在床时间 - const sortedInBedSamples = inBedSamples.sort((a, b) => + const sortedInBedSamples = inBedSamples.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() ); - + const inBedStart = sortedInBedSamples[0].startDate; const inBedEnd = sortedInBedSamples[sortedInBedSamples.length - 1].endDate; timeInBed = dayjs(inBedEnd).diff(dayjs(inBedStart), 'minute'); - + console.log('在床时间计算:'); console.log('- 上床时间:', dayjs(inBedStart).format('YYYY-MM-DD HH:mm:ss')); console.log('- 离床时间:', dayjs(inBedEnd).format('YYYY-MM-DD HH:mm:ss')); @@ -381,9 +380,8 @@ export async function fetchSleepDetailForDate(date: Date): Promise stage.stage !== SleepStage.Awake) .reduce((total, stage) => total + stage.duration, 0); // 计算睡眠效率 @@ -450,14 +448,14 @@ export function formatTime(dateString: string): string { // 将睡眠样本数据转换为15分钟间隔的睡眠阶段数据 export function convertSleepSamplesToIntervals(sleepSamples: SleepSample[], bedtime: string, wakeupTime: string): { time: string; stage: SleepStage }[] { const data: { time: string; stage: SleepStage }[] = []; - + if (sleepSamples.length === 0) { console.log('没有睡眠样本数据可用于图表显示'); return []; } // 过滤掉InBed阶段,只保留实际睡眠阶段 - const sleepOnlySamples = sleepSamples.filter(sample => + const sleepOnlySamples = sleepSamples.filter(sample => sample.value !== SleepStage.InBed ); @@ -476,14 +474,14 @@ export function convertSleepSamplesToIntervals(sleepSamples: SleepSample[], bedt // 创建一个映射,用于快速查找每个时间点的睡眠阶段 while (currentTime.isBefore(endTime)) { const currentTimestamp = currentTime.toDate().getTime(); - + // 找到当前时间点对应的睡眠阶段 let currentStage = SleepStage.Awake; // 默认为清醒 - + for (const sample of sleepOnlySamples) { const sampleStart = new Date(sample.startDate).getTime(); const sampleEnd = new Date(sample.endDate).getTime(); - + // 如果当前时间在这个样本的时间范围内 if (currentTimestamp >= sampleStart && currentTimestamp < sampleEnd) { currentStage = sample.value; @@ -493,7 +491,7 @@ export function convertSleepSamplesToIntervals(sleepSamples: SleepSample[], bedt const timeStr = currentTime.format('HH:mm'); data.push({ time: timeStr, stage: currentStage }); - + // 移动到下一个15分钟间隔 currentTime = currentTime.add(15, 'minute'); } diff --git a/services/waterRecordBridge.ts b/services/waterRecordBridge.ts new file mode 100644 index 0000000..a7dbb7d --- /dev/null +++ b/services/waterRecordBridge.ts @@ -0,0 +1,101 @@ +import { NativeModules, NativeEventEmitter, EmitterSubscription } from 'react-native'; +import { createWaterRecordAction } from '@/store/waterSlice'; +import { store } from '@/store'; +import { WaterRecordSource } from './waterRecords'; + +// Native Module 接口定义 +interface WaterRecordManagerInterface { + addWaterRecord(amount: number): Promise<{ + success: boolean; + amount: number; + newTotal: number; + }>; +} + +// 获取原生模块 +const WaterRecordManager: WaterRecordManagerInterface = NativeModules.WaterRecordManager; + +// 创建事件发射器 +const waterRecordEventEmitter = NativeModules.WaterRecordManager + ? new NativeEventEmitter(NativeModules.WaterRecordManager) + : null; + +// 事件监听器引用 +let eventSubscription: EmitterSubscription | null = null; + +// 事件数据接口 +interface WaterRecordEventData { + amount: number; + recordedAt: string; + source: string; +} + +// 初始化事件监听器 +export function initializeWaterRecordBridge(): void { + if (!waterRecordEventEmitter) { + console.warn('WaterRecordManager 原生模块不可用,跳过事件监听器初始化'); + return; + } + + if (eventSubscription) { + // 如果已经初始化,先清理 + eventSubscription.remove(); + } + + // 监听来自原生模块的事件 + eventSubscription = waterRecordEventEmitter.addListener( + 'WaterRecordAdded', + (eventData: WaterRecordEventData) => { + console.log('收到来自 Swift 的喝水记录事件:', eventData); + + // 通过 Redux 创建喝水记录 + store.dispatch(createWaterRecordAction({ + amount: eventData.amount, + recordedAt: eventData.recordedAt, + source: WaterRecordSource.Auto, // 标记为自动添加 + })); + } + ); + + console.log('WaterRecordBridge 初始化完成'); +} + +// 清理事件监听器 +export function cleanupWaterRecordBridge(): void { + if (eventSubscription) { + eventSubscription.remove(); + eventSubscription = null; + } +} + +// 供原生代码调用的添加喝水记录方法 +export async function addWaterRecordFromNative(amount: number): Promise<{ + success: boolean; + amount: number; + newTotal: number; +}> { + try { + if (!WaterRecordManager) { + throw new Error('WaterRecordManager 原生模块不可用'); + } + + const result = await WaterRecordManager.addWaterRecord(amount); + console.log('通过 Bridge 添加喝水记录成功:', result); + return result; + } catch (error) { + console.error('通过 Bridge 添加喝水记录失败:', error); + throw error; + } +} + +// 检查原生模块是否可用 +export function isWaterRecordBridgeAvailable(): boolean { + return WaterRecordManager && typeof WaterRecordManager.addWaterRecord === 'function'; +} + +export default { + initializeWaterRecordBridge, + cleanupWaterRecordBridge, + addWaterRecordFromNative, + isWaterRecordBridgeAvailable, +}; \ No newline at end of file diff --git a/utils/widgetDataSync.ts b/utils/widgetDataSync.ts new file mode 100644 index 0000000..e0d2c4b --- /dev/null +++ b/utils/widgetDataSync.ts @@ -0,0 +1,284 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { NativeModules } from 'react-native'; + +// Widget数据同步服务 +// 用于在主App和iOS Widget之间同步饮水数据 + +// App Group标识符 +const APP_GROUP_ID = 'group.com.anonymous.digitalpilates'; + +// Widget数据存储键 +const WIDGET_DATA_KEYS = { + CURRENT_WATER_INTAKE: 'widget_current_water_intake', + DAILY_WATER_GOAL: 'widget_daily_water_goal', + QUICK_ADD_AMOUNT: 'widget_quick_add_amount', + LAST_SYNC_TIME: 'widget_last_sync_time', + PENDING_WATER_RECORDS: 'widget_pending_water_records', +} as const; + +export interface WidgetWaterData { + currentIntake: number; + dailyGoal: number; + quickAddAmount: number; + lastSyncTime: string; +} + +// 默认数据 +const DEFAULT_WIDGET_DATA: WidgetWaterData = { + currentIntake: 0, + dailyGoal: 2000, + quickAddAmount: 150, + lastSyncTime: new Date().toISOString(), +}; + +/** + * 创建Native Module来访问App Group UserDefaults + * 这需要在iOS原生代码中实现 + */ +interface AppGroupUserDefaults { + setString: (groupId: string, key: string, value: string) => Promise; + getString: (groupId: string, key: string) => Promise; + setNumber: (groupId: string, key: string, value: number) => Promise; + getNumber: (groupId: string, key: string) => Promise; + removeKey: (groupId: string, key: string) => Promise; + setArray: (groupId: string, key: string, value: any[]) => Promise; + getArray: (groupId: string, key: string) => Promise; +} + +// Widget待同步水记录接口 +export interface PendingWaterRecord { + amount: number; + recordedAt: string; + source: string; + widgetAdded: boolean; +} + +// 尝试获取原生模块,如果不存在则使用fallback +const AppGroupDefaults: AppGroupUserDefaults | null = NativeModules.AppGroupUserDefaults || null; + +/** + * 将饮水数据同步到Widget + */ +export const syncWaterDataToWidget = async (data: Partial): Promise => { + try { + if (!AppGroupDefaults) { + console.warn('AppGroupUserDefaults native module not available, falling back to AsyncStorage'); + // Fallback到AsyncStorage (仅主App可用) + const currentData = await getWidgetDataFromAsyncStorage(); + const updatedData = { ...currentData, ...data, lastSyncTime: new Date().toISOString() }; + await saveWidgetDataToAsyncStorage(updatedData); + return; + } + + // 使用App Group UserDefaults + if (data.currentIntake !== undefined) { + await AppGroupDefaults.setNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.CURRENT_WATER_INTAKE, data.currentIntake); + } + + if (data.dailyGoal !== undefined) { + await AppGroupDefaults.setNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.DAILY_WATER_GOAL, data.dailyGoal); + } + + if (data.quickAddAmount !== undefined) { + await AppGroupDefaults.setNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.QUICK_ADD_AMOUNT, data.quickAddAmount); + } + + // 更新同步时间 + await AppGroupDefaults.setString(APP_GROUP_ID, WIDGET_DATA_KEYS.LAST_SYNC_TIME, new Date().toISOString()); + + console.log('Widget data synced successfully:', data); + } catch (error) { + console.error('Failed to sync data to widget:', error); + throw error; + } +}; + +/** + * 从Widget获取饮水数据 + */ +export const getWidgetWaterData = async (): Promise => { + try { + if (!AppGroupDefaults) { + console.warn('AppGroupUserDefaults native module not available, falling back to AsyncStorage'); + return await getWidgetDataFromAsyncStorage(); + } + + // 从App Group UserDefaults读取数据 + const currentIntake = await AppGroupDefaults.getNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.CURRENT_WATER_INTAKE); + const dailyGoal = await AppGroupDefaults.getNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.DAILY_WATER_GOAL); + const quickAddAmount = await AppGroupDefaults.getNumber(APP_GROUP_ID, WIDGET_DATA_KEYS.QUICK_ADD_AMOUNT); + const lastSyncTime = await AppGroupDefaults.getString(APP_GROUP_ID, WIDGET_DATA_KEYS.LAST_SYNC_TIME); + + return { + currentIntake: currentIntake || DEFAULT_WIDGET_DATA.currentIntake, + dailyGoal: dailyGoal || DEFAULT_WIDGET_DATA.dailyGoal, + quickAddAmount: quickAddAmount || DEFAULT_WIDGET_DATA.quickAddAmount, + lastSyncTime: lastSyncTime || DEFAULT_WIDGET_DATA.lastSyncTime, + }; + } catch (error) { + console.error('Failed to get widget data:', error); + return DEFAULT_WIDGET_DATA; + } +}; + +/** + * 清除Widget数据 + */ +export const clearWidgetData = async (): Promise => { + try { + if (!AppGroupDefaults) { + await AsyncStorage.multiRemove(Object.values(WIDGET_DATA_KEYS)); + return; + } + + // 清除App Group UserDefaults中的数据 + await Promise.all([ + AppGroupDefaults.removeKey(APP_GROUP_ID, WIDGET_DATA_KEYS.CURRENT_WATER_INTAKE), + AppGroupDefaults.removeKey(APP_GROUP_ID, WIDGET_DATA_KEYS.DAILY_WATER_GOAL), + AppGroupDefaults.removeKey(APP_GROUP_ID, WIDGET_DATA_KEYS.QUICK_ADD_AMOUNT), + AppGroupDefaults.removeKey(APP_GROUP_ID, WIDGET_DATA_KEYS.LAST_SYNC_TIME), + ]); + + console.log('Widget data cleared successfully'); + } catch (error) { + console.error('Failed to clear widget data:', error); + throw error; + } +}; + +/** + * Fallback: 使用AsyncStorage存储Widget数据 + */ +const saveWidgetDataToAsyncStorage = async (data: WidgetWaterData): Promise => { + const dataToStore = [ + [WIDGET_DATA_KEYS.CURRENT_WATER_INTAKE, data.currentIntake.toString()], + [WIDGET_DATA_KEYS.DAILY_WATER_GOAL, data.dailyGoal.toString()], + [WIDGET_DATA_KEYS.QUICK_ADD_AMOUNT, data.quickAddAmount.toString()], + [WIDGET_DATA_KEYS.LAST_SYNC_TIME, data.lastSyncTime], + ]; + + await AsyncStorage.multiSet(dataToStore); +}; + +/** + * Fallback: 从AsyncStorage读取Widget数据 + */ +const getWidgetDataFromAsyncStorage = async (): Promise => { + const keys = Object.values(WIDGET_DATA_KEYS); + const values = await AsyncStorage.multiGet(keys); + + const data: any = {}; + values.forEach(([key, value]) => { + if (value !== null) { + if (key === WIDGET_DATA_KEYS.LAST_SYNC_TIME) { + data[key] = value; + } else { + data[key] = parseInt(value, 10); + } + } + }); + + return { + currentIntake: data[WIDGET_DATA_KEYS.CURRENT_WATER_INTAKE] || DEFAULT_WIDGET_DATA.currentIntake, + dailyGoal: data[WIDGET_DATA_KEYS.DAILY_WATER_GOAL] || DEFAULT_WIDGET_DATA.dailyGoal, + quickAddAmount: data[WIDGET_DATA_KEYS.QUICK_ADD_AMOUNT] || DEFAULT_WIDGET_DATA.quickAddAmount, + lastSyncTime: data[WIDGET_DATA_KEYS.LAST_SYNC_TIME] || DEFAULT_WIDGET_DATA.lastSyncTime, + }; +}; + +/** + * 触发Widget刷新 + * 通知iOS系统更新Widget + */ +export const refreshWidget = async (): Promise => { + try { + if (NativeModules.WidgetManager?.reloadTimelines) { + await NativeModules.WidgetManager.reloadTimelines(); + console.log('Widget refresh triggered'); + } else { + console.warn('WidgetManager native module not available'); + } + } catch (error) { + console.error('Failed to refresh widget:', error); + } +}; + +/** + * 获取待同步的水记录 + */ +export const getPendingWaterRecords = async (): Promise => { + try { + if (!AppGroupDefaults) { + // Fallback: 从 AsyncStorage 读取 + const pendingRecordsJson = await AsyncStorage.getItem(WIDGET_DATA_KEYS.PENDING_WATER_RECORDS); + return pendingRecordsJson ? JSON.parse(pendingRecordsJson) : []; + } + + // 从 App Group UserDefaults 读取 + const pendingRecords = await AppGroupDefaults.getArray(APP_GROUP_ID, WIDGET_DATA_KEYS.PENDING_WATER_RECORDS); + return pendingRecords || []; + } catch (error) { + console.error('Failed to get pending water records:', error); + return []; + } +}; + +/** + * 清除待同步的水记录 + */ +export const clearPendingWaterRecords = async (): Promise => { + try { + if (!AppGroupDefaults) { + // Fallback: 从 AsyncStorage 清除 + await AsyncStorage.removeItem(WIDGET_DATA_KEYS.PENDING_WATER_RECORDS); + return; + } + + // 从 App Group UserDefaults 清除 + await AppGroupDefaults.removeKey(APP_GROUP_ID, WIDGET_DATA_KEYS.PENDING_WATER_RECORDS); + console.log('Pending water records cleared successfully'); + } catch (error) { + console.error('Failed to clear pending water records:', error); + } +}; + +/** + * 从Widget同步待处理的数据更改到主App + * 检查Widget中的数据更改并同步到主App的数据存储 + */ +export const syncPendingWidgetChanges = async (): Promise<{ + hasPendingChanges: boolean; + pendingRecords?: PendingWaterRecord[]; + lastSyncTime?: string; +}> => { + try { + // 获取待同步的水记录 + const pendingRecords = await getPendingWaterRecords(); + + if (pendingRecords.length > 0) { + console.log(`发现 ${pendingRecords.length} 条待同步的水记录`); + return { + hasPendingChanges: true, + pendingRecords, + lastSyncTime: new Date().toISOString(), + }; + } + + return { hasPendingChanges: false }; + } catch (error) { + console.error('Failed to sync pending widget changes:', error); + return { hasPendingChanges: false }; + } +}; + +/** + * 标记Widget数据已被主App同步 + */ +export const markWidgetDataSynced = async (): Promise => { + try { + const currentTime = new Date().toISOString(); + await AsyncStorage.setItem('last_app_widget_sync', currentTime); + } catch (error) { + console.error('Failed to mark widget data as synced:', error); + } +}; \ No newline at end of file