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