feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑 - 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时 - 添加通知验证机制,确保通知正确设置和避免重复 - 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项 - 实现断食计划持久化存储,应用重启后自动恢复 - 添加开发者测试面板用于验证通知系统可靠性 - 优化通知同步策略,支持选择性更新减少不必要的操作 - 修复个人页面编辑按钮样式问题 - 更新应用版本号至 1.0.18
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user