From cf069f35377c5fc0d1fdbb0a4b7df3ee6cc38cc8 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 14 Oct 2025 15:05:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(fasting):=20=E9=87=8D=E6=9E=84=E6=96=AD?= =?UTF-8?q?=E9=A3=9F=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18 --- app.json | 2 +- app/(tabs)/fasting.tsx | 147 +++----- app/(tabs)/personal.tsx | 7 +- app/_layout.tsx | 32 ++ app/developer/logs.tsx | 18 +- app/fasting/[planId].tsx | 6 +- .../FastingNotificationTestPanel.tsx | 229 ++++++++++++ components/ui/NotificationErrorAlert.tsx | 81 ++++ constants/Fasting.ts | 3 + hooks/useFastingNotifications.ts | 184 +++++++++ ios/OutLive.xcodeproj/project.pbxproj | 3 +- ios/OutLive/Info.plist | 207 +++++------ ios/OutLive/OutLive.entitlements | 32 +- services/fastingNotifications.ts | 350 +++++++++++++++++- store/fastingSlice.ts | 40 +- store/index.ts | 49 ++- utils/fasting.ts | 76 +++- utils/fastingNotificationTest.ts | 341 +++++++++++++++++ 18 files changed, 1565 insertions(+), 242 deletions(-) create mode 100644 components/developer/FastingNotificationTestPanel.tsx create mode 100644 components/ui/NotificationErrorAlert.tsx create mode 100644 hooks/useFastingNotifications.ts create mode 100644 utils/fastingNotificationTest.ts diff --git a/app.json b/app.json index e75e74d..c8a497f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.0.17", + "version": "1.0.18", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/(tabs)/fasting.tsx b/app/(tabs)/fasting.tsx index 2af8f11..c511f7a 100644 --- a/app/(tabs)/fasting.tsx +++ b/app/(tabs)/fasting.tsx @@ -1,13 +1,12 @@ import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard'; import { FastingPlanList } from '@/components/fasting/FastingPlanList'; import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal'; -import { Colors } from '@/constants/Colors'; +import { NotificationErrorAlert } from '@/components/ui/NotificationErrorAlert'; import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting'; import { ROUTES } from '@/constants/Routes'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; -import { useFocusEffect } from '@react-navigation/native'; -import { useColorScheme } from '@/hooks/useColorScheme'; import { useCountdown } from '@/hooks/useCountdown'; +import { useFastingNotifications } from '@/hooks/useFastingNotifications'; import { clearActiveSchedule, rescheduleActivePlan, @@ -21,31 +20,24 @@ import { getFastingPhase, getPhaseLabel, loadPreferredPlanId, - loadStoredFastingNotificationIds, - savePreferredPlanId, + savePreferredPlanId } from '@/utils/fasting'; -import type { FastingNotificationIds } from '@/utils/fasting'; -import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications'; -import { getNotificationEnabled } from '@/utils/userPreferences'; +import { useFocusEffect } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function FastingTabScreen() { const router = useRouter(); const dispatch = useAppDispatch(); - const theme = useColorScheme() ?? 'light'; - const colorTokens = Colors[theme]; + const insets = useSafeAreaInsets(); const activeSchedule = useAppSelector(selectActiveFastingSchedule); const activePlan = useAppSelector(selectActiveFastingPlan); const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0]; const [preferredPlanId, setPreferredPlanId] = useState(activePlan?.id ?? undefined); - const [notificationsReady, setNotificationsReady] = useState(false); - const notificationsLoadedRef = useRef(false); - const notificationIdsRef = useRef({}); useEffect(() => { if (!activePlan?.id) return; @@ -75,34 +67,31 @@ export default function FastingTabScreen() { }; }, [activePlan?.id]); - useFocusEffect( - useCallback(() => { - let cancelled = false; - const checkNotifications = async () => { - const ready = await ensureFastingNotificationsReady(); - if (!cancelled) { - setNotificationsReady(ready); - if (!ready) { - notificationsLoadedRef.current = false; - } - } - }; - - checkNotifications(); - - return () => { - cancelled = true; - }; - }, []) - ); - - const currentPlan: FastingPlan | undefined = useMemo(() => { if (activePlan) return activePlan; if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan; return defaultPlan; }, [activePlan, preferredPlanId, defaultPlan]); + // 使用新的通知管理 hook + const { + isReady: notificationsReady, + isLoading: notificationsLoading, + error: notificationError, + notificationIds, + lastSyncTime, + verifyAndSync, + forceSync, + clearError, + } = useFastingNotifications(activeSchedule, currentPlan); + + // 每次进入页面时验证通知 + useFocusEffect( + useCallback(() => { + verifyAndSync(); + }, [verifyAndSync]) + ); + const scheduleStart = useMemo(() => { if (activeSchedule) return new Date(activeSchedule.startISO); if (currentPlan) return getRecommendedStart(currentPlan); @@ -137,59 +126,33 @@ export default function FastingTabScreen() { const [showPicker, setShowPicker] = useState(false); + // 显示通知错误(如果有) useEffect(() => { - if (!notificationsReady) return; - - let cancelled = false; - const verifyPreference = async () => { - const enabled = await getNotificationEnabled(); - if (!cancelled && !enabled) { - setNotificationsReady(false); - notificationsLoadedRef.current = false; - } - }; - - verifyPreference(); - - return () => { - cancelled = true; - }; - }, [notificationsReady]); + if (notificationError) { + console.warn('断食通知错误:', notificationError); + // 可以在这里添加用户提示,比如 Toast 或 Snackbar + } + }, [notificationError]); const recommendedDate = useMemo(() => { if (!currentPlan) return undefined; return getRecommendedStart(currentPlan); }, [currentPlan]); + // 调试信息(开发环境) useEffect(() => { - let cancelled = false; - - const syncNotifications = async () => { - if (!notificationsLoadedRef.current) { - const storedIds = await loadStoredFastingNotificationIds(); - if (cancelled) return; - notificationIdsRef.current = storedIds; - notificationsLoadedRef.current = true; - } - - const nextIds = await resyncFastingNotifications({ - schedule: activeSchedule ?? null, - plan: notificationsReady ? currentPlan : undefined, - previousIds: notificationIdsRef.current, - enabled: notificationsReady, + if (__DEV__ && lastSyncTime) { + console.log('断食通知状态:', { + ready: notificationsReady, + loading: notificationsLoading, + error: notificationError, + notificationIds, + lastSyncTime, + schedule: activeSchedule?.startISO, + plan: currentPlan?.id, }); - - if (!cancelled) { - notificationIdsRef.current = nextIds; - } - }; - - syncNotifications(); - - return () => { - cancelled = true; - }; - }, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]); + } + }, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]); const handleAdjustStart = () => { if (!currentPlan) return; @@ -199,9 +162,9 @@ export default function FastingTabScreen() { const handleConfirmStart = (date: Date) => { if (!currentPlan) return; if (activeSchedule) { - dispatch(rescheduleActivePlan({ start: date, origin: 'manual' })); + dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' })); } else { - dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date, origin: 'manual' })); + dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date.toISOString(), origin: 'manual' })); } }; @@ -218,9 +181,12 @@ export default function FastingTabScreen() { }; return ( - + @@ -228,6 +194,13 @@ export default function FastingTabScreen() { 改善代谢 · 科学控脂 · 饮食不焦虑 + {/* 通知错误提示 */} + + {currentPlan && ( - + ); } const styles = StyleSheet.create({ safeArea: { flex: 1, + backgroundColor: 'white' }, scrollContainer: { paddingHorizontal: 20, paddingTop: 12, + }, headerRow: { marginBottom: 20, diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 9ec8464..5f702ad 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -219,7 +219,7 @@ export default function PersonalScreen() { {isLgAvaliable ? ( pushIfAuthedElseLogin('/profile/edit')}> - {isLoggedIn ? '编辑' : '登录'} + {isLoggedIn ? '编辑' : '登录'} ) : ( @@ -511,6 +511,11 @@ const styles = StyleSheet.create({ alignItems: 'center', }, editButtonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + editButtonTextGlass: { color: 'rgba(147, 112, 219, 1)', fontSize: 14, fontWeight: '600', diff --git a/app/_layout.tsx b/app/_layout.tsx index 285b981..2ffa031 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -15,9 +15,11 @@ import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; import { WaterRecordSource } from '@/services/waterRecords'; import { workoutMonitorService } from '@/services/workoutMonitor'; import { store } from '@/store'; +import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice'; import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { createWaterRecordAction } from '@/store/waterSlice'; import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; +import { loadActiveFastingSchedule } from '@/utils/fasting'; import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React, { useEffect } from 'react'; @@ -35,12 +37,42 @@ import { Provider } from 'react-redux'; function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); const { profile } = useAppSelector((state) => state.user); + const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); const { isLoggedIn } = useAuthGuard() + const fastingHydrationRequestedRef = React.useRef(false); // 初始化快捷动作处理 useQuickActions(); + React.useEffect(() => { + if (fastingHydrationRequestedRef.current) return; + if (activeFastingSchedule) { + fastingHydrationRequestedRef.current = true; + return; + } + + fastingHydrationRequestedRef.current = true; + let cancelled = false; + + const hydrate = async () => { + try { + const stored = await loadActiveFastingSchedule(); + if (cancelled || !stored) return; + if (store.getState().fasting.activeSchedule) return; + dispatch(hydrateActiveSchedule(stored)); + } catch (error) { + console.warn('恢复断食计划失败:', error); + } + }; + + hydrate(); + + return () => { + cancelled = true; + }; + }, [dispatch, activeFastingSchedule]); + useEffect(() => { if (isLoggedIn) { dispatch(fetchChallenges()); diff --git a/app/developer/logs.tsx b/app/developer/logs.tsx index d6a65ee..26068c7 100644 --- a/app/developer/logs.tsx +++ b/app/developer/logs.tsx @@ -1,4 +1,5 @@ -import { log, logger, LogEntry } from '@/utils/logger'; +import { FastingNotificationTestPanel } from '@/components/developer/FastingNotificationTestPanel'; +import { log, LogEntry, logger } from '@/utils/logger'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import React, { useEffect, useState } from 'react'; @@ -19,6 +20,7 @@ export default function LogsScreen() { const insets = useSafeAreaInsets(); const [logs, setLogs] = useState([]); const [refreshing, setRefreshing] = useState(false); + const [showTestPanel, setShowTestPanel] = useState(false); const loadLogs = async () => { try { @@ -68,6 +70,10 @@ export default function LogsScreen() { } }; + const handleTestNotifications = () => { + setShowTestPanel(true); + }; + useEffect(() => { loadLogs(); // 添加测试日志 @@ -148,6 +154,9 @@ export default function LogsScreen() { 日志 ({logs.length}) + + + @@ -186,6 +195,13 @@ export default function LogsScreen() { } /> + + {/* 断食通知测试面板 */} + {showTestPanel && ( + setShowTestPanel(false)} + /> + )} ); } diff --git a/app/fasting/[planId].tsx b/app/fasting/[planId].tsx index b36af2c..745dc87 100644 --- a/app/fasting/[planId].tsx +++ b/app/fasting/[planId].tsx @@ -55,7 +55,7 @@ export default function FastingPlanDetailScreen() { const displayWindow = buildDisplayWindow(window.start, window.end); const handleStartWithRecommended = () => { - dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart, origin: 'recommended' })); + dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart.toISOString(), origin: 'recommended' })); router.replace(ROUTES.TAB_FASTING); }; @@ -65,9 +65,9 @@ export default function FastingPlanDetailScreen() { const handleConfirmPicker = (date: Date) => { if (activeSchedule?.planId === plan.id) { - dispatch(rescheduleActivePlan({ start: date, origin: 'manual' })); + dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' })); } else { - dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' })); + dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' })); } setShowPicker(false); router.replace(ROUTES.TAB_FASTING); diff --git a/components/developer/FastingNotificationTestPanel.tsx b/components/developer/FastingNotificationTestPanel.tsx new file mode 100644 index 0000000..ff6acd2 --- /dev/null +++ b/components/developer/FastingNotificationTestPanel.tsx @@ -0,0 +1,229 @@ +import { FASTING_PLANS, FastingPlan } from '@/constants/Fasting'; +import { fastingNotificationTester } from '@/utils/fastingNotificationTest'; +import React, { useState } from 'react'; +import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface FastingNotificationTestPanelProps { + onClose: () => void; +} + +export const FastingNotificationTestPanel: React.FC = ({ + onClose, +}) => { + const [isRunning, setIsRunning] = useState(false); + const [testResults, setTestResults] = useState>([]); + + const runTest = async (plan: FastingPlan) => { + setIsRunning(true); + setTestResults([]); + + try { + const results = await fastingNotificationTester.runFullTestSuite(plan); + setTestResults(results); + + const passedCount = results.filter(r => r.passed).length; + const totalCount = results.length; + + Alert.alert( + '测试完成', + `测试结果: ${passedCount}/${totalCount} 通过`, + [{ text: '确定', onPress: () => { } }] + ); + } catch (error) { + Alert.alert( + '测试失败', + `测试过程中发生错误: ${error instanceof Error ? error.message : '未知错误'}`, + [{ text: '确定', onPress: () => { } }] + ); + } finally { + setIsRunning(false); + } + }; + + const clearResults = () => { + setTestResults([]); + fastingNotificationTester.clearResults(); + }; + + return ( + + + 断食通知测试 + + × + + + + + + 选择测试方案 + {FASTING_PLANS.map((plan) => ( + runTest(plan)} + disabled={isRunning} + > + {plan.title} + {plan.subtitle} + + ))} + + + {testResults.length > 0 && ( + + + 测试结果 + + 清除 + + + + {testResults.map((result, index) => ( + + {result.testName} + {result.message} + + {result.timestamp.toLocaleTimeString()} + + + ))} + + )} + + {isRunning && ( + + 正在运行测试... + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + backgroundColor: '#FFFFFF', + borderBottomWidth: 1, + borderBottomColor: '#E0E0E0', + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#333333', + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#F0F0F0', + justifyContent: 'center', + alignItems: 'center', + }, + closeButtonText: { + fontSize: 18, + fontWeight: '600', + color: '#666666', + }, + content: { + flex: 1, + padding: 16, + }, + section: { + marginBottom: 24, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333333', + marginBottom: 12, + }, + planButton: { + backgroundColor: '#FFFFFF', + padding: 12, + borderRadius: 8, + marginBottom: 8, + borderWidth: 1, + borderColor: '#E0E0E0', + }, + disabledButton: { + opacity: 0.5, + }, + planButtonText: { + fontSize: 14, + fontWeight: '500', + color: '#333333', + }, + planSubtitle: { + fontSize: 12, + color: '#666666', + marginTop: 2, + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#F0F0F0', + borderRadius: 4, + }, + clearButtonText: { + fontSize: 12, + color: '#666666', + }, + resultItem: { + padding: 12, + borderRadius: 8, + marginBottom: 8, + borderLeftWidth: 4, + }, + passedResult: { + backgroundColor: '#F0F9F0', + borderLeftColor: '#4CAF50', + }, + failedResult: { + backgroundColor: '#FFF3F3', + borderLeftColor: '#F44336', + }, + resultTestName: { + fontSize: 14, + fontWeight: '500', + color: '#333333', + }, + resultMessage: { + fontSize: 12, + color: '#666666', + marginTop: 2, + }, + resultTime: { + fontSize: 10, + color: '#999999', + marginTop: 4, + }, + runningText: { + fontSize: 14, + color: '#666666', + textAlign: 'center', + fontStyle: 'italic', + }, +}); \ No newline at end of file diff --git a/components/ui/NotificationErrorAlert.tsx b/components/ui/NotificationErrorAlert.tsx new file mode 100644 index 0000000..aa2df93 --- /dev/null +++ b/components/ui/NotificationErrorAlert.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface NotificationErrorAlertProps { + error: string | null; + onRetry?: () => void; + onDismiss?: () => void; +} + +export const NotificationErrorAlert: React.FC = ({ + error, + onRetry, + onDismiss, +}) => { + if (!error) return null; + + return ( + + + 通知提醒 + {error} + + {onRetry && ( + + 重试 + + )} + {onDismiss && ( + + 忽略 + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FEF2F2', + borderLeftWidth: 4, + borderLeftColor: '#EF4444', + marginHorizontal: 20, + marginVertical: 8, + borderRadius: 8, + }, + content: { + padding: 12, + }, + title: { + fontSize: 14, + fontWeight: '600', + color: '#DC2626', + marginBottom: 4, + }, + message: { + fontSize: 13, + color: '#7F1D1D', + marginBottom: 8, + lineHeight: 18, + }, + actions: { + flexDirection: 'row', + gap: 8, + }, + button: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#DC2626', + borderRadius: 6, + }, + dismissButton: { + backgroundColor: '#9CA3AF', + }, + buttonText: { + fontSize: 12, + fontWeight: '500', + color: '#FFFFFF', + }, +}); \ No newline at end of file diff --git a/constants/Fasting.ts b/constants/Fasting.ts index f669e5d..078f6df 100644 --- a/constants/Fasting.ts +++ b/constants/Fasting.ts @@ -29,8 +29,11 @@ export type FastingPlan = { export const FASTING_STORAGE_KEYS = { preferredPlanId: '@fasting_preferred_plan', notificationsRegistered: '@fasting_notifications_registered', + preStartNotificationId: '@fasting_notification_pre_start_id', startNotificationId: '@fasting_notification_start_id', + preEndNotificationId: '@fasting_notification_pre_end_id', endNotificationId: '@fasting_notification_end_id', + activeSchedule: '@fasting_active_schedule', } as const; export const FASTING_PLANS: FastingPlan[] = [ diff --git a/hooks/useFastingNotifications.ts b/hooks/useFastingNotifications.ts new file mode 100644 index 0000000..92bda4e --- /dev/null +++ b/hooks/useFastingNotifications.ts @@ -0,0 +1,184 @@ +import { FastingPlan } from '@/constants/Fasting'; +import { + ensureFastingNotificationsReady, + resyncFastingNotifications, + verifyFastingNotifications, +} from '@/services/fastingNotifications'; +import { FastingSchedule } from '@/store/fastingSlice'; +import { FastingNotificationIds, loadStoredFastingNotificationIds } from '@/utils/fasting'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface UseFastingNotificationsState { + isReady: boolean; + isLoading: boolean; + error: string | null; + notificationIds: FastingNotificationIds; + lastSyncTime: Date | null; +} + +export interface UseFastingNotificationsActions { + verifyAndSync: () => Promise; + forceSync: () => Promise; + clearError: () => void; +} + +export const useFastingNotifications = ( + schedule: FastingSchedule | null, + plan: FastingPlan | undefined +): UseFastingNotificationsState & UseFastingNotificationsActions => { + const [state, setState] = useState({ + isReady: false, + isLoading: true, + error: null, + notificationIds: {}, + lastSyncTime: null, + }); + + const isInitializedRef = useRef(false); + const notificationIdsRef = useRef({}); + const isSyncingRef = useRef(false); + + // 初始化通知系统 + const initialize = useCallback(async () => { + if (isInitializedRef.current) return; + + try { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + // 1. 检查通知权限 + const ready = await ensureFastingNotificationsReady(); + if (!ready) { + setState(prev => ({ + ...prev, + isReady: false, + isLoading: false, + error: '通知权限未授予', + })); + return; + } + + // 2. 加载存储的通知ID + const storedIds = await loadStoredFastingNotificationIds(); + notificationIdsRef.current = storedIds; + + setState(prev => ({ + ...prev, + isReady: true, + isLoading: false, + notificationIds: storedIds, + })); + + isInitializedRef.current = true; + } catch (error) { + console.error('初始化断食通知失败', error); + setState(prev => ({ + ...prev, + isReady: false, + isLoading: false, + error: error instanceof Error ? error.message : '初始化失败', + })); + } + }, []); + + // 验证和同步通知 + const verifyAndSync = useCallback(async () => { + if (!state.isReady || isSyncingRef.current) return; + + try { + isSyncingRef.current = true; + setState(prev => ({ ...prev, error: null })); + + const { isValid, updatedIds } = await verifyFastingNotifications({ + schedule, + plan, + storedIds: notificationIdsRef.current, + }); + + notificationIdsRef.current = updatedIds; + + setState(prev => ({ + ...prev, + notificationIds: updatedIds, + lastSyncTime: new Date(), + })); + + if (!isValid) { + console.log('断食通知已重新同步'); + } + } catch (error) { + console.error('验证断食通知失败', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '验证失败', + })); + + // 验证失败时不立即强制同步,避免重复调用 + // forceSync 会在用户点击重试按钮时调用 + } finally { + isSyncingRef.current = false; + } + }, [state.isReady, schedule, plan]); + + // 强制同步通知 + const forceSync = useCallback(async () => { + if (!state.isReady || isSyncingRef.current) return; + + try { + isSyncingRef.current = true; + setState(prev => ({ ...prev, error: null })); + + const nextIds = await resyncFastingNotifications({ + schedule, + plan, + previousIds: notificationIdsRef.current, + enabled: true, + }); + + notificationIdsRef.current = nextIds; + + setState(prev => ({ + ...prev, + notificationIds: nextIds, + lastSyncTime: new Date(), + })); + + console.log('断食通知已强制同步', { + schedule: schedule?.startISO, + plan: plan?.id, + notificationIds: nextIds, + }); + } catch (error) { + console.error('强制同步断食通知失败', error); + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : '同步失败', + })); + } finally { + isSyncingRef.current = false; + } + }, [state.isReady, schedule, plan]); + + // 清除错误 + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + // 初始化 + useEffect(() => { + initialize(); + }, [initialize]); + + // 当计划或方案变化时验证和同步 + useEffect(() => { + if (state.isReady) { + verifyAndSync(); + } + }, [state.isReady, schedule?.startISO, schedule?.endISO, plan?.id, verifyAndSync]); + + return { + ...state, + verifyAndSync, + forceSync, + clearError, + }; +}; \ No newline at end of file diff --git a/ios/OutLive.xcodeproj/project.pbxproj b/ios/OutLive.xcodeproj/project.pbxproj index c582a9a..75e5ec8 100644 --- a/ios/OutLive.xcodeproj/project.pbxproj +++ b/ios/OutLive.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = ""; }; 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = ""; }; - B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = ""; }; + B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = ""; }; @@ -135,7 +135,6 @@ 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */, 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 0a61884..1fe19a4 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -1,109 +1,104 @@ - - BGTaskSchedulerPermittedIdentifiers - - com.expo.modules.backgroundtask.processing - - CADisableMinimumFrameDurationOnPhone - - CFBundleAllowMixedLocalizations - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Out Live - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0.17 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - digitalpilates - com.anonymous.digitalpilates - - - - CFBundleVersion - 1 - ITSAppUsesNonExemptEncryption - - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSCameraUsageDescription - 应用需要使用相机以拍摄您的体态照片用于AI测评。 - NSHealthShareUsageDescription - 应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。 - NSHealthUpdateUsageDescription - 应用需要更新您的健康数据(体重信息)以记录您的健身进度。 - NSMicrophoneUsageDescription - 应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。 - NSPhotoLibraryAddUsageDescription - 应用需要写入相册以保存拍摄的体态照片(可选)。 - NSPhotoLibraryUsageDescription - 应用需要访问相册以选择您的体态照片用于AI测评。 - NSSpeechRecognitionUsageDescription - 应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。 - NSUserActivityTypes - - $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route - - NSUserNotificationsUsageDescription - 应用需要发送通知以提醒您喝水和站立活动。 - RCTNewArchEnabled - - UIBackgroundModes - - fetch - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UIUserInterfaceStyle - Light - UIViewControllerBasedStatusBarAppearance - - NSHealthShareUsageDescription - Read and understand health data. - NSHealthUpdateUsageDescription - Share workout data with other apps. - - NSHealthClinicalHealthRecordsShareUsageDescription - Read and understand clinical health data. - - \ No newline at end of file + + BGTaskSchedulerPermittedIdentifiers + + com.expo.modules.backgroundtask.processing + + CADisableMinimumFrameDurationOnPhone + + CFBundleAllowMixedLocalizations + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Out Live + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.18 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + digitalpilates + com.anonymous.digitalpilates + + + + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + 应用需要使用相机以拍摄您的体态照片用于AI测评。 + NSHealthClinicalHealthRecordsShareUsageDescription + Read and understand clinical health data. + NSHealthShareUsageDescription + Read and understand health data. + NSHealthUpdateUsageDescription + Share workout data with other apps. + NSMicrophoneUsageDescription + 应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。 + NSPhotoLibraryAddUsageDescription + 应用需要写入相册以保存拍摄的体态照片(可选)。 + NSPhotoLibraryUsageDescription + 应用需要访问相册以选择您的体态照片用于AI测评。 + NSSpeechRecognitionUsageDescription + 应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。 + NSUserActivityTypes + + $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + + NSUserNotificationsUsageDescription + 应用需要发送通知以提醒您喝水和站立活动。 + RCTNewArchEnabled + + UIBackgroundModes + + fetch + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/OutLive/OutLive.entitlements b/ios/OutLive/OutLive.entitlements index 0bbaa2f..3ab3686 100644 --- a/ios/OutLive/OutLive.entitlements +++ b/ios/OutLive/OutLive.entitlements @@ -1,18 +1,20 @@ - - aps-environment - development - com.apple.developer.applesignin - - Default - - com.apple.developer.healthkit - - com.apple.developer.healthkit.access - - health-records - - - \ No newline at end of file + + aps-environment + development + com.apple.developer.applesignin + + Default + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + health-records + + com.apple.developer.healthkit.background-delivery + + + diff --git a/services/fastingNotifications.ts b/services/fastingNotifications.ts index aa9c800..e84a75f 100644 --- a/services/fastingNotifications.ts +++ b/services/fastingNotifications.ts @@ -1,37 +1,59 @@ import dayjs from 'dayjs'; +import * as Notifications from 'expo-notifications'; import { FastingPlan } from '@/constants/Fasting'; import { FastingSchedule } from '@/store/fastingSlice'; import { clearFastingNotificationIds, + FastingNotificationIds, getFastingNotificationsRegistered, loadStoredFastingNotificationIds, saveFastingNotificationIds, setFastingNotificationsRegistered, - FastingNotificationIds, } from '@/utils/fasting'; import { getNotificationEnabled } from '@/utils/userPreferences'; import { notificationService, NotificationTypes } from './notifications'; -const REMINDER_OFFSET_MINUTES = 10; +const REMINDER_OFFSET_MINUTES = 30; // 改为30分钟提醒 const cancelNotificationIds = async (ids?: FastingNotificationIds) => { if (!ids) return; - const { startId, endId } = ids; + const { preStartId, startId, preEndId, endId } = ids; + + // 取消开始前30分钟通知 + try { + if (preStartId) { + await notificationService.cancelNotification(preStartId); + } + } catch (error) { + console.warn('取消断食开始前30分钟提醒失败', error); + } + + // 取消开始时通知 try { if (startId) { await notificationService.cancelNotification(startId); } } catch (error) { - console.warn('取消断食开始提醒失败', error); + console.warn('取消断食开始时提醒失败', error); } + // 取消结束前30分钟通知 + try { + if (preEndId) { + await notificationService.cancelNotification(preEndId); + } + } catch (error) { + console.warn('取消断食结束前30分钟提醒失败', error); + } + + // 取消结束时通知 try { if (endId) { await notificationService.cancelNotification(endId); } } catch (error) { - console.warn('取消断食结束提醒失败', error); + console.warn('取消断食结束时提醒失败', error); } }; @@ -108,33 +130,83 @@ export const resyncFastingNotifications = async ({ const notificationIds: FastingNotificationIds = {}; + // 1. 安排开始前30分钟通知 if (start.isAfter(now)) { const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute'); - const triggerMoment = preStart.isAfter(now) ? preStart : start; + // 只有当开始前30分钟还在未来时才安排这个通知 + if (preStart.isAfter(now)) { + try { + const preStartId = await notificationService.scheduleNotificationAtDate( + { + title: `${plan.title} 即将开始`, + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`, + data: { + type: NotificationTypes.FASTING_START, + planId: plan.id, + subtype: 'pre_start', + }, + sound: true, + priority: 'high', + }, + preStart.toDate() + ); + notificationIds.preStartId = preStartId; + } catch (error) { + console.error('安排断食开始前30分钟通知失败', error); + } + } + + // 2. 安排开始时通知 try { const startId = await notificationService.scheduleNotificationAtDate( { - title: `${plan.title} 即将开始`, - body: preStart.isAfter(now) - ? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!` - : `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`, + title: `${plan.title} 开始了`, + body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`, data: { type: NotificationTypes.FASTING_START, planId: plan.id, + subtype: 'start', }, sound: true, priority: 'high', }, - triggerMoment.toDate() + start.toDate() ); notificationIds.startId = startId; } catch (error) { - console.error('安排断食开始通知失败', error); + console.error('安排断食开始时通知失败', error); } } + // 3. 安排结束前30分钟通知 if (end.isAfter(now)) { + const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + + // 只有当结束前30分钟还在未来时才安排这个通知 + if (preEnd.isAfter(now)) { + try { + const preEndId = await notificationService.scheduleNotificationAtDate( + { + title: '即将结束断食', + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`, + data: { + type: NotificationTypes.FASTING_END, + planId: plan.id, + subtype: 'pre_end', + }, + sound: true, + priority: 'high', + }, + preEnd.toDate() + ); + notificationIds.preEndId = preEndId; + } catch (error) { + console.error('安排断食结束前30分钟通知失败', error); + } + } + + // 4. 安排结束时通知 try { const endId = await notificationService.scheduleNotificationAtDate( { @@ -143,6 +215,7 @@ export const resyncFastingNotifications = async ({ data: { type: NotificationTypes.FASTING_END, planId: plan.id, + subtype: 'end', }, sound: true, priority: 'high', @@ -151,7 +224,7 @@ export const resyncFastingNotifications = async ({ ); notificationIds.endId = endId; } catch (error) { - console.error('安排断食结束通知失败', error); + console.error('安排断食结束时通知失败', error); } } @@ -159,4 +232,255 @@ export const resyncFastingNotifications = async ({ return notificationIds; }; +/** + * 选择性同步通知,只更新需要更新的通知 + * @param schedule 断食计划 + * @param plan 断食方案 + * @param storedIds 存储的通知ID + * @param validIds 有效的通知ID + * @param enabled 是否启用通知 + * @returns 更新后的通知ID + */ +const selectiveSyncNotifications = async ({ + schedule, + plan, + storedIds, + validIds, + enabled, +}: { + schedule: FastingSchedule | null; + plan?: FastingPlan; + storedIds: FastingNotificationIds; + validIds: FastingNotificationIds; + enabled: boolean; +}): Promise => { + if (!enabled || !schedule || !plan) { + await clearFastingNotificationIds(); + return {}; + } + + const now = dayjs(); + const start = dayjs(schedule.startISO); + const end = dayjs(schedule.endISO); + + if (end.isBefore(now)) { + await clearFastingNotificationIds(); + return {}; + } + + const updatedIds: FastingNotificationIds = { ...validIds }; + + // 1. 检查开始前30分钟通知 + const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + if (preStart.isAfter(now) && !validIds.preStartId) { + try { + const preStartId = await notificationService.scheduleNotificationAtDate( + { + title: `${plan.title} 即将开始`, + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`, + data: { + type: NotificationTypes.FASTING_START, + planId: plan.id, + subtype: 'pre_start', + }, + sound: true, + priority: 'high', + }, + preStart.toDate() + ); + updatedIds.preStartId = preStartId; + } catch (error) { + console.error('安排断食开始前30分钟通知失败', error); + } + } + + // 2. 检查开始时通知 + if (start.isAfter(now) && !validIds.startId) { + try { + const startId = await notificationService.scheduleNotificationAtDate( + { + title: `${plan.title} 开始了`, + body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`, + data: { + type: NotificationTypes.FASTING_START, + planId: plan.id, + subtype: 'start', + }, + sound: true, + priority: 'high', + }, + start.toDate() + ); + updatedIds.startId = startId; + } catch (error) { + console.error('安排断食开始时通知失败', error); + } + } + + // 3. 检查结束前30分钟通知 + const preEnd = end.subtract(REMINDER_OFFSET_MINUTES, 'minute'); + if (preEnd.isAfter(now) && !validIds.preEndId) { + try { + const preEndId = await notificationService.scheduleNotificationAtDate( + { + title: '即将结束断食', + body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要结束 ${plan.title},准备高蛋白 + 低GI 的餐食奖励自己!`, + data: { + type: NotificationTypes.FASTING_END, + planId: plan.id, + subtype: 'pre_end', + }, + sound: true, + priority: 'high', + }, + preEnd.toDate() + ); + updatedIds.preEndId = preEndId; + } catch (error) { + console.error('安排断食结束前30分钟通知失败', error); + } + } + + // 4. 检查结束时通知 + if (end.isAfter(now) && !validIds.endId) { + try { + const endId = await notificationService.scheduleNotificationAtDate( + { + title: '补能时刻到啦', + body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`, + data: { + type: NotificationTypes.FASTING_END, + planId: plan.id, + subtype: 'end', + }, + sound: true, + priority: 'high', + }, + end.toDate() + ); + updatedIds.endId = endId; + } catch (error) { + console.error('安排断食结束时通知失败', error); + } + } + + await saveFastingNotificationIds(updatedIds); + return updatedIds; +}; + +/** + * 验证现有通知是否正确设置 + * @param schedule 当前断食计划 + * @param plan 当前断食方案 + * @param storedIds 存储的通知ID + * @returns 验证结果和需要更新的通知ID + */ +export const verifyFastingNotifications = async ({ + schedule, + plan, + storedIds, +}: { + schedule: FastingSchedule | null; + plan?: FastingPlan; + storedIds: FastingNotificationIds; +}): Promise<{ isValid: boolean; updatedIds: FastingNotificationIds }> => { + if (!schedule || !plan) { + // 如果没有计划或方案,应该清空所有通知 + if (Object.values(storedIds).some(id => id)) { + await cancelNotificationIds(storedIds); + await clearFastingNotificationIds(); + return { isValid: false, updatedIds: {} }; + } + return { isValid: true, updatedIds: storedIds }; + } + + const now = dayjs(); + const start = dayjs(schedule.startISO); + const end = dayjs(schedule.endISO); + + // 如果断食期已结束,应该清空所有通知 + if (end.isBefore(now)) { + if (Object.values(storedIds).some(id => id)) { + await cancelNotificationIds(storedIds); + await clearFastingNotificationIds(); + return { isValid: false, updatedIds: {} }; + } + return { isValid: true, updatedIds: {} }; + } + + // 获取当前已安排的通知 + let scheduledNotifications: Notifications.NotificationRequest[] = []; + try { + scheduledNotifications = await notificationService.getAllScheduledNotifications(); + } catch (error) { + console.warn('获取已安排通知失败', error); + // 如果获取通知失败,不要立即重新同步,而是返回当前存储的ID + // 这样可以避免可能的重复通知问题 + return { isValid: false, updatedIds: storedIds }; + } + + // 检查每个通知是否存在且正确 + const expectedNotifications = [ + { id: storedIds.preStartId, type: 'pre_start', time: start.subtract(REMINDER_OFFSET_MINUTES, 'minute') }, + { id: storedIds.startId, type: 'start', time: start }, + { id: storedIds.preEndId, type: 'pre_end', time: end.subtract(REMINDER_OFFSET_MINUTES, 'minute') }, + { id: storedIds.endId, type: 'end', time: end }, + ]; + + let needsResync = false; + const validIds: FastingNotificationIds = {}; + + for (const expected of expectedNotifications) { + // 跳过已过期的通知 + if (expected.time.isBefore(now)) { + if (expected.id) { + needsResync = true; + } + continue; + } + + if (!expected.id) { + needsResync = true; + continue; + } + + // 检查通知是否还存在 + const exists = scheduledNotifications.some(n => n.identifier === expected.id); + if (!exists) { + needsResync = true; + continue; + } + + // 通知存在且有效 + switch (expected.type) { + case 'pre_start': + validIds.preStartId = expected.id; + break; + case 'start': + validIds.startId = expected.id; + break; + case 'pre_end': + validIds.preEndId = expected.id; + break; + case 'end': + validIds.endId = expected.id; + break; + } + } + + if (needsResync) { + // 只同步需要更新的通知,而不是全部重新同步 + const updatedIds = await selectiveSyncNotifications({ + schedule, + plan, + storedIds, + validIds, + enabled: true + }); + return { isValid: false, updatedIds }; + } + + return { isValid: true, updatedIds: validIds }; +}; + export type { FastingNotificationIds }; diff --git a/store/fastingSlice.ts b/store/fastingSlice.ts index 4961221..1be2f8b 100644 --- a/store/fastingSlice.ts +++ b/store/fastingSlice.ts @@ -1,7 +1,7 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import dayjs from 'dayjs'; import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting'; import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import dayjs from 'dayjs'; import type { RootState } from './index'; export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start'; @@ -29,14 +29,35 @@ const fastingSlice = createSlice({ name: 'fasting', initialState, reducers: { + hydrateActiveSchedule: ( + state, + action: PayloadAction + ) => { + const incoming = action.payload; + if (!incoming) { + state.activeSchedule = null; + return; + } + + if (state.activeSchedule) { + const currentUpdated = dayjs(state.activeSchedule.updatedAtISO ?? state.activeSchedule.startISO); + const incomingUpdated = dayjs(incoming.updatedAtISO ?? incoming.startISO); + if (currentUpdated.isSame(incomingUpdated) || currentUpdated.isAfter(incomingUpdated)) { + return; + } + } + + state.activeSchedule = incoming; + }, scheduleFastingPlan: ( state, - action: PayloadAction<{ planId: string; start: Date; origin?: FastingScheduleOrigin }> + action: PayloadAction<{ planId: string; start: string; origin?: FastingScheduleOrigin }> ) => { const plan = getPlanById(action.payload.planId); if (!plan) return; - const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours); + const startDate = new Date(action.payload.start); + const { start, end } = calculateFastingWindow(startDate, plan.fastingHours); const nowISO = new Date().toISOString(); state.activeSchedule = { planId: plan.id, @@ -49,13 +70,14 @@ const fastingSlice = createSlice({ }, rescheduleActivePlan: ( state, - action: PayloadAction<{ start: Date; origin?: FastingScheduleOrigin }> + action: PayloadAction<{ start: string; origin?: FastingScheduleOrigin }> ) => { if (!state.activeSchedule) return; const plan = getPlanById(state.activeSchedule.planId); if (!plan) return; - const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours); + const startDate = new Date(action.payload.start); + const { start, end } = calculateFastingWindow(startDate, plan.fastingHours); state.activeSchedule = { ...state.activeSchedule, startISO: start.toISOString(), @@ -66,11 +88,12 @@ const fastingSlice = createSlice({ }, setRecommendedSchedule: ( state, - action: PayloadAction<{ planId: string; recommendedStart: Date }> + action: PayloadAction<{ planId: string; recommendedStart: string }> ) => { const plan = getPlanById(action.payload.planId); if (!plan) return; - const { start, end } = calculateFastingWindow(action.payload.recommendedStart, plan.fastingHours); + const startDate = new Date(action.payload.recommendedStart); + const { start, end } = calculateFastingWindow(startDate, plan.fastingHours); const nowISO = new Date().toISOString(); state.activeSchedule = { planId: plan.id, @@ -107,6 +130,7 @@ const fastingSlice = createSlice({ }); export const { + hydrateActiveSchedule, scheduleFastingPlan, rescheduleActivePlan, setRecommendedSchedule, diff --git a/store/index.ts b/store/index.ts index 6a43cb5..df8d1a3 100644 --- a/store/index.ts +++ b/store/index.ts @@ -7,7 +7,13 @@ import foodLibraryReducer from './foodLibrarySlice'; import foodRecognitionReducer from './foodRecognitionSlice'; import goalsReducer from './goalsSlice'; import healthReducer from './healthSlice'; -import fastingReducer from './fastingSlice'; +import fastingReducer, { + clearActiveSchedule, + completeActiveSchedule, + rescheduleActivePlan, + scheduleFastingPlan, + setRecommendedSchedule, +} from './fastingSlice'; import moodReducer from './moodSlice'; import nutritionReducer from './nutritionSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice'; @@ -16,6 +22,7 @@ import trainingPlanReducer from './trainingPlanSlice'; import userReducer from './userSlice'; import waterReducer from './waterSlice'; import workoutReducer from './workoutSlice'; +import { persistActiveFastingSchedule } from '@/utils/fasting'; // 创建监听器中间件来处理自动同步 const listenerMiddleware = createListenerMiddleware(); @@ -45,6 +52,46 @@ syncActions.forEach(action => { }); }); +const persistFastingState = async (listenerApi: any) => { + const state = listenerApi.getState() as { fasting?: { activeSchedule?: any } }; + await persistActiveFastingSchedule(state?.fasting?.activeSchedule ?? null); +}; + +listenerMiddleware.startListening({ + actionCreator: scheduleFastingPlan, + effect: async (_, listenerApi) => { + await persistFastingState(listenerApi); + }, +}); + +listenerMiddleware.startListening({ + actionCreator: rescheduleActivePlan, + effect: async (_, listenerApi) => { + await persistFastingState(listenerApi); + }, +}); + +listenerMiddleware.startListening({ + actionCreator: completeActiveSchedule, + effect: async (_, listenerApi) => { + await persistFastingState(listenerApi); + }, +}); + +listenerMiddleware.startListening({ + actionCreator: setRecommendedSchedule, + effect: async (_, listenerApi) => { + await persistFastingState(listenerApi); + }, +}); + +listenerMiddleware.startListening({ + actionCreator: clearActiveSchedule, + effect: async () => { + await persistActiveFastingSchedule(null); + }, +}); + export const store = configureStore({ reducer: { user: userReducer, diff --git a/utils/fasting.ts b/utils/fasting.ts index 01f5c12..1bdf2a1 100644 --- a/utils/fasting.ts +++ b/utils/fasting.ts @@ -1,9 +1,10 @@ +import { FASTING_STORAGE_KEYS } from '@/constants/Fasting'; +import type { FastingSchedule } from '@/store/fastingSlice'; +import AsyncStorage from '@/utils/kvStore'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; -import AsyncStorage from '@/utils/kvStore'; -import { FASTING_STORAGE_KEYS } from '@/constants/Fasting'; dayjs.extend(duration); dayjs.extend(isSameOrAfter); @@ -100,6 +101,47 @@ export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => { }; }; +export const loadActiveFastingSchedule = async (): Promise => { + try { + const stored = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.activeSchedule); + if (!stored) return null; + + const parsed = JSON.parse(stored) as Partial; + if ( + !parsed || + typeof parsed.planId !== 'string' || + typeof parsed.startISO !== 'string' || + typeof parsed.endISO !== 'string' + ) { + return null; + } + + return { + planId: parsed.planId, + startISO: parsed.startISO, + endISO: parsed.endISO, + createdAtISO: parsed.createdAtISO ?? parsed.startISO, + updatedAtISO: parsed.updatedAtISO ?? parsed.endISO, + origin: parsed.origin ?? 'manual', + }; + } catch (error) { + console.warn('读取断食计划失败', error); + return null; + } +}; + +export const persistActiveFastingSchedule = async (schedule: FastingSchedule | null) => { + try { + if (schedule) { + await AsyncStorage.setItem(FASTING_STORAGE_KEYS.activeSchedule, JSON.stringify(schedule)); + } else { + await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.activeSchedule); + } + } catch (error) { + console.warn('保存断食计划失败', error); + } +}; + export const loadPreferredPlanId = async (): Promise => { try { return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId); @@ -118,8 +160,10 @@ export const savePreferredPlanId = async (planId: string) => { }; export type FastingNotificationIds = { - startId?: string | null; - endId?: string | null; + preStartId?: string | null; // 开始前30分钟 + startId?: string | null; // 开始时 + preEndId?: string | null; // 结束前30分钟 + endId?: string | null; // 结束时 }; export const getFastingNotificationsRegistered = async (): Promise => { @@ -146,13 +190,17 @@ export const setFastingNotificationsRegistered = async (registered: boolean) => export const loadStoredFastingNotificationIds = async (): Promise => { try { - const [startId, endId] = await Promise.all([ + const [preStartId, startId, preEndId, endId] = await Promise.all([ + AsyncStorage.getItem(FASTING_STORAGE_KEYS.preStartNotificationId), AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId), + AsyncStorage.getItem(FASTING_STORAGE_KEYS.preEndNotificationId), AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId), ]); return { + preStartId: preStartId ?? undefined, startId: startId ?? undefined, + preEndId: preEndId ?? undefined, endId: endId ?? undefined, }; } catch (error) { @@ -163,12 +211,28 @@ export const loadStoredFastingNotificationIds = async (): Promise { try { + // 保存开始前30分钟通知ID + if (ids.preStartId) { + await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preStartNotificationId, ids.preStartId); + } else { + await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId); + } + + // 保存开始时通知ID if (ids.startId) { await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId); } else { await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId); } + // 保存结束前30分钟通知ID + if (ids.preEndId) { + await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preEndNotificationId, ids.preEndId); + } else { + await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId); + } + + // 保存结束时通知ID if (ids.endId) { await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId); } else { @@ -182,7 +246,9 @@ export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => export const clearFastingNotificationIds = async () => { try { await Promise.all([ + AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId), AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId), + AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId), AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId), ]); } catch (error) { diff --git a/utils/fastingNotificationTest.ts b/utils/fastingNotificationTest.ts new file mode 100644 index 0000000..6d6461a --- /dev/null +++ b/utils/fastingNotificationTest.ts @@ -0,0 +1,341 @@ +import { FastingPlan } from '@/constants/Fasting'; +import { + ensureFastingNotificationsReady, + resyncFastingNotifications, + verifyFastingNotifications +} from '@/services/fastingNotifications'; +import { notificationService } from '@/services/notifications'; +import { FastingSchedule } from '@/store/fastingSlice'; +import { FastingNotificationIds } from '@/utils/fasting'; +import dayjs from 'dayjs'; + +/** + * 断食通知系统测试工具 + * 用于验证通知系统在各种场景下的可靠性 + */ +export class FastingNotificationTester { + private testResults: Array<{ + testName: string; + passed: boolean; + message: string; + timestamp: Date; + }> = []; + + private logResult(testName: string, passed: boolean, message: string) { + const result = { + testName, + passed, + message, + timestamp: new Date(), + }; + this.testResults.push(result); + + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${testName}: ${message}`); + } + + /** + * 获取测试结果 + */ + getTestResults() { + return this.testResults; + } + + /** + * 清除测试结果 + */ + clearResults() { + this.testResults = []; + } + + /** + * 测试通知权限检查 + */ + async testNotificationPermissions() { + try { + const ready = await ensureFastingNotificationsReady(); + this.logResult( + '通知权限检查', + ready, + ready ? '通知权限已授予' : '通知权限未授予' + ); + return ready; + } catch (error) { + this.logResult( + '通知权限检查', + false, + `权限检查失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + return false; + } + } + + /** + * 测试通知安排 + */ + async testNotificationScheduling(plan: FastingPlan) { + const now = dayjs(); + const start = now.add(1, 'hour'); + const end = start.add(plan.fastingHours, 'hour'); + + const schedule: FastingSchedule = { + planId: plan.id, + startISO: start.toISOString(), + endISO: end.toISOString(), + createdAtISO: now.toISOString(), + updatedAtISO: now.toISOString(), + origin: 'manual', + }; + + try { + const notificationIds = await resyncFastingNotifications({ + schedule, + plan, + previousIds: {}, + enabled: true, + }); + + const hasAllIds = !!(notificationIds.preStartId && notificationIds.startId && + notificationIds.preEndId && notificationIds.endId); + + this.logResult( + '通知安排测试', + hasAllIds, + hasAllIds + ? '成功安排所有四个通知点' + : `缺少通知ID: ${JSON.stringify(notificationIds)}` + ); + + return { success: hasAllIds, notificationIds }; + } catch (error) { + this.logResult( + '通知安排测试', + false, + `通知安排失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + return { success: false, notificationIds: {} }; + } + } + + /** + * 测试通知验证 + */ + async testNotificationVerification( + schedule: FastingSchedule, + plan: FastingPlan, + notificationIds: FastingNotificationIds + ) { + try { + const { isValid, updatedIds } = await verifyFastingNotifications({ + schedule, + plan, + storedIds: notificationIds, + }); + + this.logResult( + '通知验证测试', + isValid, + isValid ? '通知验证通过' : '通知验证失败,已重新同步' + ); + + return { isValid, updatedIds }; + } catch (error) { + this.logResult( + '通知验证测试', + false, + `通知验证失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + return { isValid: false, updatedIds: {} }; + } + } + + /** + * 测试通知取消 + */ + async testNotificationCancellation(notificationIds: FastingNotificationIds) { + try { + // 取消所有通知 + const cancelPromises = []; + if (notificationIds.preStartId) { + cancelPromises.push(notificationService.cancelNotification(notificationIds.preStartId)); + } + if (notificationIds.startId) { + cancelPromises.push(notificationService.cancelNotification(notificationIds.startId)); + } + if (notificationIds.preEndId) { + cancelPromises.push(notificationService.cancelNotification(notificationIds.preEndId)); + } + if (notificationIds.endId) { + cancelPromises.push(notificationService.cancelNotification(notificationIds.endId)); + } + + await Promise.all(cancelPromises); + + // 验证通知是否已取消 + const scheduledNotifications = await notificationService.getAllScheduledNotifications(); + const remainingIds = scheduledNotifications.map(n => n.identifier); + const cancelledIds = Object.values(notificationIds).filter(id => + id && !remainingIds.includes(id) + ); + + const allCancelled = cancelledIds.length === Object.values(notificationIds).filter(id => id).length; + + this.logResult( + '通知取消测试', + allCancelled, + allCancelled + ? '所有通知已成功取消' + : `部分通知未取消: ${JSON.stringify(cancelledIds)}` + ); + + return allCancelled; + } catch (error) { + this.logResult( + '通知取消测试', + false, + `通知取消失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + return false; + } + } + + /** + * 测试边界情况 + */ + async testEdgeCases(plan: FastingPlan) { + const now = dayjs(); + + // 测试1: 已经过期的断食期 + const pastStart = now.subtract(2, 'hour'); + const pastEnd = pastStart.add(plan.fastingHours, 'hour'); + + const pastSchedule: FastingSchedule = { + planId: plan.id, + startISO: pastStart.toISOString(), + endISO: pastEnd.toISOString(), + createdAtISO: now.toISOString(), + updatedAtISO: now.toISOString(), + origin: 'manual', + }; + + try { + const pastNotificationIds = await resyncFastingNotifications({ + schedule: pastSchedule, + plan, + previousIds: {}, + enabled: true, + }); + + const hasNoIds = Object.values(pastNotificationIds).every(id => !id); + + this.logResult( + '过期断食期测试', + hasNoIds, + hasNoIds ? '正确处理过期断食期,未安排通知' : '错误地为过期断食期安排了通知' + ); + } catch (error) { + this.logResult( + '过期断食期测试', + false, + `过期断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + } + + // 测试2: 即将开始的断食期(少于30分钟) + const imminentStart = now.add(15, 'minute'); + const imminentEnd = imminentStart.add(plan.fastingHours, 'hour'); + + const imminentSchedule: FastingSchedule = { + planId: plan.id, + startISO: imminentStart.toISOString(), + endISO: imminentEnd.toISOString(), + createdAtISO: now.toISOString(), + updatedAtISO: now.toISOString(), + origin: 'manual', + }; + + try { + const imminentNotificationIds = await resyncFastingNotifications({ + schedule: imminentSchedule, + plan, + previousIds: {}, + enabled: true, + }); + + // 应该只有开始时、结束前30分钟和结束时的通知 + const hasCorrectIds = !imminentNotificationIds.preStartId && + !!imminentNotificationIds.startId && + !!imminentNotificationIds.preEndId && + !!imminentNotificationIds.endId; + + this.logResult( + '即将开始断食期测试', + hasCorrectIds, + hasCorrectIds + ? '正确处理即将开始的断食期,只安排了必要的通知' + : '通知安排不正确' + ); + } catch (error) { + this.logResult( + '即将开始断食期测试', + false, + `即将开始断食期测试失败: ${error instanceof Error ? error.message : '未知错误'}` + ); + } + } + + /** + * 运行完整的测试套件 + */ + async runFullTestSuite(plan: FastingPlan) { + console.log('开始运行断食通知系统测试套件...'); + this.clearResults(); + + // 1. 测试通知权限 + const hasPermission = await this.testNotificationPermissions(); + if (!hasPermission) { + console.log('通知权限未授予,跳过其他测试'); + return this.getTestResults(); + } + + // 2. 测试通知安排 + const { success: schedulingSuccess, notificationIds } = await this.testNotificationScheduling(plan); + if (!schedulingSuccess) { + console.log('通知安排失败,跳过后续测试'); + return this.getTestResults(); + } + + // 3. 创建测试用的断食计划 + const now = dayjs(); + const start = now.add(1, 'hour'); + const end = start.add(plan.fastingHours, 'hour'); + + const schedule: FastingSchedule = { + planId: plan.id, + startISO: start.toISOString(), + endISO: end.toISOString(), + createdAtISO: now.toISOString(), + updatedAtISO: now.toISOString(), + origin: 'manual', + }; + + // 4. 测试通知验证 + await this.testNotificationVerification(schedule, plan, notificationIds); + + // 5. 测试边界情况 + await this.testEdgeCases(plan); + + // 6. 测试通知取消 + await this.testNotificationCancellation(notificationIds); + + const results = this.getTestResults(); + const passedCount = results.filter(r => r.passed).length; + const totalCount = results.length; + + console.log(`测试完成: ${passedCount}/${totalCount} 通过`); + + return results; + } +} + +// 导出测试实例 +export const fastingNotificationTester = new FastingNotificationTester(); \ No newline at end of file