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

345 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
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 { useCountdown } from '@/hooks/useCountdown';
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
import {
clearActiveSchedule,
rescheduleActivePlan,
scheduleFastingPlan,
selectActiveFastingPlan,
selectActiveFastingSchedule,
} from '@/store/fastingSlice';
import {
buildDisplayWindow,
calculateFastingWindow,
getFastingPhase,
getPhaseLabel,
loadPreferredPlanId,
savePreferredPlanId
} from '@/utils/fasting';
import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FastingTabScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
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);
useEffect(() => {
if (!activePlan?.id) return;
setPreferredPlanId(activePlan.id);
void savePreferredPlanId(activePlan.id);
}, [activePlan?.id]);
useEffect(() => {
let cancelled = false;
const hydratePreferredPlan = async () => {
try {
const savedPlanId = await loadPreferredPlanId();
if (cancelled) return;
if (activePlan?.id) return;
if (savedPlanId && getPlanById(savedPlanId)) {
setPreferredPlanId(savedPlanId);
}
} catch (error) {
console.warn('读取断食首选计划失败', error);
}
};
hydratePreferredPlan();
return () => {
cancelled = true;
};
}, [activePlan?.id]);
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);
return undefined;
}, [activeSchedule, currentPlan]);
const scheduleEnd = useMemo(() => {
if (activeSchedule && currentPlan) {
return new Date(activeSchedule.endISO);
}
if (currentPlan && scheduleStart) {
return calculateFastingWindow(scheduleStart, currentPlan.fastingHours).end;
}
return undefined;
}, [activeSchedule, currentPlan, scheduleStart]);
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
const { formatted: countdownValue } = useCountdown({ target: countdownTarget ?? null });
const progress = useMemo(() => {
if (!scheduleStart || !scheduleEnd) return 0;
const total = scheduleEnd.getTime() - scheduleStart.getTime();
if (total <= 0) return 0;
const now = Date.now();
if (now <= scheduleStart.getTime()) return 0;
if (now >= scheduleEnd.getTime()) return 1;
return (now - scheduleStart.getTime()) / total;
}, [scheduleStart, scheduleEnd]);
const displayWindow = buildDisplayWindow(scheduleStart ?? null, scheduleEnd ?? null);
const [showPicker, setShowPicker] = useState(false);
// 显示通知错误(如果有)
useEffect(() => {
if (notificationError) {
console.warn('断食通知错误:', notificationError);
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
}
}, [notificationError]);
const recommendedDate = useMemo(() => {
if (!currentPlan) return undefined;
return getRecommendedStart(currentPlan);
}, [currentPlan]);
// 调试信息(开发环境)
useEffect(() => {
if (__DEV__ && lastSyncTime) {
console.log('断食通知状态:', {
ready: notificationsReady,
loading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
schedule: activeSchedule?.startISO,
plan: currentPlan?.id,
});
}
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, currentPlan?.id]);
const handleAdjustStart = () => {
if (!currentPlan) return;
setShowPicker(true);
};
const handleConfirmStart = (date: Date) => {
if (!currentPlan) return;
if (activeSchedule) {
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else {
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date.toISOString(), origin: 'manual' }));
}
};
const handleSelectPlan = (plan: FastingPlan) => {
router.push(`${ROUTES.FASTING_PLAN_DETAIL}/${plan.id}`);
};
const handleViewMeal = () => {
router.push(ROUTES.FOOD_LIBRARY);
};
const handleResetPlan = () => {
dispatch(clearActiveSchedule());
};
return (
<View style={[styles.safeArea]}>
<ScrollView
contentContainerStyle={[styles.scrollContainer, {
paddingTop: insets.top,
paddingBottom: 120
}]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<Text style={styles.screenTitle}></Text>
<Text style={styles.screenSubtitle}> · · </Text>
</View>
{/* 通知错误提示 */}
<NotificationErrorAlert
error={notificationError}
onRetry={forceSync}
onDismiss={clearError}
/>
{currentPlan && (
<FastingOverviewCard
plan={currentPlan}
phaseLabel={getPhaseLabel(phase)}
countdownLabel={phase === 'fasting' ? '距离进食还有' : '距离断食还有'}
countdownValue={countdownValue}
startDayLabel={displayWindow.startDayLabel}
startTimeLabel={displayWindow.startTimeLabel}
endDayLabel={displayWindow.endDayLabel}
endTimeLabel={displayWindow.endTimeLabel}
onAdjustStartPress={handleAdjustStart}
onViewMealsPress={handleViewMeal}
progress={progress}
/>
)}
{currentPlan && (
<View style={styles.highlightCard}>
<View style={styles.highlightHeader}>
<Text style={styles.highlightTitle}></Text>
<Text style={styles.highlightSubtitle}>{currentPlan.subtitle}</Text>
</View>
{currentPlan.highlights.map((highlight) => (
<View key={highlight} style={styles.highlightItem}>
<View style={[styles.highlightDot, { backgroundColor: currentPlan.theme.accent }]} />
<Text style={styles.highlightText}>{highlight}</Text>
</View>
))}
<View style={styles.resetRow}>
<Text style={styles.resetHint}>
</Text>
<Text style={styles.resetAction} onPress={handleResetPlan}>
</Text>
</View>
</View>
)}
<FastingPlanList
plans={FASTING_PLANS}
activePlanId={activePlan?.id ?? currentPlan?.id}
onSelectPlan={handleSelectPlan}
/>
</ScrollView>
<FastingStartPickerModal
visible={showPicker}
onClose={() => setShowPicker(false)}
initialDate={scheduleStart}
recommendedDate={recommendedDate}
onConfirm={handleConfirmStart}
/>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: 'white'
},
scrollContainer: {
paddingHorizontal: 20,
paddingTop: 12,
},
headerRow: {
marginBottom: 20,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#2E3142',
marginBottom: 6,
},
screenSubtitle: {
fontSize: 14,
color: '#6F7D87',
fontWeight: '500',
},
highlightCard: {
marginTop: 28,
padding: 20,
borderRadius: 24,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.06,
shadowRadius: 20,
elevation: 4,
},
highlightHeader: {
marginBottom: 12,
},
highlightTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
},
highlightSubtitle: {
fontSize: 13,
color: '#6F7D87',
marginTop: 6,
},
highlightItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
highlightDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 7,
marginRight: 10,
},
highlightText: {
flex: 1,
fontSize: 14,
color: '#4A5460',
lineHeight: 20,
},
resetRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
},
resetHint: {
flex: 1,
fontSize: 12,
color: '#8A96A3',
marginRight: 12,
},
resetAction: {
fontSize: 12,
fontWeight: '600',
color: '#6366F1',
},
});