feat(fasting): 新增轻断食功能模块

新增完整的轻断食功能,包括:
- 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划
- 断食状态实时追踪和倒计时显示
- 自定义开始时间选择器
- 断食通知提醒功能
- Redux状态管理和数据持久化
- 新增tab导航入口和路由配置
This commit is contained in:
richarjiang
2025-10-13 19:21:29 +08:00
parent 971aebd560
commit e03b2b3032
17 changed files with 2390 additions and 7 deletions

View File

@@ -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 (
<LinearGradient
colors={[
themeColors?.accentSecondary ?? colors.heroSurfaceTint,
themeColors?.backdrop ?? colors.pageBackgroundEmphasis,
]}
style={styles.container}
>
<View style={styles.headerRow}>
<View>
<Text style={styles.planLabel}></Text>
{plan?.id && (
<View style={styles.planTag}>
<Text style={[styles.planTagText, { color: themeColors?.accent ?? colors.primary }]}>
{plan.id}
</Text>
</View>
)}
</View>
{plan?.badge && (
<View style={[styles.badge, { backgroundColor: `${themeColors?.accent ?? colors.primary}20` }]}>
<Text style={[styles.badgeText, { color: themeColors?.accent ?? colors.primary }]}>
{plan.badge}
</Text>
</View>
)}
</View>
<View style={styles.scheduleRow}>
<View style={styles.scheduleCell}>
<Text style={styles.scheduleLabel}></Text>
<Text style={styles.scheduleDay}>{startDayLabel}</Text>
<Text style={styles.scheduleTime}>{startTimeLabel}</Text>
</View>
<View style={styles.separator} />
<View style={styles.scheduleCell}>
<Text style={styles.scheduleLabel}></Text>
<Text style={styles.scheduleDay}>{endDayLabel}</Text>
<Text style={styles.scheduleTime}>{endTimeLabel}</Text>
</View>
</View>
<View style={styles.statusRow}>
<View style={styles.ringContainer}>
<CircularRing
size={168}
strokeWidth={14}
progress={progress}
progressColor={themeColors?.ringProgress ?? colors.primary}
trackColor={themeColors?.ringTrack ?? 'rgba(0,0,0,0.05)'}
showCenterText={false}
startAngleDeg={-90}
resetToken={phaseLabel}
/>
<View style={styles.ringContent}>
<Text style={styles.phaseText}>{phaseLabel}</Text>
<Text style={styles.countdownLabel}>{countdownLabel}</Text>
<Text style={styles.countdownValue}>{countdownValue}</Text>
</View>
</View>
</View>
<View style={styles.actionsRow}>
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: themeColors?.accent ?? colors.primary }]}
onPress={onAdjustStartPress}
activeOpacity={0.85}
>
<Text style={[styles.secondaryButtonText, { color: themeColors?.accent ?? colors.primary }]}>
</Text>
</TouchableOpacity>
{/* <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: themeColors?.accent ?? colors.primary }]}
onPress={onViewMealsPress}
activeOpacity={0.9}
>
<Text style={styles.primaryButtonText}>查看食谱</Text>
</TouchableOpacity> */}
</View>
</LinearGradient>
);
}
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',
},
});

View File

@@ -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<FastingPlan['difficulty'], string> = {
: '适合入门',
: '脂代提升',
: '平台突破',
};
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 (
<View style={styles.wrapper}>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerBadge}>
<Text style={styles.headerBadgeText}></Text>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{sortedPlans.map((plan) => {
const isActive = plan.id === activePlanId;
return (
<TouchableOpacity
key={plan.id}
style={[
styles.card,
{
backgroundColor: plan.theme.backdrop,
borderColor: isActive ? plan.theme.accent : 'transparent',
},
]}
activeOpacity={0.85}
onPress={() => onSelectPlan(plan)}
>
<View style={styles.cardTopRow}>
<View style={[styles.difficultyPill, { backgroundColor: `${plan.theme.accent}1A` }]}>
<Text style={[styles.difficultyText, { color: plan.theme.accent }]}>
{plan.difficulty}
</Text>
</View>
{plan.badge && (
<View style={[styles.badgePill, { backgroundColor: `${plan.theme.accent}26` }]}>
<Text style={[styles.badgeText, { color: plan.theme.accent }]}>
{plan.badge}
</Text>
</View>
)}
</View>
<Text style={styles.cardTitle}>{plan.title}</Text>
<Text style={styles.cardSubtitle}>{plan.subtitle}</Text>
<View style={styles.metaRow}>
<View style={styles.metaItem}>
<IconSymbol name="chart.pie.fill" color={colors.icon} size={16} />
<Text style={styles.metaText}>{plan.fastingHours} </Text>
</View>
<View style={styles.metaItem}>
<IconSymbol name="flag.fill" color={colors.icon} size={16} />
<Text style={styles.metaText}>{difficultyLabel[plan.difficulty]}</Text>
</View>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
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',
},
});

View File

@@ -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<number | null>(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 (
<FloatingSelectionCard
visible={visible}
onClose={onClose}
title="断食开始时间"
>
<View style={styles.pickerRow}>
<WheelPickerExpo
key={`day-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={dayIndex}
items={dayOptions.map((item) => ({ label: item.label, value: item.offset }))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, dayIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
<WheelPickerExpo
key={`hour-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={hourIndex}
items={HOURS.map((hour) => ({ label: hour.toString().padStart(2, '0'), value: hour }))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, hourIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
<WheelPickerExpo
key={`minute-${pickerKey}`}
height={180}
width={110}
initialSelectedIndex={minuteIndex}
items={MINUTES.map((minute) => ({
label: minute.toString().padStart(2, '0'),
value: minute,
}))}
onChange={({ index }) => setIndexes((prev) => ({ ...prev, minuteIndex: index }))}
backgroundColor="transparent"
itemTextStyle={textStyle}
selectedIndicatorStyle={pickerIndicatorStyle}
haptics
/>
</View>
<View style={styles.footerRow}>
<TouchableOpacity
style={[styles.recommendButton, { borderColor: colors.primary }]}
onPress={handleUseRecommended}
activeOpacity={0.85}
>
<Text style={[styles.recommendButtonText, { color: colors.primary }]}>使</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.confirmButton, { backgroundColor: colors.primary }]}
onPress={handleConfirm}
activeOpacity={0.9}
>
<Text style={styles.confirmButtonText}></Text>
</TouchableOpacity>
</View>
</FloatingSelectionCard>
);
}
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',
},
});

View File

@@ -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 (
<Modal
visible={visible}
@@ -52,9 +68,9 @@ export function FloatingSelectionCard({
onPress={onClose}
activeOpacity={0.7}
>
<View style={styles.closeButtonInner}>
<CloseWrapper style={closeInnerStyle} {...closeWrapperProps}>
<Ionicons name="close" size={24} color="#666" />
</View>
</CloseWrapper>
</TouchableOpacity>
</View>
</BlurView>
@@ -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,
},
});
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)',
},
});

View File

@@ -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',