feat: 完善饮水 widget

This commit is contained in:
richarjiang
2025-09-09 14:26:16 +08:00
parent cacfde064f
commit e56ebe3636
13 changed files with 984 additions and 62 deletions

View File

@@ -12,9 +12,13 @@ import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { backgroundTaskManager } from '@/services/backgroundTaskManager'; import { backgroundTaskManager } from '@/services/backgroundTaskManager';
import { notificationService } from '@/services/notifications'; import { notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions'; import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store'; import { store } from '@/store';
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice'; import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React from 'react'; import React from 'react';
import RNExitApp from 'react-native-exit-app'; import RNExitApp from 'react-native-exit-app';
@@ -49,6 +53,35 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
// 初始化快捷动作 // 初始化快捷动作
await setupQuickActions(); await setupQuickActions();
console.log('快捷动作初始化成功'); 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) { } catch (error) {
console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error); console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error);
} }

View File

@@ -12,6 +12,8 @@ import {
} from '@/store/waterSlice'; } from '@/store/waterSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { saveWaterIntakeToHealthKit, deleteWaterIntakeFromHealthKit } from '@/utils/health'; import { saveWaterIntakeToHealthKit, deleteWaterIntakeFromHealthKit } from '@/utils/health';
import { syncWaterDataToWidget, refreshWidget } from '@/utils/widgetDataSync';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
@@ -101,6 +103,25 @@ export const useWaterData = () => {
// 重新获取今日统计 // 重新获取今日统计
dispatch(fetchTodayWaterStats()); 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; return true;
} catch (error: any) { } catch (error: any) {
console.error('添加喝水记录失败:', error); console.error('添加喝水记录失败:', error);
@@ -200,6 +221,20 @@ export const useWaterData = () => {
const updateWaterGoal = useCallback(async (goal: number) => { const updateWaterGoal = useCallback(async (goal: number) => {
try { try {
await dispatch(updateWaterGoalAction(goal)).unwrap(); 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; return true;
} catch (error: any) { } catch (error: any) {
console.error('更新喝水目标失败:', error); console.error('更新喝水目标失败:', error);
@@ -212,7 +247,7 @@ export const useWaterData = () => {
Toast.error(errorMessage); Toast.error(errorMessage);
return false; return false;
} }
}, [dispatch]); }, [dispatch, todayStats]);
// 计算总喝水量 // 计算总喝水量
const getTotalAmount = useCallback((records: any[]) => { const getTotalAmount = useCallback((records: any[]) => {
@@ -249,6 +284,26 @@ export const useWaterData = () => {
loadTodayData(); loadTodayData();
}, [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 { return {
// 数据 // 数据
todayStats, todayStats,

View File

@@ -7,12 +7,68 @@
import WidgetKit import WidgetKit
import AppIntents import AppIntents
import Foundation
struct ConfigurationAppIntent: WidgetConfigurationIntent { struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" } static var title: LocalizedStringResource { "Water Widget Configuration" }
static var description: IntentDescription { "This is an example widget." } static var description: IntentDescription { "Configure water intake widget settings." }
}
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃") // Intent for adding water record
var favoriteEmoji: String 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()
}
} }

View File

@@ -8,50 +8,139 @@
import WidgetKit import WidgetKit
import SwiftUI 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 { struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry { 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 { 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<SimpleEntry> { func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = [] var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = 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 { for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! 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) 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)!))
} }
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> { // Fetch water data from shared App Group storage
// // Generate a list containing the contexts this widget is relevant in. 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
)
}
} }
struct SimpleEntry: TimelineEntry { struct SimpleEntry: TimelineEntry {
let date: Date let date: Date
let configuration: ConfigurationAppIntent let configuration: ConfigurationAppIntent
let waterData: WaterData
} }
struct WaterWidgetEntryView : View { struct WaterWidgetEntryView : View {
var entry: Provider.Entry var entry: Provider.Entry
var body: some View { var body: some View {
VStack { VStack(spacing: 0) {
Text("Time:") // Header with title and add button
Text(entry.date, style: .time) HStack {
Text("喝水")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color(red: 0.098, green: 0.129, blue: 0.149))
Text("Favorite Emoji:") Spacer()
Text(entry.configuration.favoriteEmoji)
// 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 { var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
WaterWidgetEntryView(entry: entry) WaterWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
} }
.configurationDisplayName("饮水记录")
.description("追踪你的每日饮水量,快速添加饮水记录")
.supportedFamilies([.systemSmall])
} }
} }
extension ConfigurationAppIntent { extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent { fileprivate static var defaultConfig: ConfigurationAppIntent {
let intent = ConfigurationAppIntent() return ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
} }
} }
#Preview(as: .systemSmall) { #Preview(as: .systemSmall) {
WaterWidget() WaterWidget()
} timeline: { } timeline: {
SimpleEntry(date: .now, configuration: .smiley) SimpleEntry(date: .now, configuration: .defaultConfig, waterData: WaterData(currentIntake: 500, targetIntake: 2000, quickAddAmount: 150))
SimpleEntry(date: .now, configuration: .starEyes) 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))
} }

View File

@@ -0,0 +1,39 @@
//
// AppGroupUserDefaults.m
// digitalpilates
//
// Objective-C bridge file for AppGroupUserDefaults Swift module
//
#import <React/RCTBridgeModule.h>
@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

View File

@@ -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)
}
}
}
}

View File

@@ -0,0 +1,16 @@
//
// WaterRecordManager.m
// digitalpilates
//
// Objective-C bridge file for WaterRecordManager Swift module
//
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(WaterRecordManager, NSObject)
RCT_EXTERN_METHOD(addWaterRecord:(int)amount
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,18 @@
//
// WidgetManager.m
// digitalpilates
//
// Objective-C bridge file for WidgetManager Swift module
//
#import <React/RCTBridgeModule.h>
@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

View File

@@ -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)
}
}
}
}

View File

@@ -165,7 +165,6 @@ function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] {
// 计算实际睡眠时间(包括所有睡眠阶段,排除在床时间) // 计算实际睡眠时间(包括所有睡眠阶段,排除在床时间)
const actualSleepTime = Array.from(stageMap.entries()) const actualSleepTime = Array.from(stageMap.entries())
.filter(([stage]) => stage !== SleepStage.InBed)
.reduce((total, [, duration]) => total + duration, 0); .reduce((total, [, duration]) => total + duration, 0);
// 生成统计数据,包含所有睡眠阶段(包括清醒时间) // 生成统计数据,包含所有睡眠阶段(包括清醒时间)
@@ -381,9 +380,8 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
// 计算睡眠阶段统计 // 计算睡眠阶段统计
const sleepStages = calculateSleepStageStats(sleepSamples); const sleepStages = calculateSleepStageStats(sleepSamples);
// 计算总睡眠时间(排除清醒时间) // 计算总睡眠时间
const totalSleepTime = sleepStages const totalSleepTime = sleepStages
.filter(stage => stage.stage !== SleepStage.Awake)
.reduce((total, stage) => total + stage.duration, 0); .reduce((total, stage) => total + stage.duration, 0);
// 计算睡眠效率 // 计算睡眠效率

View File

@@ -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,
};

284
utils/widgetDataSync.ts Normal file
View File

@@ -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<void>;
getString: (groupId: string, key: string) => Promise<string | null>;
setNumber: (groupId: string, key: string, value: number) => Promise<void>;
getNumber: (groupId: string, key: string) => Promise<number>;
removeKey: (groupId: string, key: string) => Promise<void>;
setArray: (groupId: string, key: string, value: any[]) => Promise<void>;
getArray: (groupId: string, key: string) => Promise<any[] | null>;
}
// 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<WidgetWaterData>): Promise<void> => {
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<WidgetWaterData> => {
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<void> => {
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<void> => {
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<WidgetWaterData> => {
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<void> => {
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<PendingWaterRecord[]> => {
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<void> => {
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<void> => {
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);
}
};