feat(fasting): 新增轻断食功能模块
新增完整的轻断食功能,包括: - 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划 - 断食状态实时追踪和倒计时显示 - 自定义开始时间选择器 - 断食通知提醒功能 - Redux状态管理和数据持久化 - 新增tab导航入口和路由配置
This commit is contained in:
@@ -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
369
app/(tabs)/fasting.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user