feat(fasting): 新增轻断食功能模块

新增完整的轻断食功能,包括:
- 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划
- 断食状态实时追踪和倒计时显示
- 自定义开始时间选择器
- 断食通知提醒功能
- Redux状态管理和数据持久化
- 新增tab导航入口和路由配置
This commit is contained in:
richarjiang
2025-10-13 19:21:29 +08:00
parent 971aebd560
commit e03b2b3032
17 changed files with 2390 additions and 7 deletions

View File

@@ -21,6 +21,7 @@ type TabConfig = {
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', title: '健康' },
fasting: { icon: 'timer', title: '断食' },
goals: { icon: 'flag.fill', title: '习惯' },
challenges: { icon: 'trophy.fill', title: '挑战' },
personal: { icon: 'person.fill', title: '个人' },
@@ -36,6 +37,7 @@ export default function TabLayout() {
const isTabSelected = (routeName: string): boolean => {
const routeMap: Record<string, string> = {
statistics: ROUTES.TAB_STATISTICS,
fasting: ROUTES.TAB_FASTING,
goals: ROUTES.TAB_GOALS,
challenges: ROUTES.TAB_CHALLENGES,
personal: ROUTES.TAB_PERSONAL,
@@ -176,6 +178,10 @@ export default function TabLayout() {
<Label></Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="goals">
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
<Label></Label>
@@ -198,6 +204,7 @@ export default function TabLayout() {
>
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />

369
app/(tabs)/fasting.tsx Normal file
View File

@@ -0,0 +1,369 @@
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
import { Colors } from '@/constants/Colors';
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 {
clearActiveSchedule,
rescheduleActivePlan,
scheduleFastingPlan,
selectActiveFastingPlan,
selectActiveFastingSchedule,
} from '@/store/fastingSlice';
import {
buildDisplayWindow,
calculateFastingWindow,
getFastingPhase,
getPhaseLabel,
loadPreferredPlanId,
loadStoredFastingNotificationIds,
savePreferredPlanId,
} from '@/utils/fasting';
import type { FastingNotificationIds } from '@/utils/fasting';
import { ensureFastingNotificationsReady, resyncFastingNotifications } from '@/services/fastingNotifications';
import { getNotificationEnabled } from '@/utils/userPreferences';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } 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 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;
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]);
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]);
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 (!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]);
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 (!cancelled) {
notificationIdsRef.current = nextIds;
}
};
syncNotifications();
return () => {
cancelled = true;
};
}, [notificationsReady, activeSchedule?.startISO, activeSchedule?.endISO, currentPlan?.id]);
const handleAdjustStart = () => {
if (!currentPlan) return;
setShowPicker(true);
};
const handleConfirmStart = (date: Date) => {
if (!currentPlan) return;
if (activeSchedule) {
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
} else {
dispatch(scheduleFastingPlan({ planId: currentPlan.id, start: date, 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 (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} edges={['top', 'left', 'right']}>
<ScrollView
contentContainerStyle={[styles.scrollContainer, { paddingBottom: 32 }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<Text style={styles.screenTitle}></Text>
<Text style={styles.screenSubtitle}> · · </Text>
</View>
{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}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
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',
},
});

View File

@@ -209,6 +209,7 @@ export default function RootLayout() {
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />

528
app/fasting/[planId].tsx Normal file
View File

@@ -0,0 +1,528 @@
import { CircularRing } from '@/components/CircularRing';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
import { Colors } from '@/constants/Colors';
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
rescheduleActivePlan,
scheduleFastingPlan,
selectActiveFastingSchedule,
} from '@/store/fastingSlice';
import { buildDisplayWindow, calculateFastingWindow, savePreferredPlanId } from '@/utils/fasting';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type InfoTab = 'fit' | 'avoid' | 'intro';
const TAB_LABELS: Record<InfoTab, string> = {
fit: '适合人群',
avoid: '不适合人群',
intro: '计划介绍',
};
export default function FastingPlanDetailScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const dispatch = useAppDispatch();
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
const { planId } = useLocalSearchParams<{ planId: string }>();
const fallbackPlan = FASTING_PLANS[0];
const plan: FastingPlan = useMemo(
() => (planId ? getPlanById(planId) ?? fallbackPlan : fallbackPlan),
[planId, fallbackPlan]
);
useEffect(() => {
void savePreferredPlanId(plan.id);
}, [plan.id]);
const [infoTab, setInfoTab] = useState<InfoTab>('fit');
const [showPicker, setShowPicker] = useState(false);
const glassAvailable = isLiquidGlassAvailable();
const recommendedStart = useMemo(() => getRecommendedStart(plan), [plan]);
const window = calculateFastingWindow(recommendedStart, plan.fastingHours);
const displayWindow = buildDisplayWindow(window.start, window.end);
const handleStartWithRecommended = () => {
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart, origin: 'recommended' }));
router.replace(ROUTES.TAB_FASTING);
};
const handleOpenPicker = () => {
setShowPicker(true);
};
const handleConfirmPicker = (date: Date) => {
if (activeSchedule?.planId === plan.id) {
dispatch(rescheduleActivePlan({ start: date, origin: 'manual' }));
} else {
dispatch(scheduleFastingPlan({ planId: plan.id, start: date, origin: 'manual' }));
}
setShowPicker(false);
router.replace(ROUTES.TAB_FASTING);
};
const renderInfoList = () => {
let items: string[] = [];
if (infoTab === 'fit') items = plan.audienceFit;
if (infoTab === 'avoid') items = plan.audienceAvoid;
if (infoTab === 'intro') items = [plan.description, ...plan.nutritionTips];
return (
<View style={styles.infoList}>
{items.map((item) => (
<View key={item} style={styles.infoItem}>
<View style={[styles.infoDot, { backgroundColor: plan.theme.accent }]} />
<Text style={styles.infoText}>{item}</Text>
</View>
))}
</View>
);
};
const fastingRatio = plan.fastingHours / 24;
return (
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
{/* 固定悬浮的返回按钮 */}
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
<TouchableOpacity style={styles.backButton} onPress={router.back} activeOpacity={0.8}>
{glassAvailable ? (
<GlassView
style={styles.backButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.4)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</GlassView>
) : (
<View style={styles.backButtonFallback}>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</View>
)}
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
<LinearGradient
colors={[plan.theme.accentSecondary, plan.theme.backdrop]}
style={[styles.hero, { paddingTop: insets.top + 12 }]}
>
<View style={{
paddingTop: insets.top + 12
}}>
<View style={styles.heroHeader}>
<Text style={styles.planId}>{plan.id}</Text>
{plan.badge && (
<View style={[styles.heroBadge, { backgroundColor: `${plan.theme.accent}2B` }]}>
<Text style={[styles.heroBadgeText, { color: plan.theme.accent }]}>{plan.badge}</Text>
</View>
)}
</View>
<Text style={styles.heroTitle}>{plan.title}</Text>
<Text style={styles.heroSubtitle}>{plan.subtitle}</Text>
<View style={styles.tagRow}>
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
{plan.fastingHours}
</Text>
</View>
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
{plan.eatingHours}
</Text>
</View>
</View>
</View>
</LinearGradient>
<View style={styles.body}>
<View style={styles.chartCard}>
<CircularRing
size={190}
strokeWidth={18}
progress={fastingRatio}
progressColor={plan.theme.accent}
trackColor={plan.theme.ringTrack}
showCenterText={false}
/>
<View style={styles.chartContent}>
<Text style={styles.chartTitle}></Text>
<Text style={styles.chartValue}>{plan.fastingHours} h </Text>
<Text style={styles.chartSubtitle}> {plan.eatingHours} h</Text>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: plan.theme.accent }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: plan.theme.ringTrack }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
<View style={styles.windowCard}>
<Text style={styles.windowLabel}></Text>
<View style={styles.windowRow}>
<View style={styles.windowCell}>
<Text style={styles.windowTitle}></Text>
<Text style={styles.windowDay}>{displayWindow.startDayLabel}</Text>
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
{displayWindow.startTimeLabel}
</Text>
</View>
<View style={styles.windowDivider} />
<View style={styles.windowCell}>
<Text style={styles.windowTitle}></Text>
<Text style={styles.windowDay}>{displayWindow.endDayLabel}</Text>
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
{displayWindow.endTimeLabel}
</Text>
</View>
</View>
<Text style={styles.windowHint}>
2
</Text>
</View>
<View style={styles.tabContainer}>
{(Object.keys(TAB_LABELS) as InfoTab[]).map((tabKey) => {
const isActive = infoTab === tabKey;
return (
<TouchableOpacity
key={tabKey}
style={[
styles.tabButton,
isActive && { backgroundColor: plan.theme.accent },
]}
onPress={() => setInfoTab(tabKey)}
activeOpacity={0.9}
>
<Text
style={[
styles.tabButtonText,
{ color: isActive ? '#fff' : colors.textSecondary },
]}
>
{TAB_LABELS[tabKey]}
</Text>
</TouchableOpacity>
);
})}
</View>
{renderInfoList()}
<View style={styles.actionBlock}>
<TouchableOpacity
style={[styles.secondaryAction, { borderColor: plan.theme.accent }]}
onPress={handleOpenPicker}
activeOpacity={0.85}
>
<Text style={[styles.secondaryActionText, { color: plan.theme.accent }]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryAction, { backgroundColor: plan.theme.accent }]}
onPress={handleStartWithRecommended}
activeOpacity={0.9}
>
<Text style={styles.primaryActionText}>
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
<FastingStartPickerModal
visible={showPicker}
onClose={() => setShowPicker(false)}
initialDate={recommendedStart}
recommendedDate={recommendedStart}
onConfirm={handleConfirmPicker}
/>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
hero: {
paddingHorizontal: 24,
paddingBottom: 32,
borderBottomLeftRadius: 32,
borderBottomRightRadius: 32,
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 24,
zIndex: 10,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
backButtonGlass: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
overflow: 'hidden',
},
backButtonFallback: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.85)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.5)',
},
heroContent: {
},
heroHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
planId: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
marginRight: 12,
},
heroBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
heroBadgeText: {
fontSize: 12,
fontWeight: '600',
},
heroTitle: {
fontSize: 26,
fontWeight: '800',
color: '#2E3142',
marginBottom: 8,
},
heroSubtitle: {
fontSize: 14,
color: '#5B6572',
marginBottom: 16,
},
tagRow: {
flexDirection: 'row',
},
tagChip: {
marginRight: 10,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
},
tagChipText: {
fontSize: 12,
fontWeight: '600',
},
body: {
paddingHorizontal: 24,
paddingTop: 28,
},
chartCard: {
alignItems: 'center',
marginBottom: 24,
},
chartContent: {
position: 'absolute',
top: 70,
alignItems: 'center',
},
chartTitle: {
fontSize: 14,
color: '#6F7D87',
marginBottom: 6,
},
chartValue: {
fontSize: 20,
fontWeight: '700',
color: '#2E3142',
},
chartSubtitle: {
fontSize: 12,
color: '#6F7D87',
marginTop: 4,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 20,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 12,
},
legendDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 6,
},
legendText: {
fontSize: 12,
color: '#5B6572',
},
windowCard: {
borderRadius: 20,
backgroundColor: '#FFFFFF',
padding: 20,
marginBottom: 24,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
elevation: 3,
},
windowLabel: {
fontSize: 16,
fontWeight: '700',
color: '#2E3142',
marginBottom: 12,
},
windowRow: {
flexDirection: 'row',
alignItems: 'center',
},
windowCell: {
flex: 1,
alignItems: 'center',
},
windowTitle: {
fontSize: 12,
color: '#778290',
marginBottom: 6,
},
windowDay: {
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
},
windowTime: {
fontSize: 24,
fontWeight: '700',
marginTop: 6,
},
windowDivider: {
width: 1,
height: 60,
backgroundColor: 'rgba(95,105,116,0.2)',
},
windowHint: {
fontSize: 12,
color: '#6F7D87',
marginTop: 16,
lineHeight: 18,
},
tabContainer: {
flexDirection: 'row',
marginBottom: 20,
borderRadius: 20,
backgroundColor: '#F2F3F5',
padding: 4,
},
tabButton: {
flex: 1,
borderRadius: 16,
paddingVertical: 12,
alignItems: 'center',
},
tabButtonText: {
fontSize: 13,
fontWeight: '600',
},
infoList: {
marginBottom: 28,
},
infoItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
infoDot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 10,
marginTop: 7,
},
infoText: {
flex: 1,
fontSize: 14,
color: '#4A5460',
lineHeight: 21,
},
actionBlock: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 50,
},
secondaryAction: {
flex: 1,
borderWidth: 1.4,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
marginRight: 12,
backgroundColor: '#FFFFFF',
},
secondaryActionText: {
fontSize: 14,
fontWeight: '600',
},
primaryAction: {
flex: 1,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
},
primaryActionText: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
},
});

View File

@@ -0,0 +1,274 @@
import { CircularRing } from '@/components/CircularRing';
import { Colors } from '@/constants/Colors';
import type { FastingPlan } from '@/constants/Fasting';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import {
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type FastingOverviewCardProps = {
plan?: FastingPlan;
phaseLabel: string;
countdownLabel: string;
countdownValue: string;
startDayLabel: string;
startTimeLabel: string;
endDayLabel: string;
endTimeLabel: string;
onAdjustStartPress: () => void;
onViewMealsPress: () => void;
progress: number;
};
export function FastingOverviewCard({
plan,
phaseLabel,
countdownLabel,
countdownValue,
startDayLabel,
startTimeLabel,
endDayLabel,
endTimeLabel,
onAdjustStartPress,
onViewMealsPress,
progress,
}: FastingOverviewCardProps) {
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const themeColors = plan?.theme;
return (
<LinearGradient
colors={[
themeColors?.accentSecondary ?? colors.heroSurfaceTint,
themeColors?.backdrop ?? colors.pageBackgroundEmphasis,
]}
style={styles.container}
>
<View style={styles.headerRow}>
<View>
<Text style={styles.planLabel}></Text>
{plan?.id && (
<View style={styles.planTag}>
<Text style={[styles.planTagText, { color: themeColors?.accent ?? colors.primary }]}>
{plan.id}
</Text>
</View>
)}
</View>
{plan?.badge && (
<View style={[styles.badge, { backgroundColor: `${themeColors?.accent ?? colors.primary}20` }]}>
<Text style={[styles.badgeText, { color: themeColors?.accent ?? colors.primary }]}>
{plan.badge}
</Text>
</View>
)}
</View>
<View style={styles.scheduleRow}>
<View style={styles.scheduleCell}>
<Text style={styles.scheduleLabel}></Text>
<Text style={styles.scheduleDay}>{startDayLabel}</Text>
<Text style={styles.scheduleTime}>{startTimeLabel}</Text>
</View>
<View style={styles.separator} />
<View style={styles.scheduleCell}>
<Text style={styles.scheduleLabel}></Text>
<Text style={styles.scheduleDay}>{endDayLabel}</Text>
<Text style={styles.scheduleTime}>{endTimeLabel}</Text>
</View>
</View>
<View style={styles.statusRow}>
<View style={styles.ringContainer}>
<CircularRing
size={168}
strokeWidth={14}
progress={progress}
progressColor={themeColors?.ringProgress ?? colors.primary}
trackColor={themeColors?.ringTrack ?? 'rgba(0,0,0,0.05)'}
showCenterText={false}
startAngleDeg={-90}
resetToken={phaseLabel}
/>
<View style={styles.ringContent}>
<Text style={styles.phaseText}>{phaseLabel}</Text>
<Text style={styles.countdownLabel}>{countdownLabel}</Text>
<Text style={styles.countdownValue}>{countdownValue}</Text>
</View>
</View>
</View>
<View style={styles.actionsRow}>
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: themeColors?.accent ?? colors.primary }]}
onPress={onAdjustStartPress}
activeOpacity={0.85}
>
<Text style={[styles.secondaryButtonText, { color: themeColors?.accent ?? colors.primary }]}>
</Text>
</TouchableOpacity>
{/* <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: themeColors?.accent ?? colors.primary }]}
onPress={onViewMealsPress}
activeOpacity={0.9}
>
<Text style={styles.primaryButtonText}>查看食谱</Text>
</TouchableOpacity> */}
</View>
</LinearGradient>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 28,
paddingHorizontal: 20,
paddingVertical: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 6,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
planLabel: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
marginBottom: 6,
},
planTag: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
planTagText: {
fontSize: 13,
fontWeight: '600',
},
badge: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 18,
},
badgeText: {
fontSize: 12,
fontWeight: '600',
},
scheduleRow: {
flexDirection: 'row',
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.8)',
paddingVertical: 14,
paddingHorizontal: 16,
alignItems: 'center',
},
scheduleCell: {
flex: 1,
alignItems: 'center',
},
scheduleLabel: {
fontSize: 13,
color: '#70808E',
marginBottom: 6,
fontWeight: '500',
},
scheduleDay: {
fontSize: 16,
color: '#2E3142',
fontWeight: '600',
},
scheduleTime: {
fontSize: 24,
fontWeight: '700',
color: '#2E3142',
marginTop: 4,
},
separator: {
width: 1,
height: 52,
backgroundColor: 'rgba(112,128,142,0.22)',
},
statusRow: {
marginTop: 26,
alignItems: 'center',
},
ringContainer: {
width: 180,
height: 180,
borderRadius: 90,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
position: 'relative',
},
ringContent: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
phaseText: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
marginBottom: 8,
},
countdownLabel: {
fontSize: 12,
color: '#6F7D87',
marginBottom: 4,
},
countdownValue: {
fontSize: 20,
fontWeight: '700',
color: '#2E3142',
letterSpacing: 1,
},
actionsRow: {
marginTop: 24,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
secondaryButton: {
paddingHorizontal: 20,
borderWidth: 1.2,
borderRadius: 24,
paddingVertical: 14,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.92)',
},
secondaryButtonText: {
fontSize: 15,
fontWeight: '600',
},
primaryButton: {
flex: 1,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2E3142',
},
primaryButtonText: {
fontSize: 15,
fontWeight: '700',
color: '#fff',
},
});

View File

@@ -0,0 +1,185 @@
import type { FastingPlan } from '@/constants/Fasting';
import { Colors } from '@/constants/Colors';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { useColorScheme } from '@/hooks/useColorScheme';
import React, { useMemo } from 'react';
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type FastingPlanListProps = {
plans: FastingPlan[];
activePlanId?: string | null;
onSelectPlan: (plan: FastingPlan) => void;
};
const difficultyLabel: Record<FastingPlan['difficulty'], string> = {
: '适合入门',
: '脂代提升',
: '平台突破',
};
export function FastingPlanList({ plans, activePlanId, onSelectPlan }: FastingPlanListProps) {
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const sortedPlans = useMemo(
() => plans.slice().sort((a, b) => a.fastingHours - b.fastingHours),
[plans]
);
return (
<View style={styles.wrapper}>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerBadge}>
<Text style={styles.headerBadgeText}></Text>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{sortedPlans.map((plan) => {
const isActive = plan.id === activePlanId;
return (
<TouchableOpacity
key={plan.id}
style={[
styles.card,
{
backgroundColor: plan.theme.backdrop,
borderColor: isActive ? plan.theme.accent : 'transparent',
},
]}
activeOpacity={0.85}
onPress={() => onSelectPlan(plan)}
>
<View style={styles.cardTopRow}>
<View style={[styles.difficultyPill, { backgroundColor: `${plan.theme.accent}1A` }]}>
<Text style={[styles.difficultyText, { color: plan.theme.accent }]}>
{plan.difficulty}
</Text>
</View>
{plan.badge && (
<View style={[styles.badgePill, { backgroundColor: `${plan.theme.accent}26` }]}>
<Text style={[styles.badgeText, { color: plan.theme.accent }]}>
{plan.badge}
</Text>
</View>
)}
</View>
<Text style={styles.cardTitle}>{plan.title}</Text>
<Text style={styles.cardSubtitle}>{plan.subtitle}</Text>
<View style={styles.metaRow}>
<View style={styles.metaItem}>
<IconSymbol name="chart.pie.fill" color={colors.icon} size={16} />
<Text style={styles.metaText}>{plan.fastingHours} </Text>
</View>
<View style={styles.metaItem}>
<IconSymbol name="flag.fill" color={colors.icon} size={16} />
<Text style={styles.metaText}>{difficultyLabel[plan.difficulty]}</Text>
</View>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
marginTop: 32,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
paddingHorizontal: 4,
},
headerTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
},
headerBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: 'rgba(53, 52, 69, 0.08)',
},
headerBadgeText: {
fontSize: 12,
fontWeight: '600',
color: '#353445',
},
scrollContent: {
paddingRight: 20,
},
card: {
width: 220,
borderRadius: 24,
paddingVertical: 18,
paddingHorizontal: 18,
marginRight: 16,
borderWidth: 2,
},
cardTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
difficultyPill: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
difficultyText: {
fontSize: 12,
fontWeight: '600',
},
badgePill: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
badgeText: {
fontSize: 11,
fontWeight: '600',
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: '#2E3142',
marginBottom: 6,
},
cardSubtitle: {
fontSize: 13,
fontWeight: '500',
color: '#5B6572',
marginBottom: 12,
},
metaRow: {
marginTop: 'auto',
},
metaItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
metaText: {
marginLeft: 6,
fontSize: 12,
color: '#5B6572',
fontWeight: '500',
},
});

View File

@@ -0,0 +1,242 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import WheelPickerExpo from 'react-native-wheel-picker-expo';
import dayjs from 'dayjs';
import { FloatingSelectionCard } from '@/components/ui/FloatingSelectionCard';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
type FastingStartPickerModalProps = {
visible: boolean;
onClose: () => void;
initialDate?: Date | null;
recommendedDate?: Date | null;
onConfirm: (date: Date) => void;
};
type DayOption = {
label: string;
offset: number;
};
const buildDayOptions = (now: dayjs.Dayjs): DayOption[] => ([
{ offset: -1, label: '昨天' },
{ offset: 0, label: '今天' },
{ offset: 1, label: '明天' },
].map((item) => ({
...item,
label: item.offset === -1
? '昨天'
: item.offset === 0
? '今天'
: '明天',
})));
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
export function FastingStartPickerModal({
visible,
onClose,
initialDate,
recommendedDate,
onConfirm,
}: FastingStartPickerModalProps) {
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const now = useMemo(() => dayjs(), []);
const dayOptions = useMemo(() => buildDayOptions(now), [now]);
const deriveInitialIndexes = (source?: Date | null) => {
const seed = source ? dayjs(source) : now;
const dayDiff = seed.startOf('day').diff(now.startOf('day'), 'day');
const dayIndex = dayOptions.findIndex((option) => option.offset === dayDiff);
const hourIndex = HOURS.findIndex((hour) => hour === seed.hour());
const snappedMinute = seed.minute() - (seed.minute() % 5);
const minuteIndex = MINUTES.findIndex((minute) => minute === snappedMinute);
return {
dayIndex: dayIndex === -1 ? 1 : dayIndex,
hourIndex: hourIndex === -1 ? now.hour() : hourIndex,
minuteIndex:
minuteIndex === -1
? Math.max(0, Math.min(MINUTES.length - 1, Math.floor(seed.minute() / 5)))
: minuteIndex,
};
};
const defaultBaseRef = useRef(new Date());
const baseDate = useMemo(
() => initialDate ?? recommendedDate ?? defaultBaseRef.current,
[initialDate, recommendedDate]
);
const baseTimestamp = baseDate.getTime();
const [{ dayIndex, hourIndex, minuteIndex }, setIndexes] = useState(() =>
deriveInitialIndexes(baseDate)
);
const [pickerKey, setPickerKey] = useState(0);
const lastAppliedTimestamp = useRef<number | null>(null);
const wasVisibleRef = useRef(false);
useEffect(() => {
if (!visible) {
wasVisibleRef.current = false;
return;
}
const shouldReset =
!wasVisibleRef.current ||
lastAppliedTimestamp.current !== baseTimestamp;
if (shouldReset) {
const nextIndexes = deriveInitialIndexes(baseDate);
setIndexes(nextIndexes);
setPickerKey((prev) => prev + 1);
lastAppliedTimestamp.current = baseTimestamp;
}
wasVisibleRef.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, baseTimestamp]);
const handleConfirm = () => {
const selectedDay = dayOptions[dayIndex] ?? dayOptions[1];
const base = now.startOf('day').add(selectedDay?.offset ?? 0, 'day');
const hour = HOURS[hourIndex] ?? now.hour();
const minute = MINUTES[minuteIndex] ?? 0;
const result = base.hour(hour).minute(minute).second(0).millisecond(0);
onConfirm(result.toDate());
onClose();
};
const handleUseRecommended = () => {
if (!recommendedDate) return;
setIndexes(deriveInitialIndexes(recommendedDate));
setPickerKey((prev) => prev + 1);
lastAppliedTimestamp.current = recommendedDate.getTime();
};
const pickerIndicatorStyle = useMemo(
() => ({
backgroundColor: `${colors.primary}12`,
borderRadius: 12,
}),
[colors.primary]
);
const textStyle = {
fontSize: 18,
fontWeight: '600' as const,
color: '#2E3142',
};
return (
<FloatingSelectionCard
visible={visible}
onClose={onClose}
title="断食开始时间"
>
<View style={styles.pickerRow}>
<WheelPickerExpo
key={`day-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={dayIndex}
items={dayOptions.map((item) => ({ label: item.label, value: item.offset }))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
<WheelPickerExpo
key={`hour-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={hourIndex}
items={HOURS.map((hour) => ({ label: hour.toString().padStart(2, '0'), value: hour }))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
<WheelPickerExpo
key={`minute-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={minuteIndex}
items={MINUTES.map((minute) => ({
label: minute.toString().padStart(2, '0'),
value: minute,
}))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
</View>
<View style={styles.footerRow}>
<TouchableOpacity
style={[styles.recommendButton, { borderColor: colors.primary }]}
onPress={handleUseRecommended}
activeOpacity={0.85}
>
<Text style={[styles.recommendButtonText, { color: colors.primary }]}>使</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.confirmButton, { backgroundColor: colors.primary }]}
onPress={handleConfirm}
activeOpacity={0.9}
>
<Text style={styles.confirmButtonText}></Text>
</TouchableOpacity>
</View>
</FloatingSelectionCard>
);
}
const styles = StyleSheet.create({
pickerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginBottom: 24,
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
recommendButton: {
flex: 1,
borderWidth: 1.2,
borderRadius: 24,
paddingVertical: 12,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.95)',
},
recommendButtonText: {
fontSize: 14,
fontWeight: '600',
},
confirmButton: {
flex: 1,
borderRadius: 24,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
confirmButtonText: {
fontSize: 15,
fontWeight: '700',
color: '#fff',
},
});

View File

@@ -1,5 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import React from 'react';
import {
Modal,
@@ -22,6 +23,21 @@ export function FloatingSelectionCard({
title,
children
}: FloatingSelectionCardProps) {
const glassAvailable = isLiquidGlassAvailable();
const CloseWrapper = glassAvailable ? GlassView : View;
const closeInnerStyle = [
styles.closeButtonInnerBase,
glassAvailable ? styles.closeButtonInnerGlass : styles.closeButtonInnerFallback,
];
const closeWrapperProps = glassAvailable
? {
glassEffectStyle: 'regular' as const,
tintColor: 'rgba(255,255,255,0.45)',
isInteractive: true,
}
: {};
return (
<Modal
visible={visible}
@@ -52,9 +68,9 @@ export function FloatingSelectionCard({
onPress={onClose}
activeOpacity={0.7}
>
<View style={styles.closeButtonInner}>
<CloseWrapper style={closeInnerStyle} {...closeWrapperProps}>
<Ionicons name="close" size={24} color="#666" />
</View>
</CloseWrapper>
</TouchableOpacity>
</View>
</BlurView>
@@ -67,6 +83,7 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'flex-end',
alignItems: 'center',
paddingHorizontal: 20,
},
backdrop: {
position: 'absolute',
@@ -103,11 +120,10 @@ const styles = StyleSheet.create({
closeButton: {
marginTop: 20,
},
closeButtonInner: {
closeButtonInnerBase: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
@@ -119,4 +135,12 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 3,
},
});
closeButtonInnerGlass: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.45)',
backgroundColor: 'rgba(255,255,255,0.35)',
},
closeButtonInnerFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
});

View File

@@ -18,6 +18,10 @@ const MAPPING = {
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
'chart.pie.fill': 'pie-chart',
'flag.fill': 'flag',
'trophy.fill': 'emoji-events',
'timer': 'timer',
'person.fill': 'person',
'person.3.fill': 'people',
'message.fill': 'message',

206
constants/Fasting.ts Normal file
View File

@@ -0,0 +1,206 @@
import dayjs from 'dayjs';
export type FastingDifficulty = '新手' | '进阶' | '强化';
export type FastingPlan = {
id: string;
title: string;
subtitle: string;
badge?: string;
difficulty: FastingDifficulty;
fastingHours: number;
eatingHours: number;
recommendedStartHour: number;
recommendedStartMinute?: number;
description: string;
highlights: string[];
audienceFit: string[];
audienceAvoid: string[];
nutritionTips: string[];
theme: {
backdrop: string;
accent: string;
accentSecondary: string;
ringTrack: string;
ringProgress: string;
};
};
export const FASTING_STORAGE_KEYS = {
preferredPlanId: '@fasting_preferred_plan',
notificationsRegistered: '@fasting_notifications_registered',
startNotificationId: '@fasting_notification_start_id',
endNotificationId: '@fasting_notification_end_id',
} as const;
export const FASTING_PLANS: FastingPlan[] = [
{
id: '12-12',
title: '12-12 新手体验计划',
subtitle: '进食12小时 · 断食12小时',
badge: '新手',
difficulty: '新手',
fastingHours: 12,
eatingHours: 12,
recommendedStartHour: 20,
description: '帮助初次尝试轻断食的用户建立稳定节奏,温和过渡到代谢切换。',
highlights: [
'维持血糖稳定,减少夜间加餐',
'保护基础代谢率,循序渐进更易坚持',
'适合与轻度有氧、舒缓训练搭配',
],
audienceFit: [
'没有断食经验的初学者',
'作息规律、希望改善饮食结构的用户',
'需要缓解水肿或控制晚间进食的群体',
],
audienceAvoid: [
'孕期、哺乳期人群',
'血糖波动较大或正在服用降糖药者',
'近期有胃病或严重胃酸倒流史者',
],
nutritionTips: [
'进食期优先安排高纤维蔬菜 + 优质蛋白 + 复杂碳水搭配',
'保持足量饮水,可搭配无糖茶饮、电解质水',
'睡前2小时避免高糖、高脂肪食物降低夜间胃酸',
],
theme: {
backdrop: '#EEF5E3',
accent: '#99B75F',
accentSecondary: '#F6F7ED',
ringTrack: 'rgba(153, 183, 95, 0.18)',
ringProgress: '#9FBB62',
},
},
{
id: '14-10',
title: '14-10 轻断食计划',
subtitle: '进食10小时 · 断食14小时',
badge: '热门',
difficulty: '新手',
fastingHours: 14,
eatingHours: 10,
recommendedStartHour: 19,
description: '黄金入门比例,兼顾脂肪代谢与日常社交,适合作为长期饮食模式。',
highlights: [
'夜间空腹时长更长,有利于提高睡眠质量',
'降低胰岛素分泌频率,优化血脂指标',
'搭配力量训练,可提升瘦体重占比',
],
audienceFit: [
'需要控制体脂、保持肌力的办公人群',
'晚餐时间较固定的家庭用户',
'希望巩固作息、减少夜宵的用户',
],
audienceAvoid: [
'体重过低 (BMI < 18.5) 或营养不良者',
'高强度训练日需额外摄入能量的运动员',
'正在恢复期的慢性病患者(需医师评估)',
],
nutritionTips: [
'进食窗口内保持 3 餐或 2 餐 1 加餐的均衡结构',
'早餐建议加入优质蛋白与低 GI 碳水,避免血糖波动',
'断食期可饮用黑咖啡、无糖茶饮,补充矿物质避免疲劳',
],
theme: {
backdrop: '#F8F2DD',
accent: '#E4B74B',
accentSecondary: '#FBF5E2',
ringTrack: 'rgba(228, 183, 75, 0.18)',
ringProgress: '#E8B649',
},
},
{
id: '16-8',
title: '16-8 进阶燃脂计划',
subtitle: '进食8小时 · 断食16小时',
badge: '进阶',
difficulty: '进阶',
fastingHours: 16,
eatingHours: 8,
recommendedStartHour: 18,
description: '经典进阶版,帮助维持较长脂肪供能区间,适合已有断食基础的用户。',
highlights: [
'延长生长激素分泌窗口,辅助保持肌肉',
'强化脂肪动员,改善胰岛素敏感性',
'与力量训练、HIIT 等组合,塑形效率更高',
],
audienceFit: [
'有一定断食经验,希望进一步控制体脂',
'每日有规律运动或力量训练基础的用户',
'午晚餐可在 12:00-20:00 完成的职场人士',
],
audienceAvoid: [
'近期有暴饮暴食或进食障碍史者',
'工作强度大且需要夜间补充能量的岗位',
'肝胆问题或血糖控制异常者需遵医嘱执行',
],
nutritionTips: [
'进食期注意蛋白质分配,建议每餐 ≥25g 蛋白',
'提前准备低油低糖食谱,避免进食窗口暴食',
'断食期补充电解质与维生素C缓解疲劳与饥饿感',
],
theme: {
backdrop: '#EFE8FD',
accent: '#8E75E1',
accentSecondary: '#F4EDFF',
ringTrack: 'rgba(142, 117, 225, 0.18)',
ringProgress: '#8F78E0',
},
},
{
id: '18-6',
title: '18-6 脂代强化计划',
subtitle: '进食6小时 · 断食18小时',
badge: '高阶',
difficulty: '强化',
fastingHours: 18,
eatingHours: 6,
recommendedStartHour: 17,
description: '针对塑形巩固与体重停滞期用户,提供高强度代谢刺激方案。',
highlights: [
'强化脂肪氧化,提升线粒体效率',
'帮助突破减脂平台期,维持血脂稳态',
'适合与周期力量训练、冷热交替等手段配合',
],
audienceFit: [
'已有 8 周以上断食经验且生理指标稳定者',
'体重调节遇到平台期的进阶用户',
'可在 10:30-16:30 内完成主要餐食的用户',
],
audienceAvoid: [
'甲状腺功能异常或血糖控制不佳者',
'夜班、倒班等作息紊乱人群',
'有过度节食史或需要高能量摄入的用户',
],
nutritionTips: [
'确保进食窗口内能量充足,搭配足量优质脂肪与蛋白',
'安排训练前后加餐,保障恢复与肌肉合成',
'断食期适量补充电解质、绿叶蔬菜汁,避免头晕疲劳',
],
theme: {
backdrop: '#F7E3E5',
accent: '#E26A7F',
accentSecondary: '#FCE9EC',
ringTrack: 'rgba(226, 106, 127, 0.18)',
ringProgress: '#E06B80',
},
},
];
export const getPlanById = (planId: string) =>
FASTING_PLANS.find((plan) => plan.id === planId);
export const getRecommendedStart = (plan: FastingPlan, baseDate: Date = new Date()) => {
const start = dayjs(baseDate)
.hour(plan.recommendedStartHour)
.minute(plan.recommendedStartMinute ?? 0)
.second(0)
.millisecond(0);
// If the recommended time already passed today, schedule for tomorrow
if (start.isBefore(dayjs())) {
return start.add(1, 'day').toDate();
}
return start.toDate();
};

View File

@@ -7,6 +7,7 @@ export const ROUTES = {
TAB_STATISTICS: '/statistics',
TAB_CHALLENGES: '/challenges',
TAB_PERSONAL: '/personal',
TAB_FASTING: '/fasting',
// 训练相关路由
WORKOUT_TODAY: '/workout/today',
@@ -53,6 +54,9 @@ export const ROUTES = {
WATER_SETTINGS: '/water/settings',
WATER_REMINDER_SETTINGS: '/water/reminder-settings',
// 轻断食相关
FASTING_PLAN_DETAIL: '/fasting',
// 任务相关路由
TASK_DETAIL: '/task-detail',
@@ -81,6 +85,7 @@ export const ROUTE_PARAMS = {
// 任务参数
TASK_ID: 'taskId',
FASTING_PLAN_ID: 'planId',
// 重定向参数
REDIRECT_TO: 'redirectTo',

54
hooks/useCountdown.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { formatCountdown } from '@/utils/fasting';
interface CountdownOptions {
target: Date | null | undefined;
intervalMs?: number;
autoStart?: boolean;
}
export const useCountdown = ({ target, intervalMs = 1000, autoStart = true }: CountdownOptions) => {
const [now, setNow] = useState(() => new Date());
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const targetTimestamp = target instanceof Date ? target.getTime() : null;
useEffect(() => {
if (!autoStart) return undefined;
if (targetTimestamp == null) return undefined;
timerRef.current && clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setNow(new Date());
}, intervalMs);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [targetTimestamp, intervalMs, autoStart]);
useEffect(() => {
if (targetTimestamp == null) return;
setNow(new Date());
}, [targetTimestamp]);
const diffMs = useMemo(() => {
if (targetTimestamp == null) return 0;
return targetTimestamp - now.getTime();
}, [targetTimestamp, now]);
const formatted = useMemo(() => {
if (targetTimestamp == null) return '--:--:--';
return formatCountdown(new Date(targetTimestamp), now);
}, [targetTimestamp, now]);
return {
now,
diffMs,
formatted,
isExpired: diffMs <= 0,
hasTarget: targetTimestamp != null,
};
};

View File

@@ -0,0 +1,162 @@
import dayjs from 'dayjs';
import { FastingPlan } from '@/constants/Fasting';
import { FastingSchedule } from '@/store/fastingSlice';
import {
clearFastingNotificationIds,
getFastingNotificationsRegistered,
loadStoredFastingNotificationIds,
saveFastingNotificationIds,
setFastingNotificationsRegistered,
FastingNotificationIds,
} from '@/utils/fasting';
import { getNotificationEnabled } from '@/utils/userPreferences';
import { notificationService, NotificationTypes } from './notifications';
const REMINDER_OFFSET_MINUTES = 10;
const cancelNotificationIds = async (ids?: FastingNotificationIds) => {
if (!ids) return;
const { startId, endId } = ids;
try {
if (startId) {
await notificationService.cancelNotification(startId);
}
} catch (error) {
console.warn('取消断食开始提醒失败', error);
}
try {
if (endId) {
await notificationService.cancelNotification(endId);
}
} catch (error) {
console.warn('取消断食结束提醒失败', error);
}
};
export const ensureFastingNotificationsReady = async (): Promise<boolean> => {
try {
const notificationsEnabled = await getNotificationEnabled();
if (!notificationsEnabled) {
return false;
}
const registered = await getFastingNotificationsRegistered();
if (registered) {
return true;
}
const status = await notificationService.getPermissionStatus();
if (status !== 'granted') {
const requestStatus = await notificationService.requestPermission();
if (requestStatus !== 'granted') {
return false;
}
}
await setFastingNotificationsRegistered(true);
return true;
} catch (error) {
console.warn('初始化断食通知失败', error);
return false;
}
};
type ResyncOptions = {
schedule: FastingSchedule | null;
plan?: FastingPlan;
previousIds?: FastingNotificationIds;
enabled: boolean;
};
export const resyncFastingNotifications = async ({
schedule,
plan,
previousIds,
enabled,
}: ResyncOptions): Promise<FastingNotificationIds> => {
const storedIds = previousIds ?? (await loadStoredFastingNotificationIds());
await cancelNotificationIds(storedIds);
if (!enabled) {
await setFastingNotificationsRegistered(false);
await clearFastingNotificationIds();
return {};
}
const preferenceEnabled = await getNotificationEnabled();
if (!preferenceEnabled) {
await setFastingNotificationsRegistered(false);
await clearFastingNotificationIds();
return {};
}
if (!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 notificationIds: FastingNotificationIds = {};
if (start.isAfter(now)) {
const preStart = start.subtract(REMINDER_OFFSET_MINUTES, 'minute');
const triggerMoment = preStart.isAfter(now) ? preStart : start;
try {
const startId = await notificationService.scheduleNotificationAtDate(
{
title: `${plan.title} 即将开始`,
body: preStart.isAfter(now)
? `还有 ${REMINDER_OFFSET_MINUTES} 分钟就要进入断食窗口,喝一杯温水,准备迎接更轻盈的自己!`
: `现在开始 ${plan.title},放下零食,给身体一次真正的休息时间。`,
data: {
type: NotificationTypes.FASTING_START,
planId: plan.id,
},
sound: true,
priority: 'high',
},
triggerMoment.toDate()
);
notificationIds.startId = startId;
} catch (error) {
console.error('安排断食开始通知失败', error);
}
}
if (end.isAfter(now)) {
try {
const endId = await notificationService.scheduleNotificationAtDate(
{
title: '补能时刻到啦',
body: `${plan.title} 已完成!用一顿高蛋白 + 低GI 的餐食奖励自己,让代谢持续高效运转。`,
data: {
type: NotificationTypes.FASTING_END,
planId: plan.id,
},
sound: true,
priority: 'high',
},
end.toDate()
);
notificationIds.endId = endId;
} catch (error) {
console.error('安排断食结束通知失败', error);
}
}
await saveFastingNotificationIds(notificationIds);
return notificationIds;
};
export type { FastingNotificationIds };

View File

@@ -1,3 +1,4 @@
import { ROUTES } from '@/constants/Routes';
import { getNotificationEnabled } from '@/utils/userPreferences';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
@@ -5,7 +6,6 @@ import { router } from 'expo-router';
// 配置通知处理方式
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
@@ -179,6 +179,8 @@ export class NotificationService {
if (data?.url) {
router.push(data.url as any);
}
} else if (data?.type === NotificationTypes.FASTING_START || data?.type === NotificationTypes.FASTING_END) {
router.push(ROUTES.TAB_FASTING as any);
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
// 处理锻炼完成通知
console.log('用户点击了锻炼完成通知', data);
@@ -517,6 +519,8 @@ export const NotificationTypes = {
REGULAR_WATER_REMINDER: 'regular_water_reminder',
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
WORKOUT_COMPLETION: 'workout_completion',
FASTING_START: 'fasting_start',
FASTING_END: 'fasting_end',
} as const;
// 便捷方法

126
store/fastingSlice.ts Normal file
View File

@@ -0,0 +1,126 @@
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 type { RootState } from './index';
export type FastingScheduleOrigin = 'manual' | 'recommended' | 'quick-start';
export type FastingSchedule = {
planId: string;
startISO: string;
endISO: string;
createdAtISO: string;
updatedAtISO: string;
origin: FastingScheduleOrigin;
};
type FastingState = {
activeSchedule: FastingSchedule | null;
history: FastingSchedule[];
};
const initialState: FastingState = {
activeSchedule: null,
history: [],
};
const fastingSlice = createSlice({
name: 'fasting',
initialState,
reducers: {
scheduleFastingPlan: (
state,
action: PayloadAction<{ planId: string; start: Date; origin?: FastingScheduleOrigin }>
) => {
const plan = getPlanById(action.payload.planId);
if (!plan) return;
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
const nowISO = new Date().toISOString();
state.activeSchedule = {
planId: plan.id,
startISO: start.toISOString(),
endISO: end.toISOString(),
createdAtISO: nowISO,
updatedAtISO: nowISO,
origin: action.payload.origin ?? 'manual',
};
},
rescheduleActivePlan: (
state,
action: PayloadAction<{ start: Date; origin?: FastingScheduleOrigin }>
) => {
if (!state.activeSchedule) return;
const plan = getPlanById(state.activeSchedule.planId);
if (!plan) return;
const { start, end } = calculateFastingWindow(action.payload.start, plan.fastingHours);
state.activeSchedule = {
...state.activeSchedule,
startISO: start.toISOString(),
endISO: end.toISOString(),
updatedAtISO: new Date().toISOString(),
origin: action.payload.origin ?? state.activeSchedule.origin,
};
},
setRecommendedSchedule: (
state,
action: PayloadAction<{ planId: string; recommendedStart: Date }>
) => {
const plan = getPlanById(action.payload.planId);
if (!plan) return;
const { start, end } = calculateFastingWindow(action.payload.recommendedStart, plan.fastingHours);
const nowISO = new Date().toISOString();
state.activeSchedule = {
planId: plan.id,
startISO: start.toISOString(),
endISO: end.toISOString(),
createdAtISO: nowISO,
updatedAtISO: nowISO,
origin: 'recommended',
};
},
completeActiveSchedule: (state) => {
if (!state.activeSchedule) return;
const phase = getFastingPhase(
new Date(state.activeSchedule.startISO),
new Date(state.activeSchedule.endISO)
);
if (phase === 'fasting') {
// Allow manual completion only when fasting window已经结束
state.activeSchedule = {
...state.activeSchedule,
endISO: dayjs().toISOString(),
updatedAtISO: new Date().toISOString(),
};
}
state.history.unshift(state.activeSchedule);
state.activeSchedule = null;
},
clearActiveSchedule: (state) => {
state.activeSchedule = null;
},
},
});
export const {
scheduleFastingPlan,
rescheduleActivePlan,
setRecommendedSchedule,
completeActiveSchedule,
clearActiveSchedule,
} = fastingSlice.actions;
export default fastingSlice.reducer;
export const selectFastingRoot = (state: RootState) => state.fasting;
export const selectActiveFastingSchedule = (state: RootState) => state.fasting.activeSchedule;
export const selectFastingPlans = () => FASTING_PLANS;
export const selectActiveFastingPlan = (state: RootState): FastingPlan | undefined => {
const schedule = state.fasting.activeSchedule;
if (!schedule) return undefined;
return getPlanById(schedule.planId);
};

View File

@@ -7,6 +7,7 @@ import foodLibraryReducer from './foodLibrarySlice';
import foodRecognitionReducer from './foodRecognitionSlice';
import goalsReducer from './goalsSlice';
import healthReducer from './healthSlice';
import fastingReducer from './fastingSlice';
import moodReducer from './moodSlice';
import nutritionReducer from './nutritionSlice';
import scheduleExerciseReducer from './scheduleExerciseSlice';
@@ -62,6 +63,7 @@ export const store = configureStore({
foodRecognition: foodRecognitionReducer,
workout: workoutReducer,
water: waterReducer,
fasting: fastingReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
@@ -69,4 +71,3 @@ export const store = configureStore({
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

191
utils/fasting.ts Normal file
View File

@@ -0,0 +1,191 @@
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);
dayjs.extend(isSameOrBefore);
export type FastingPhase = 'upcoming' | 'fasting' | 'completed';
export const calculateFastingWindow = (start: Date, fastingHours: number) => {
const startDate = dayjs(start).second(0).millisecond(0);
const endDate = startDate.add(fastingHours, 'hour');
return {
start: startDate.toDate(),
end: endDate.toDate(),
};
};
export const getFastingPhase = (start?: Date | null, end?: Date | null, now: Date = new Date()): FastingPhase => {
if (!start || !end) {
return 'completed';
}
const nowJs = dayjs(now);
const startJs = dayjs(start);
const endJs = dayjs(end);
if (nowJs.isBefore(startJs)) {
return 'upcoming';
}
if (nowJs.isSameOrAfter(startJs) && nowJs.isBefore(endJs)) {
return 'fasting';
}
return 'completed';
};
export const getPhaseLabel = (phase: FastingPhase) => {
switch (phase) {
case 'fasting':
return '断食中';
case 'upcoming':
return '可饮食';
case 'completed':
return '可饮食';
default:
return '可饮食';
}
};
export const formatDayDescriptor = (date: Date | null | undefined, now: Date = new Date()) => {
if (!date) return '--';
const target = dayjs(date);
const today = dayjs(now).startOf('day');
if (target.isSame(today, 'day')) {
return '今天';
}
if (target.isSame(today.subtract(1, 'day'), 'day')) {
return '昨天';
}
if (target.isSame(today.add(1, 'day'), 'day')) {
return '明天';
}
return target.format('MM-DD');
};
export const formatTime = (date: Date | null | undefined) => {
if (!date) return '--:--';
return dayjs(date).format('HH:mm');
};
export const formatCountdown = (target: Date | null | undefined, now: Date = new Date()) => {
if (!target) return '--:--:--';
const diff = dayjs(target).diff(now);
if (diff <= 0) {
return '00:00:00';
}
const dur = dayjs.duration(diff);
const hours = String(Math.floor(dur.asHours())).padStart(2, '0');
const minutes = String(dur.minutes()).padStart(2, '0');
const seconds = String(dur.seconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
};
export const buildDisplayWindow = (start?: Date | null, end?: Date | null) => {
return {
startDayLabel: formatDayDescriptor(start ?? null),
startTimeLabel: formatTime(start ?? null),
endDayLabel: formatDayDescriptor(end ?? null),
endTimeLabel: formatTime(end ?? null),
};
};
export const loadPreferredPlanId = async (): Promise<string | null> => {
try {
return await AsyncStorage.getItem(FASTING_STORAGE_KEYS.preferredPlanId);
} catch (error) {
console.warn('加载断食首选计划ID失败', error);
return null;
}
};
export const savePreferredPlanId = async (planId: string) => {
try {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.preferredPlanId, planId);
} catch (error) {
console.warn('保存断食首选计划ID失败', error);
}
};
export type FastingNotificationIds = {
startId?: string | null;
endId?: string | null;
};
export const getFastingNotificationsRegistered = async (): Promise<boolean> => {
try {
const value = await AsyncStorage.getItem(FASTING_STORAGE_KEYS.notificationsRegistered);
return value === 'true';
} catch (error) {
console.warn('读取断食通知注册状态失败', error);
return false;
}
};
export const setFastingNotificationsRegistered = async (registered: boolean) => {
try {
if (registered) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.notificationsRegistered, 'true');
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.notificationsRegistered);
}
} catch (error) {
console.warn('更新断食通知注册状态失败', error);
}
};
export const loadStoredFastingNotificationIds = async (): Promise<FastingNotificationIds> => {
try {
const [startId, endId] = await Promise.all([
AsyncStorage.getItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.getItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
return {
startId: startId ?? undefined,
endId: endId ?? undefined,
};
} catch (error) {
console.warn('读取断食通知ID失败', error);
return {};
}
};
export const saveFastingNotificationIds = async (ids: FastingNotificationIds) => {
try {
if (ids.startId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.startNotificationId, ids.startId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId);
}
if (ids.endId) {
await AsyncStorage.setItem(FASTING_STORAGE_KEYS.endNotificationId, ids.endId);
} else {
await AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId);
}
} catch (error) {
console.warn('保存断食通知ID失败', error);
}
};
export const clearFastingNotificationIds = async () => {
try {
await Promise.all([
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.startNotificationId),
AsyncStorage.removeItem(FASTING_STORAGE_KEYS.endNotificationId),
]);
} catch (error) {
console.warn('清除断食通知ID失败', error);
}
};