diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index f16c262..928173f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -21,6 +21,7 @@ type TabConfig = { const TAB_CONFIGS: Record = { 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 = { 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() { + + + + @@ -198,6 +204,7 @@ export default function TabLayout() { > + diff --git a/app/(tabs)/fasting.tsx b/app/(tabs)/fasting.tsx new file mode 100644 index 0000000..2af8f11 --- /dev/null +++ b/app/(tabs)/fasting.tsx @@ -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(activePlan?.id ?? undefined); + const [notificationsReady, setNotificationsReady] = useState(false); + const notificationsLoadedRef = useRef(false); + const notificationIdsRef = useRef({}); + + 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 ( + + + + 轻断食 + 改善代谢 · 科学控脂 · 饮食不焦虑 + + + {currentPlan && ( + + )} + + {currentPlan && ( + + + 计划亮点 + {currentPlan.subtitle} + + {currentPlan.highlights.map((highlight) => ( + + + {highlight} + + ))} + + + 如果计划与作息不符,可重新选择方案或调整开始时间。 + + + 重置 + + + + )} + + + + + setShowPicker(false)} + initialDate={scheduleStart} + recommendedDate={recommendedDate} + onConfirm={handleConfirmStart} + /> + + ); +} + +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', + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 4bde564..285b981 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -209,6 +209,7 @@ export default function RootLayout() { + diff --git a/app/fasting/[planId].tsx b/app/fasting/[planId].tsx new file mode 100644 index 0000000..b36af2c --- /dev/null +++ b/app/fasting/[planId].tsx @@ -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 = { + 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('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 ( + + {items.map((item) => ( + + + {item} + + ))} + + ); + }; + + const fastingRatio = plan.fastingHours / 24; + + return ( + + {/* 固定悬浮的返回按钮 */} + + + {glassAvailable ? ( + + + + ) : ( + + + + )} + + + + + + + + {plan.id} + {plan.badge && ( + + {plan.badge} + + )} + + {plan.title} + {plan.subtitle} + + + + + 断食 {plan.fastingHours} 小时 + + + + + 进食 {plan.eatingHours} 小时 + + + + + + + + + + + 每日节奏 + {plan.fastingHours} h 断食 + 进食窗口 {plan.eatingHours} h + + + + + 断食期 + + + + 进食期 + + + + + + 推荐开始时间 + + + 开始 + {displayWindow.startDayLabel} + + {displayWindow.startTimeLabel} + + + + + 结束 + {displayWindow.endDayLabel} + + {displayWindow.endTimeLabel} + + + + + 推荐在晚餐后约 2 小时开始,保证进食期覆盖早餐至午后。 + + + + + {(Object.keys(TAB_LABELS) as InfoTab[]).map((tabKey) => { + const isActive = infoTab === tabKey; + return ( + setInfoTab(tabKey)} + activeOpacity={0.9} + > + + {TAB_LABELS[tabKey]} + + + ); + })} + + + {renderInfoList()} + + + + + 自定义开始时间 + + + + + 开始轻断食 + + + + + + + setShowPicker(false)} + initialDate={recommendedStart} + recommendedDate={recommendedStart} + onConfirm={handleConfirmPicker} + /> + + ); +} + +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', + }, +}); diff --git a/components/fasting/FastingOverviewCard.tsx b/components/fasting/FastingOverviewCard.tsx new file mode 100644 index 0000000..faf5f8e --- /dev/null +++ b/components/fasting/FastingOverviewCard.tsx @@ -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 ( + + + + 轻断食计划 + {plan?.id && ( + + + {plan.id} + + + )} + + {plan?.badge && ( + + + {plan.badge} + + + )} + + + + + 断食开始时间 + {startDayLabel} + {startTimeLabel} + + + + 断食结束时间 + {endDayLabel} + {endTimeLabel} + + + + + + + + {phaseLabel} + {countdownLabel} + {countdownValue} + + + + + + + + 提前开始断食 + + + {/* + 查看食谱 + */} + + + ); +} + +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', + }, +}); diff --git a/components/fasting/FastingPlanList.tsx b/components/fasting/FastingPlanList.tsx new file mode 100644 index 0000000..ba79e2b --- /dev/null +++ b/components/fasting/FastingPlanList.tsx @@ -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 = { + 新手: '适合入门', + 进阶: '脂代提升', + 强化: '平台突破', +}; + +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 ( + + + 单日计划 + + 精选 + + + + {sortedPlans.map((plan) => { + const isActive = plan.id === activePlanId; + return ( + onSelectPlan(plan)} + > + + + + {plan.difficulty} + + + {plan.badge && ( + + + {plan.badge} + + + )} + + {plan.title} + {plan.subtitle} + + + + {plan.fastingHours} 小时断食 + + + + {difficultyLabel[plan.difficulty]} + + + + ); + })} + + + ); +} + +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', + }, +}); diff --git a/components/fasting/FastingStartPickerModal.tsx b/components/fasting/FastingStartPickerModal.tsx new file mode 100644 index 0000000..2721ccd --- /dev/null +++ b/components/fasting/FastingStartPickerModal.tsx @@ -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(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 ( + + + ({ label: item.label, value: item.offset }))} + onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))} + backgroundColor="transparent" + itemTextStyle={textStyle} + selectedIndicatorStyle={pickerIndicatorStyle} + haptics + /> + ({ label: hour.toString().padStart(2, '0'), value: hour }))} + onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))} + backgroundColor="transparent" + itemTextStyle={textStyle} + selectedIndicatorStyle={pickerIndicatorStyle} + haptics + /> + ({ + label: minute.toString().padStart(2, '0'), + value: minute, + }))} + onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))} + backgroundColor="transparent" + itemTextStyle={textStyle} + selectedIndicatorStyle={pickerIndicatorStyle} + haptics + /> + + + + + 使用推荐时间 + + + 确定 + + + + ); +} + +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', + }, +}); diff --git a/components/ui/FloatingSelectionCard.tsx b/components/ui/FloatingSelectionCard.tsx index ce389dd..0538943 100644 --- a/components/ui/FloatingSelectionCard.tsx +++ b/components/ui/FloatingSelectionCard.tsx @@ -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 ( - + - + @@ -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, }, -}); \ No newline at end of file + 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)', + }, +}); diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx index 3e1ef31..a68acf2 100644 --- a/components/ui/IconSymbol.tsx +++ b/components/ui/IconSymbol.tsx @@ -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', diff --git a/constants/Fasting.ts b/constants/Fasting.ts new file mode 100644 index 0000000..f669e5d --- /dev/null +++ b/constants/Fasting.ts @@ -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(); +}; diff --git a/constants/Routes.ts b/constants/Routes.ts index f6a12d1..e784a75 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -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', diff --git a/hooks/useCountdown.ts b/hooks/useCountdown.ts new file mode 100644 index 0000000..46a40a7 --- /dev/null +++ b/hooks/useCountdown.ts @@ -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 | 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, + }; +}; diff --git a/services/fastingNotifications.ts b/services/fastingNotifications.ts new file mode 100644 index 0000000..aa9c800 --- /dev/null +++ b/services/fastingNotifications.ts @@ -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 => { + 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 => { + 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 }; diff --git a/services/notifications.ts b/services/notifications.ts index 89f9ea6..bfdfa72 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -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; // 便捷方法 diff --git a/store/fastingSlice.ts b/store/fastingSlice.ts new file mode 100644 index 0000000..4961221 --- /dev/null +++ b/store/fastingSlice.ts @@ -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); +}; diff --git a/store/index.ts b/store/index.ts index b98a3c4..6a43cb5 100644 --- a/store/index.ts +++ b/store/index.ts @@ -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; export type AppDispatch = typeof store.dispatch; - diff --git a/utils/fasting.ts b/utils/fasting.ts new file mode 100644 index 0000000..01f5c12 --- /dev/null +++ b/utils/fasting.ts @@ -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 => { + 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 => { + 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 => { + 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); + } +};