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

@@ -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;
}
};
verifyPreference();
return () => {
cancelled = true;
};
}, [notificationsReady]);
if (notificationError) {
console.warn('断食通知错误:', notificationError);
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
}
}, [notificationError]);
const recommendedDate = useMemo(() => {
if (!currentPlan) return undefined;
return getRecommendedStart(currentPlan);
}, [currentPlan]);
// 调试信息(开发环境)
useEffect(() => {
let cancelled = false;
const syncNotifications = async () => {
if (!notificationsLoadedRef.current) {
const storedIds = await loadStoredFastingNotificationIds();
if (cancelled) return;
notificationIdsRef.current = storedIds;
notificationsLoadedRef.current = true;
}
const nextIds = await resyncFastingNotifications({
schedule: activeSchedule ?? null,
plan: notificationsReady ? currentPlan : undefined,
previousIds: notificationIdsRef.current,
enabled: notificationsReady,
if (__DEV__ && lastSyncTime) {
console.log('断食通知状态:', {
ready: notificationsReady,
loading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
schedule: activeSchedule?.startISO,
plan: currentPlan?.id,
});
if (!cancelled) {
notificationIdsRef.current = nextIds;
}
};
syncNotifications();
return () => {
cancelled = true;
};
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
}
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
const handleAdjustStart = () => {
if (!currentPlan) return;
@@ -199,9 +162,9 @@ export default function FastingTabScreen() {
const handleConfirmStart = (date: Date) => {
if (!currentPlan) return;
if (activeSchedule) {
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else {
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date, origin: 'manual' }));
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date.toISOString(), origin: 'manual' }));
}
};
@@ -218,9 +181,12 @@ export default function FastingTabScreen() {
};
return (
<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,