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