feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
This commit is contained in:
2
app.json
2
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",
|
||||
|
||||
@@ -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<string | undefined>(activePlan?.id ?? undefined);
|
||||
const [notificationsReady, setNotificationsReady] = useState(false);
|
||||
const notificationsLoadedRef = useRef(false);
|
||||
const notificationIdsRef = useRef<FastingNotificationIds>({});
|
||||
|
||||
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;
|
||||
if (notificationError) {
|
||||
console.warn('断食通知错误:', notificationError);
|
||||
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
|
||||
}
|
||||
};
|
||||
|
||||
verifyPreference();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [notificationsReady]);
|
||||
}, [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 (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}>
|
||||
<View style={[styles.safeArea]}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]}
|
||||
contentContainerStyle={[styles.scrollContainer, {
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 120
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
@@ -228,6 +194,13 @@ export default function FastingTabScreen() {
|
||||
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||||
</View>
|
||||
|
||||
{/* 通知错误提示 */}
|
||||
<NotificationErrorAlert
|
||||
error={notificationError}
|
||||
onRetry={forceSync}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
|
||||
{currentPlan && (
|
||||
<FastingOverviewCard
|
||||
plan={currentPlan}
|
||||
@@ -281,17 +254,19 @@ export default function FastingTabScreen() {
|
||||
recommendedDate={recommendedDate}
|
||||
onConfirm={handleConfirmStart}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white'
|
||||
},
|
||||
scrollContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
|
||||
},
|
||||
headerRow: {
|
||||
marginBottom: 20,
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function PersonalScreen() {
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<GlassView style={styles.editButtonGlass}>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<LogEntry[]>([]);
|
||||
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() {
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>日志 ({logs.length})</Text>
|
||||
<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}>
|
||||
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
||||
</TouchableOpacity>
|
||||
@@ -186,6 +195,13 @@ export default function LogsScreen() {
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 断食通知测试面板 */}
|
||||
{showTestPanel && (
|
||||
<FastingNotificationTestPanel
|
||||
onClose={() => setShowTestPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
229
components/developer/FastingNotificationTestPanel.tsx
Normal file
229
components/developer/FastingNotificationTestPanel.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
81
components/ui/NotificationErrorAlert.tsx
Normal file
81
components/ui/NotificationErrorAlert.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
184
hooks/useFastingNotifications.ts
Normal file
184
hooks/useFastingNotifications.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -30,7 +30,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -135,7 +135,6 @@
|
||||
08BACF4D920A957DC2FE4350 /* Pods-OutLive.debug.xcconfig */,
|
||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.17</string>
|
||||
<string>1.0.18</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -55,10 +55,12 @@
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>应用需要使用相机以拍摄您的体态照片用于AI测评。</string>
|
||||
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
|
||||
<string>Read and understand clinical health data.</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。</string>
|
||||
<string>Read and understand health data.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string>
|
||||
<string>Share workout data with other apps.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
@@ -98,12 +100,5 @@
|
||||
<string>Light</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>Read and understand health data.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>Share workout data with other apps.</string>
|
||||
<!-- Below is only required if requesting clinical health data -->
|
||||
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
|
||||
<string>Read and understand clinical health data.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -14,5 +14,7 @@
|
||||
<array>
|
||||
<string>health-records</string>
|
||||
</array>
|
||||
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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 startId = await notificationService.scheduleNotificationAtDate(
|
||||
const preStartId = await notificationService.scheduleNotificationAtDate(
|
||||
{
|
||||
title: `${plan.title} 即将开始`,
|
||||
body: preStart.isAfter(now)
|
||||
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
|
||||
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
|
||||
body: `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`,
|
||||
data: {
|
||||
type: NotificationTypes.FASTING_START,
|
||||
planId: plan.id,
|
||||
subtype: 'pre_start',
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
},
|
||||
triggerMoment.toDate()
|
||||
preStart.toDate()
|
||||
);
|
||||
notificationIds.startId = startId;
|
||||
notificationIds.preStartId = preStartId;
|
||||
} catch (error) {
|
||||
console.error('安排断食开始通知失败', error);
|
||||
console.error('安排断食开始前30分钟通知失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 安排开始时通知
|
||||
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()
|
||||
);
|
||||
notificationIds.startId = startId;
|
||||
} catch (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<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 };
|
||||
|
||||
@@ -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<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: (
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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> => {
|
||||
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<boolean> => {
|
||||
@@ -146,13 +190,17 @@ export const setFastingNotificationsRegistered = async (registered: boolean) =>
|
||||
|
||||
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
|
||||
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<FastingNotific
|
||||
|
||||
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
|
||||
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) {
|
||||
|
||||
341
utils/fastingNotificationTest.ts
Normal file
341
utils/fastingNotificationTest.ts
Normal 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();
|
||||
Reference in New Issue
Block a user