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": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.17",
"version": "1.0.18",
"orientation": "portrait",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",

View File

@@ -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,

View File

@@ -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',

View File

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

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 { 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>
);
}

View File

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

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 = {
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[] = [

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>"; };
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>";
};

View File

@@ -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>

View File

@@ -14,5 +14,7 @@
<array>
<string>health-records</string>
</array>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
</dict>
</plist>

View File

@@ -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 };

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 { 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,

View File

@@ -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,

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 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) {

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