feat: 完善饮水 widget
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SimpleEntry> {
|
||||
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<ConfigurationAppIntent> {
|
||||
// // 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))
|
||||
}
|
||||
|
||||
39
ios/digitalpilates/AppGroupUserDefaults.m
Normal file
39
ios/digitalpilates/AppGroupUserDefaults.m
Normal 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
|
||||
111
ios/digitalpilates/AppGroupUserDefaults.swift
Normal file
111
ios/digitalpilates/AppGroupUserDefaults.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
ios/digitalpilates/WaterRecordManager.m
Normal file
16
ios/digitalpilates/WaterRecordManager.m
Normal 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
|
||||
81
ios/digitalpilates/WaterRecordManager.swift
Normal file
81
ios/digitalpilates/WaterRecordManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
ios/digitalpilates/WidgetManager.m
Normal file
18
ios/digitalpilates/WidgetManager.m
Normal 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
|
||||
46
ios/digitalpilates/WidgetManager.swift
Normal file
46
ios/digitalpilates/WidgetManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,17 +101,17 @@ async function fetchSleepSamples(date: Date): Promise<SleepSample[]> {
|
||||
|
||||
// 添加详细日志,了解实际获取到的数据类型
|
||||
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<SleepDetailDa
|
||||
|
||||
// 找到入睡时间和起床时间
|
||||
// 过滤出实际睡眠阶段(排除在床时间和清醒时间)
|
||||
const actualSleepSamples = sleepSamples.filter(sample =>
|
||||
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<SleepDetailDa
|
||||
// 计算在床时间 - 使用 INBED 样本数据
|
||||
let timeInBed: number;
|
||||
const inBedSamples = sleepSamples.filter(sample => 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<SleepDetailDa
|
||||
// 计算睡眠阶段统计
|
||||
const sleepStages = calculateSleepStageStats(sleepSamples);
|
||||
|
||||
// 计算总睡眠时间(排除清醒时间)
|
||||
// 计算总睡眠时间
|
||||
const totalSleepTime = sleepStages
|
||||
.filter(stage => 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');
|
||||
}
|
||||
|
||||
101
services/waterRecordBridge.ts
Normal file
101
services/waterRecordBridge.ts
Normal 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
284
utils/widgetDataSync.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user