Files
digital-pilates/utils/widgetDataSync.ts
richarjiang b0e93eedae feat(ios): 添加用药计划Widget小组件支持
- 创建medicineExtension小组件,支持iOS桌面显示用药计划
- 实现App Group数据共享机制,支持主应用与小组件数据同步
- 添加AppGroupUserDefaultsManager原生模块,提供跨应用数据访问能力
- 添加WidgetManager和WidgetCenterHelper,实现小组件刷新控制
- 在medications页面和Redux store中集成小组件数据同步逻辑
- 支持实时同步今日用药状态(待服用/已服用/已错过)到小组件
- 配置App Group entitlements (group.com.anonymous.digitalpilates)
- 更新Xcode项目配置,添加WidgetKit和SwiftUI框架支持
2025-11-14 08:51:02 +08:00

531 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
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;
// 用药计划Widget数据存储键
const MEDICATION_WIDGET_KEYS = {
MEDICATION_DATA: 'widget_medication_data',
PENDING_MEDICATION_ACTIONS: 'widget_pending_medication_actions',
MEDICATION_LAST_SYNC: 'widget_medication_last_sync',
} 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(),
};
// 用药计划小组件数据接口
export interface WidgetMedicationData {
medications: WidgetMedicationItem[];
lastSyncTime: string;
date: string; // YYYY-MM-DD格式的日期
}
export interface WidgetMedicationItem {
id: string;
name: string;
dosage: string;
scheduledTime: string; // HH:mm格式
status: 'upcoming' | 'taken' | 'missed';
medicationId: string;
recordId?: string;
image?: string; // 图片URI或默认图片资源名
}
// 默认用药数据
const DEFAULT_MEDICATION_WIDGET_DATA: WidgetMedicationData = {
medications: [],
lastSyncTime: new Date().toISOString(),
date: new Date().toISOString().split('T')[0], // 格式化为YYYY-MM-DD
};
/**
* 创建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.AppGroupUserDefaultsManager || null;
// 添加调试日志
if (AppGroupDefaults) {
console.log('✅ AppGroupUserDefaultsManager native module loaded successfully');
} else {
console.warn('⚠️ AppGroupUserDefaultsManager native module not found, will use AsyncStorage fallback');
}
/**
* 将饮水数据同步到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: [string, string][] = [
[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);
}
};
// ==================== 用药计划数据同步 ====================
/**
* 将用药数据同步到Widget
*/
export const syncMedicationDataToWidget = async (data: WidgetMedicationData): Promise<void> => {
try {
console.log('🔄 [Widget Sync] Starting medication data sync...');
console.log('🔄 [Widget Sync] Data to sync:', {
medicationsCount: data.medications.length,
date: data.date,
lastSyncTime: data.lastSyncTime,
medications: data.medications.map(m => ({
name: m.name,
scheduledTime: m.scheduledTime,
status: m.status
}))
});
if (!AppGroupDefaults) {
console.warn('⚠️ [Widget Sync] AppGroupUserDefaults native module not available, falling back to AsyncStorage');
// Fallback到AsyncStorage
const dataJson = JSON.stringify(data);
await AsyncStorage.setItem(MEDICATION_WIDGET_KEYS.MEDICATION_DATA, dataJson);
await AsyncStorage.setItem(MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC, data.lastSyncTime);
console.log('✅ [Widget Sync] Data saved to AsyncStorage (fallback)');
return;
}
// 使用App Group UserDefaults
const dataJson = JSON.stringify(data);
console.log('📝 [Widget Sync] JSON payload length:', dataJson.length);
await AppGroupDefaults.setString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA, dataJson);
await AppGroupDefaults.setString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC, data.lastSyncTime);
console.log('✅ [Widget Sync] Medication widget data synced successfully:', data.medications.length, 'medications');
// 验证数据是否写入成功
const verifyJson = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA);
if (verifyJson) {
console.log('✅ [Widget Sync] Verification successful - data exists in App Group');
} else {
console.warn('⚠️ [Widget Sync] Verification failed - data not found in App Group');
}
} catch (error) {
console.error('❌ [Widget Sync] Failed to sync medication data to widget:', error);
throw error;
}
};
/**
* 从Widget获取用药数据
*/
export const getMedicationDataFromWidget = async (): Promise<WidgetMedicationData> => {
try {
if (!AppGroupDefaults) {
console.warn('AppGroupUserDefaults native module not available, falling back to AsyncStorage');
// Fallback从AsyncStorage读取
const dataJson = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.MEDICATION_DATA);
const lastSyncTime = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC);
if (dataJson) {
const data = JSON.parse(dataJson);
return {
...data,
lastSyncTime: lastSyncTime || DEFAULT_MEDICATION_WIDGET_DATA.lastSyncTime,
};
}
return DEFAULT_MEDICATION_WIDGET_DATA;
}
// 从App Group UserDefaults读取数据
const dataJson = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA);
const lastSyncTime = await AppGroupDefaults.getString(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC);
if (dataJson) {
const data = JSON.parse(dataJson);
return {
...data,
lastSyncTime: lastSyncTime || DEFAULT_MEDICATION_WIDGET_DATA.lastSyncTime,
};
}
return DEFAULT_MEDICATION_WIDGET_DATA;
} catch (error) {
console.error('Failed to get medication widget data:', error);
return DEFAULT_MEDICATION_WIDGET_DATA;
}
};
/**
* 清除用药Widget数据
*/
export const clearMedicationWidgetData = async (): Promise<void> => {
try {
if (!AppGroupDefaults) {
await AsyncStorage.multiRemove([
MEDICATION_WIDGET_KEYS.MEDICATION_DATA,
MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC,
MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS,
]);
return;
}
// 清除App Group UserDefaults中的数据
await Promise.all([
AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_DATA),
AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.MEDICATION_LAST_SYNC),
AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS),
]);
console.log('Medication widget data cleared successfully');
} catch (error) {
console.error('Failed to clear medication widget data:', error);
throw error;
}
};
/**
* Widget用药操作接口
*/
export interface WidgetMedicationAction {
type: 'take' | 'skip';
recordId: string;
medicationId: string;
timestamp: string;
}
/**
* 从Widget获取待处理的用药操作
*/
export const getPendingMedicationActions = async (): Promise<WidgetMedicationAction[]> => {
try {
if (!AppGroupDefaults) {
// Fallback: 从 AsyncStorage 读取
const pendingActionsJson = await AsyncStorage.getItem(MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS);
return pendingActionsJson ? JSON.parse(pendingActionsJson) : [];
}
// 从 App Group UserDefaults 读取
const pendingActions = await AppGroupDefaults.getArray(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS);
return pendingActions || [];
} catch (error) {
console.error('Failed to get pending medication actions:', error);
return [];
}
};
/**
* 清除待处理的用药操作
*/
export const clearPendingMedicationActions = async (): Promise<void> => {
try {
if (!AppGroupDefaults) {
// Fallback: 从 AsyncStorage 清除
await AsyncStorage.removeItem(MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS);
return;
}
// 从 App Group UserDefaults 清除
await AppGroupDefaults.removeKey(APP_GROUP_ID, MEDICATION_WIDGET_KEYS.PENDING_MEDICATION_ACTIONS);
console.log('Pending medication actions cleared successfully');
} catch (error) {
console.error('Failed to clear pending medication actions:', error);
}
};
/**
* 将Redux数据转换为Widget数据格式
*/
export const convertMedicationDataToWidget = (
records: import('@/types/medication').MedicationRecord[],
medications: import('@/types/medication').Medication[],
date: string
): WidgetMedicationData => {
const medicationMap = new Map(medications.map(m => [m.id, m]));
const widgetItems: WidgetMedicationItem[] = records
.map(record => {
const medication = medicationMap.get(record.medicationId);
if (!medication || !medication.isActive) return null;
return {
id: record.id,
name: medication.name,
dosage: `${medication.dosageValue} ${medication.dosageUnit}`,
scheduledTime: dayjs(record.scheduledTime).format('HH:mm'),
status: record.status,
medicationId: medication.id,
recordId: record.id,
image: medication.photoUrl,
};
})
.filter(Boolean) as WidgetMedicationItem[];
// 按时间排序
widgetItems.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime));
return {
medications: widgetItems,
lastSyncTime: new Date().toISOString(),
date,
};
};