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',
|
||||
},
|
||||
});
|
||||
@@ -209,6 +209,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
|
||||
528
app/fasting/[planId].tsx
Normal file
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