feat(ios): 添加用药计划Widget小组件支持

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

View File

@@ -1,4 +1,5 @@
import AsyncStorage from '@/utils/kvStore';
import dayjs from 'dayjs';
import { NativeModules } from 'react-native';
// Widget数据同步服务
@@ -16,6 +17,13 @@ const WIDGET_DATA_KEYS = {
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;
@@ -31,6 +39,31 @@ const DEFAULT_WIDGET_DATA: WidgetWaterData = {
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原生代码中实现
@@ -54,7 +87,14 @@ export interface PendingWaterRecord {
}
// 尝试获取原生模块如果不存在则使用fallback
const AppGroupDefaults: AppGroupUserDefaults | null = NativeModules.AppGroupUserDefaults || null;
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
@@ -281,4 +321,211 @@ export const markWidgetDataSynced = async (): Promise<void> => {
} 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,
};
};