Files
digital-pilates/app/fasting/[planId].tsx
richarjiang e03b2b3032 feat(fasting): 新增轻断食功能模块
新增完整的轻断食功能,包括:
- 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划
- 断食状态实时追踪和倒计时显示
- 自定义开始时间选择器
- 断食通知提醒功能
- Redux状态管理和数据持久化
- 新增tab导航入口和路由配置
2025-10-13 19:21:29 +08:00

529 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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',
},
});