feat(fasting): 重构断食通知系统并增强可靠性

- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑
- 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时
- 添加通知验证机制,确保通知正确设置和避免重复
- 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项
- 实现断食计划持久化存储,应用重启后自动恢复
- 添加开发者测试面板用于验证通知系统可靠性
- 优化通知同步策略,支持选择性更新减少不必要的操作
- 修复个人页面编辑按钮样式问题
- 更新应用版本号至 1.0.18
This commit is contained in:
richarjiang
2025-10-14 15:05:11 +08:00
parent e03b2b3032
commit cf069f3537
18 changed files with 1565 additions and 242 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.0.17", "version": "1.0.18",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -1,13 +1,12 @@
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard'; import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
import { FastingPlanList } from '@/components/fasting/FastingPlanList'; import { FastingPlanList } from '@/components/fasting/FastingPlanList';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal'; 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 { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useFocusEffect } from '@react-navigation/native';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCountdown } from '@/hooks/useCountdown'; import { useCountdown } from '@/hooks/useCountdown';
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
import { import {
clearActiveSchedule, clearActiveSchedule,
rescheduleActivePlan, rescheduleActivePlan,
@@ -21,31 +20,24 @@ import {
getFastingPhase, getFastingPhase,
getPhaseLabel, getPhaseLabel,
loadPreferredPlanId, loadPreferredPlanId,
loadStoredFastingNotificationIds, savePreferredPlanId
savePreferredPlanId,
} from '@/utils/fasting'; } from '@/utils/fasting';
import type { FastingNotificationIds } from '@/utils/fasting'; import { useFocusEffect } from '@react-navigation/native';
import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications';
import { getNotificationEnabled } from '@/utils/userPreferences';
import { useRouter } from 'expo-router'; 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 { 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() { export default function FastingTabScreen() {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const theme = useColorScheme() ?? 'light'; const insets = useSafeAreaInsets();
const colorTokens = Colors[theme];
const activeSchedule = useAppSelector(selectActiveFastingSchedule); const activeSchedule = useAppSelector(selectActiveFastingSchedule);
const activePlan = useAppSelector(selectActiveFastingPlan); const activePlan = useAppSelector(selectActiveFastingPlan);
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0]; const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined); const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(activePlan?.id ?? undefined);
const [notificationsReady, setNotificationsReady] = useState(false);
const notificationsLoadedRef = useRef(false);
const notificationIdsRef = useRef<FastingNotificationIds>({});
useEffect(() => { useEffect(() => {
if (!activePlan?.id) return; if (!activePlan?.id) return;
@@ -75,34 +67,31 @@ export default function FastingTabScreen() {
}; };
}, [activePlan?.id]); }, [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(() => { const currentPlan: FastingPlan | undefined = useMemo(() => {
if (activePlan) return activePlan; if (activePlan) return activePlan;
if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan; if (preferredPlanId) return getPlanById(preferredPlanId) ?? defaultPlan;
return defaultPlan; return defaultPlan;
}, [activePlan, preferredPlanId, 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(() => { const scheduleStart = useMemo(() => {
if (activeSchedule) return new Date(activeSchedule.startISO); if (activeSchedule) return new Date(activeSchedule.startISO);
if (currentPlan) return getRecommendedStart(currentPlan); if (currentPlan) return getRecommendedStart(currentPlan);
@@ -137,59 +126,33 @@ export default function FastingTabScreen() {
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
// 显示通知错误(如果有)
useEffect(() => { useEffect(() => {
if (!notificationsReady) return; if (notificationError) {
console.warn('断食通知错误:', notificationError);
let cancelled = false; // 可以在这里添加用户提示,比如 Toast 或 Snackbar
const verifyPreference = async () => { }
const enabled = await getNotificationEnabled(); }, [notificationError]);
if (!cancelled && !enabled) {
setNotificationsReady(false);
notificationsLoadedRef.current = false;
}
};
verifyPreference();
return () => {
cancelled = true;
};
}, [notificationsReady]);
const recommendedDate = useMemo(() => { const recommendedDate = useMemo(() => {
if (!currentPlan) return undefined; if (!currentPlan) return undefined;
return getRecommendedStart(currentPlan); return getRecommendedStart(currentPlan);
}, [currentPlan]); }, [currentPlan]);
// 调试信息(开发环境)
useEffect(() => { useEffect(() => {
let cancelled = false; if (__DEV__ && lastSyncTime) {
console.log('断食通知状态:', {
const syncNotifications = async () => { ready: notificationsReady,
if (!notificationsLoadedRef.current) { loading: notificationsLoading,
const storedIds = await loadStoredFastingNotificationIds(); error: notificationError,
if (cancelled) return; notificationIds,
notificationIdsRef.current = storedIds; lastSyncTime,
notificationsLoadedRef.current = true; schedule: activeSchedule?.startISO,
} plan: currentPlan?.id,
const nextIds = await resyncFastingNotifications({
schedule: activeSchedule ?? null,
plan: notificationsReady ? currentPlan : undefined,
previousIds: notificationIdsRef.current,
enabled: notificationsReady,
}); });
}
if (!cancelled) { }, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
notificationIdsRef.current = nextIds;
}
};
syncNotifications();
return () => {
cancelled = true;
};
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
const handleAdjustStart = () => { const handleAdjustStart = () => {
if (!currentPlan) return; if (!currentPlan) return;
@@ -199,9 +162,9 @@ export default function FastingTabScreen() {
const handleConfirmStart = (date: Date) => { const handleConfirmStart = (date: Date) => {
if (!currentPlan) return; if (!currentPlan) return;
if (activeSchedule) { if (activeSchedule) {
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' })); dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else { } 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 ( return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}> <View style={[styles.safeArea]}>
<ScrollView <ScrollView
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]} contentContainerStyle={[styles.scrollContainer, {
paddingTop: insets.top,
paddingBottom: 120
}]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.headerRow}> <View style={styles.headerRow}>
@@ -228,6 +194,13 @@ export default function FastingTabScreen() {
<Text style={styles.screenSubtitle}> · · </Text> <Text style={styles.screenSubtitle}> · · </Text>
</View> </View>
{/* 通知错误提示 */}
<NotificationErrorAlert
error={notificationError}
onRetry={forceSync}
onDismiss={clearError}
/>
{currentPlan && ( {currentPlan && (
<FastingOverviewCard <FastingOverviewCard
plan={currentPlan} plan={currentPlan}
@@ -281,17 +254,19 @@ export default function FastingTabScreen() {
recommendedDate={recommendedDate} recommendedDate={recommendedDate}
onConfirm={handleConfirmStart} onConfirm={handleConfirmStart}
/> />
</SafeAreaView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safeArea: { safeArea: {
flex: 1, flex: 1,
backgroundColor: 'white'
}, },
scrollContainer: { scrollContainer: {
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 12, paddingTop: 12,
}, },
headerRow: { headerRow: {
marginBottom: 20, marginBottom: 20,

View File

@@ -219,7 +219,7 @@ export default function PersonalScreen() {
{isLgAvaliable ? ( {isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}> <GlassView style={styles.editButtonGlass}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text> <Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
</GlassView> </GlassView>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@@ -511,6 +511,11 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
editButtonText: { editButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)', color: 'rgba(147, 112, 219, 1)',
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',

View File

@@ -15,9 +15,11 @@ import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords'; import { WaterRecordSource } from '@/services/waterRecords';
import { workoutMonitorService } from '@/services/workoutMonitor'; import { workoutMonitorService } from '@/services/workoutMonitor';
import { store } from '@/store'; import { store } from '@/store';
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice'; import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { loadActiveFastingSchedule } from '@/utils/fasting';
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
@@ -35,12 +37,42 @@ import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) { function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { profile } = useAppSelector((state) => state.user); const { profile } = useAppSelector((state) => state.user);
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const { isLoggedIn } = useAuthGuard() const { isLoggedIn } = useAuthGuard()
const fastingHydrationRequestedRef = React.useRef(false);
// 初始化快捷动作处理 // 初始化快捷动作处理
useQuickActions(); 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(() => { useEffect(() => {
if (isLoggedIn) { if (isLoggedIn) {
dispatch(fetchChallenges()); dispatch(fetchChallenges());

View File

@@ -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 { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -19,6 +20,7 @@ export default function LogsScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [showTestPanel, setShowTestPanel] = useState(false);
const loadLogs = async () => { const loadLogs = async () => {
try { try {
@@ -68,6 +70,10 @@ export default function LogsScreen() {
} }
}; };
const handleTestNotifications = () => {
setShowTestPanel(true);
};
useEffect(() => { useEffect(() => {
loadLogs(); loadLogs();
// 添加测试日志 // 添加测试日志
@@ -148,6 +154,9 @@ export default function LogsScreen() {
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.title}> ({logs.length})</Text> <Text style={styles.title}> ({logs.length})</Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<TouchableOpacity onPress={handleTestNotifications} style={styles.actionButton}>
<Ionicons name="notifications-outline" size={20} color="#FF8800" />
</TouchableOpacity>
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}> <TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
<Ionicons name="share-outline" size={20} color="#9370DB" /> <Ionicons name="share-outline" size={20} color="#9370DB" />
</TouchableOpacity> </TouchableOpacity>
@@ -186,6 +195,13 @@ export default function LogsScreen() {
</View> </View>
} }
/> />
{/* 断食通知测试面板 */}
{showTestPanel && (
<FastingNotificationTestPanel
onClose={() => setShowTestPanel(false)}
/>
)}
</View> </View>
); );
} }

View File

@@ -55,7 +55,7 @@ export default function FastingPlanDetailScreen() {
const displayWindow = buildDisplayWindow(window.start, window.end); const displayWindow = buildDisplayWindow(window.start, window.end);
const handleStartWithRecommended = () => { 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); router.replace(ROUTES.TAB_FASTING);
}; };
@@ -65,9 +65,9 @@ export default function FastingPlanDetailScreen() {
const handleConfirmPicker = (date: Date) => { const handleConfirmPicker = (date: Date) => {
if (activeSchedule?.planId === plan.id) { if (activeSchedule?.planId === plan.id) {
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' })); dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else { } else {
dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' })); dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' }));
} }
setShowPicker(false); setShowPicker(false);
router.replace(ROUTES.TAB_FASTING); router.replace(ROUTES.TAB_FASTING);

View File

@@ -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<FastingNotificationTestPanelProps> = ({
onClose,
}) => {
const [isRunning, setIsRunning] = useState(false);
const [testResults, setTestResults] = useState<Array<{
testName: string;
passed: boolean;
message: string;
timestamp: Date;
}>>([]);
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 (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}></Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
{FASTING_PLANS.map((plan) => (
<TouchableOpacity
key={plan.id}
style={[styles.planButton, isRunning && styles.disabledButton]}
onPress={() => runTest(plan)}
disabled={isRunning}
>
<Text style={styles.planButtonText}>{plan.title}</Text>
<Text style={styles.planSubtitle}>{plan.subtitle}</Text>
</TouchableOpacity>
))}
</View>
{testResults.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity style={styles.clearButton} onPress={clearResults}>
<Text style={styles.clearButtonText}></Text>
</TouchableOpacity>
</View>
{testResults.map((result, index) => (
<View key={index} style={[
styles.resultItem,
result.passed ? styles.passedResult : styles.failedResult
]}>
<Text style={styles.resultTestName}>{result.testName}</Text>
<Text style={styles.resultMessage}>{result.message}</Text>
<Text style={styles.resultTime}>
{result.timestamp.toLocaleTimeString()}
</Text>
</View>
))}
</View>
)}
{isRunning && (
<View style={styles.section}>
<Text style={styles.runningText}>...</Text>
</View>
)}
</ScrollView>
</View>
);
};
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',
},
});

View File

@@ -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<NotificationErrorAlertProps> = ({
error,
onRetry,
onDismiss,
}) => {
if (!error) return null;
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}></Text>
<Text style={styles.message}>{error}</Text>
<View style={styles.actions}>
{onRetry && (
<TouchableOpacity style={styles.button} onPress={onRetry}>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
)}
{onDismiss && (
<TouchableOpacity style={[styles.button, styles.dismissButton]} onPress={onDismiss}>
<Text style={styles.buttonText}></Text>
</TouchableOpacity>
)}
</View>
</View>
</View>
);
};
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',
},
});

View File

@@ -29,8 +29,11 @@ export type FastingPlan = {
export const FASTING_STORAGE_KEYS = { export const FASTING_STORAGE_KEYS = {
preferredPlanId: '@fasting_preferred_plan', preferredPlanId: '@fasting_preferred_plan',
notificationsRegistered: '@fasting_notifications_registered', notificationsRegistered: '@fasting_notifications_registered',
preStartNotificationId: '@fasting_notification_pre_start_id',
startNotificationId: '@fasting_notification_start_id', startNotificationId: '@fasting_notification_start_id',
preEndNotificationId: '@fasting_notification_pre_end_id',
endNotificationId: '@fasting_notification_end_id', endNotificationId: '@fasting_notification_end_id',
activeSchedule: '@fasting_active_schedule',
} as const; } as const;
export const FASTING_PLANS: FastingPlan[] = [ export const FASTING_PLANS: FastingPlan[] = [

View File

@@ -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<void>;
forceSync: () => Promise<void>;
clearError: () => void;
}
export const useFastingNotifications = (
schedule: FastingSchedule | null,
plan: FastingPlan | undefined
): UseFastingNotificationsState & UseFastingNotificationsActions => {
const [state, setState] = useState<UseFastingNotificationsState>({
isReady: false,
isLoading: true,
error: null,
notificationIds: {},
lastSyncTime: null,
});
const isInitializedRef = useRef(false);
const notificationIdsRef = useRef<FastingNotificationIds>({});
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,
};
};

View File

@@ -30,7 +30,7 @@
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; }; 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; 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 = "<group>"; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
@@ -135,7 +135,6 @@
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */, 08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */, 9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };

View File

@@ -1,109 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array> <array>
<string>com.expo.modules.backgroundtask.processing</string> <string>com.expo.modules.backgroundtask.processing</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleAllowMixedLocalizations</key> <key>CFBundleAllowMixedLocalizations</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Out Live</string> <string>Out Live</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.17</string> <string>1.0.18</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>digitalpilates</string> <string>digitalpilates</string>
<string>com.anonymous.digitalpilates</string> <string>com.anonymous.digitalpilates</string>
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>1</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<false/> <false/>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string> <string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
<key>NSHealthShareUsageDescription</key> <key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
<string>应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。</string> <string>Read and understand clinical health data.</string>
<key>NSHealthUpdateUsageDescription</key> <key>NSHealthShareUsageDescription</key>
<string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string> <string>Read and understand health data.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSHealthUpdateUsageDescription</key>
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string> <string>Share workout data with other apps.</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>应用需要写入相册以保存拍摄的体态照片(可选)。</string> <string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryAddUsageDescription</key>
<string>应用需要访问相册以选择您的体态照片用于AI测评。</string> <string>应用需要写入相册以保存拍摄的体态照片(可选)。</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。</string> <string>应用需要访问相册以选择您的体态照片用于AI测评。</string>
<key>NSUserActivityTypes</key> <key>NSSpeechRecognitionUsageDescription</key>
<array> <string>应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <key>NSUserActivityTypes</key>
</array> <array>
<key>NSUserNotificationsUsageDescription</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
<string>应用需要发送通知以提醒您喝水和站立活动。</string> </array>
<key>RCTNewArchEnabled</key> <key>NSUserNotificationsUsageDescription</key>
<true/> <string>应用需要发送通知以提醒您喝水和站立活动。</string>
<key>UIBackgroundModes</key> <key>RCTNewArchEnabled</key>
<array> <true/>
<string>fetch</string> <key>UIBackgroundModes</key>
</array> <array>
<key>UILaunchStoryboardName</key> <string>fetch</string>
<string>SplashScreen</string> </array>
<key>UIRequiredDeviceCapabilities</key> <key>UILaunchStoryboardName</key>
<array> <string>SplashScreen</string>
<string>arm64</string> <key>UIRequiredDeviceCapabilities</key>
</array> <array>
<key>UIRequiresFullScreen</key> <string>arm64</string>
<false/> </array>
<key>UIStatusBarStyle</key> <key>UIRequiresFullScreen</key>
<string>UIStatusBarStyleDefault</string> <false/>
<key>UISupportedInterfaceOrientations</key> <key>UIStatusBarStyle</key>
<array> <string>UIStatusBarStyleDefault</string>
<string>UIInterfaceOrientationPortrait</string> <key>UISupportedInterfaceOrientations</key>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <array>
</array> <string>UIInterfaceOrientationPortrait</string>
<key>UIUserInterfaceStyle</key> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>Light</string> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIUserInterfaceStyle</key>
<false/> <string>Light</string>
<key>NSHealthShareUsageDescription</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<string>Read and understand health data.</string> <false/>
<key>NSHealthUpdateUsageDescription</key> </dict>
<string>Share workout data with other apps.</string> </plist>
<!-- Below is only required if requesting clinical health data -->
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
<string>Read and understand clinical health data.</string>
</dict>
</plist>

View File

@@ -1,18 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.applesignin</key> <key>com.apple.developer.applesignin</key>
<array> <array>
<string>Default</string> <string>Default</string>
</array> </array>
<key>com.apple.developer.healthkit</key> <key>com.apple.developer.healthkit</key>
<true/> <true/>
<key>com.apple.developer.healthkit.access</key> <key>com.apple.developer.healthkit.access</key>
<array> <array>
<string>health-records</string> <string>health-records</string>
</array> </array>
</dict> <key>com.apple.developer.healthkit.background-delivery</key>
</plist> <true/>
</dict>
</plist>

View File

@@ -1,37 +1,59 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as Notifications from 'expo-notifications';
import { FastingPlan } from '@/constants/Fasting'; import { FastingPlan } from '@/constants/Fasting';
import { FastingSchedule } from '@/store/fastingSlice'; import { FastingSchedule } from '@/store/fastingSlice';
import { import {
clearFastingNotificationIds, clearFastingNotificationIds,
FastingNotificationIds,
getFastingNotificationsRegistered, getFastingNotificationsRegistered,
loadStoredFastingNotificationIds, loadStoredFastingNotificationIds,
saveFastingNotificationIds, saveFastingNotificationIds,
setFastingNotificationsRegistered, setFastingNotificationsRegistered,
FastingNotificationIds,
} from '@/utils/fasting'; } from '@/utils/fasting';
import { getNotificationEnabled } from '@/utils/userPreferences'; import { getNotificationEnabled } from '@/utils/userPreferences';
import { notificationService, NotificationTypes } from './notifications'; import { notificationService, NotificationTypes } from './notifications';
const REMINDER_OFFSET_MINUTES = 10; const REMINDER_OFFSET_MINUTES = 30; // 改为30分钟提醒
const cancelNotificationIds = async (ids?: FastingNotificationIds) => { const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
if (!ids) return; 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 { try {
if (startId) { if (startId) {
await notificationService.cancelNotification(startId); await notificationService.cancelNotification(startId);
} }
} catch (error) { } catch (error) {
console.warn('取消断食开始提醒失败', error); console.warn('取消断食开始提醒失败', error);
} }
// 取消结束前30分钟通知
try {
if (preEndId) {
await notificationService.cancelNotification(preEndId);
}
} catch (error) {
console.warn('取消断食结束前30分钟提醒失败', error);
}
// 取消结束时通知
try { try {
if (endId) { if (endId) {
await notificationService.cancelNotification(endId); await notificationService.cancelNotification(endId);
} }
} catch (error) { } catch (error) {
console.warn('取消断食结束提醒失败', error); console.warn('取消断食结束提醒失败', error);
} }
}; };
@@ -108,33 +130,83 @@ export const resyncFastingNotifications = async ({
const notificationIds: FastingNotificationIds = {}; const notificationIds: FastingNotificationIds = {};
// 1. 安排开始前30分钟通知
if (start.isAfter(now)) { if (start.isAfter(now)) {
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute'); 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 { try {
const startId = await notificationService.scheduleNotificationAtDate( const startId = await notificationService.scheduleNotificationAtDate(
{ {
title: `${plan.title} 即将开始`, title: `${plan.title} 开始`,
body: preStart.isAfter(now) body: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
data: { data: {
type: NotificationTypes.FASTING_START, type: NotificationTypes.FASTING_START,
planId: plan.id, planId: plan.id,
subtype: 'start',
}, },
sound: true, sound: true,
priority: 'high', priority: 'high',
}, },
triggerMoment.toDate() start.toDate()
); );
notificationIds.startId = startId; notificationIds.startId = startId;
} catch (error) { } catch (error) {
console.error('安排断食开始通知失败', error); console.error('安排断食开始通知失败', error);
} }
} }
// 3. 安排结束前30分钟通知
if (end.isAfter(now)) { 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 { try {
const endId = await notificationService.scheduleNotificationAtDate( const endId = await notificationService.scheduleNotificationAtDate(
{ {
@@ -143,6 +215,7 @@ export const resyncFastingNotifications = async ({
data: { data: {
type: NotificationTypes.FASTING_END, type: NotificationTypes.FASTING_END,
planId: plan.id, planId: plan.id,
subtype: 'end',
}, },
sound: true, sound: true,
priority: 'high', priority: 'high',
@@ -151,7 +224,7 @@ export const resyncFastingNotifications = async ({
); );
notificationIds.endId = endId; notificationIds.endId = endId;
} catch (error) { } catch (error) {
console.error('安排断食结束通知失败', error); console.error('安排断食结束通知失败', error);
} }
} }
@@ -159,4 +232,255 @@ export const resyncFastingNotifications = async ({
return notificationIds; 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<FastingNotificationIds> => {
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 }; export type { FastingNotificationIds };

View File

@@ -1,7 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting'; import { FASTING_PLANS, FastingPlan, getPlanById } from '@/constants/Fasting';
import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting'; import { calculateFastingWindow, getFastingPhase } from '@/utils/fasting';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import dayjs from 'dayjs';
import type { RootState } from './index'; import type { RootState } from './index';
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start'; export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start';
@@ -29,14 +29,35 @@ const fastingSlice = createSlice({
name: 'fasting', name: 'fasting',
initialState, initialState,
reducers: { reducers: {
hydrateActiveSchedule: (
state,
action: PayloadAction<FastingSchedule | null>
) => {
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: ( scheduleFastingPlan: (
state, state,
action: PayloadAction<{ planId: string; start: Date; origin?: FastingScheduleOrigin }> action: PayloadAction<{ planId: string; start: string; origin?: FastingScheduleOrigin }>
) => { ) => {
const plan = getPlanById(action.payload.planId); const plan = getPlanById(action.payload.planId);
if (!plan) return; 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(); const nowISO = new Date().toISOString();
state.activeSchedule = { state.activeSchedule = {
planId: plan.id, planId: plan.id,
@@ -49,13 +70,14 @@ const fastingSlice = createSlice({
}, },
rescheduleActivePlan: ( rescheduleActivePlan: (
state, state,
action: PayloadAction<{ start: Date; origin?: FastingScheduleOrigin }> action: PayloadAction<{ start: string; origin?: FastingScheduleOrigin }>
) => { ) => {
if (!state.activeSchedule) return; if (!state.activeSchedule) return;
const plan = getPlanById(state.activeSchedule.planId); const plan = getPlanById(state.activeSchedule.planId);
if (!plan) return; 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 = {
...state.activeSchedule, ...state.activeSchedule,
startISO: start.toISOString(), startISO: start.toISOString(),
@@ -66,11 +88,12 @@ const fastingSlice = createSlice({
}, },
setRecommendedSchedule: ( setRecommendedSchedule: (
state, state,
action: PayloadAction<{ planId: string; recommendedStart: Date }> action: PayloadAction<{ planId: string; recommendedStart: string }>
) => { ) => {
const plan = getPlanById(action.payload.planId); const plan = getPlanById(action.payload.planId);
if (!plan) return; 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(); const nowISO = new Date().toISOString();
state.activeSchedule = { state.activeSchedule = {
planId: plan.id, planId: plan.id,
@@ -107,6 +130,7 @@ const fastingSlice = createSlice({
}); });
export const { export const {
hydrateActiveSchedule,
scheduleFastingPlan, scheduleFastingPlan,
rescheduleActivePlan, rescheduleActivePlan,
setRecommendedSchedule, setRecommendedSchedule,

View File

@@ -7,7 +7,13 @@ import foodLibraryReducer from './foodLibrarySlice';
import foodRecognitionReducer from './foodRecognitionSlice'; import foodRecognitionReducer from './foodRecognitionSlice';
import goalsReducer from './goalsSlice'; import goalsReducer from './goalsSlice';
import healthReducer from './healthSlice'; import healthReducer from './healthSlice';
import fastingReducer from './fastingSlice'; import fastingReducer, {
clearActiveSchedule,
completeActiveSchedule,
rescheduleActivePlan,
scheduleFastingPlan,
setRecommendedSchedule,
} from './fastingSlice';
import moodReducer from './moodSlice'; import moodReducer from './moodSlice';
import nutritionReducer from './nutritionSlice'; import nutritionReducer from './nutritionSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice'; import scheduleExerciseReducer from './scheduleExerciseSlice';
@@ -16,6 +22,7 @@ import trainingPlanReducer from './trainingPlanSlice';
import userReducer from './userSlice'; import userReducer from './userSlice';
import waterReducer from './waterSlice'; import waterReducer from './waterSlice';
import workoutReducer from './workoutSlice'; import workoutReducer from './workoutSlice';
import { persistActiveFastingSchedule } from '@/utils/fasting';
// 创建监听器中间件来处理自动同步 // 创建监听器中间件来处理自动同步
const listenerMiddleware = createListenerMiddleware(); 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({ export const store = configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,

View File

@@ -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 dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import AsyncStorage from '@/utils/kvStore';
import { FASTING_STORAGE_KEYS } from '@/constants/Fasting';
dayjs.extend(duration); dayjs.extend(duration);
dayjs.extend(isSameOrAfter); dayjs.extend(isSameOrAfter);
@@ -100,6 +101,47 @@ export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
}; };
}; };
export const loadActiveFastingSchedule = async (): Promise<FastingSchedule | null> => {
try {
const stored = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.activeSchedule);
if (!stored) return null;
const parsed = JSON.parse(stored) as Partial<FastingSchedule>;
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<string | null> => { export const loadPreferredPlanId = async (): Promise<string | null> => {
try { try {
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId); return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
@@ -118,8 +160,10 @@ export const savePreferredPlanId = async (planId: string) => {
}; };
export type FastingNotificationIds = { export type FastingNotificationIds = {
startId?: string | null; preStartId?: string | null; // 开始前30分钟
endId?: string | null; startId?: string | null; // 开始时
preEndId?: string | null; // 结束前30分钟
endId?: string | null; // 结束时
}; };
export const getFastingNotificationsRegistered = async (): Promise<boolean> => { export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
@@ -146,13 +190,17 @@ export const setFastingNotificationsRegistered = async (registered: boolean) =>
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => { export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
try { 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.startNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId), AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
]); ]);
return { return {
preStartId: preStartId ?? undefined,
startId: startId ?? undefined, startId: startId ?? undefined,
preEndId: preEndId ?? undefined,
endId: endId ?? undefined, endId: endId ?? undefined,
}; };
} catch (error) { } catch (error) {
@@ -163,12 +211,28 @@ export const loadStoredFastingNotificationIds = async (): Promise<FastingNotific
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => { export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
try { 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) { if (ids.startId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId); await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
} else { } else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId); 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) { if (ids.endId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId); await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
} else { } else {
@@ -182,7 +246,9 @@ export const saveFastingNotificationIds = async (ids: FastingNotificationIds) =>
export const clearFastingNotificationIds = async () => { export const clearFastingNotificationIds = async () => {
try { try {
await Promise.all([ await Promise.all([
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preStartNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId), AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.preEndNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId), AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
]); ]);
} catch (error) { } catch (error) {

View File

@@ -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();