新增完整的轻断食功能,包括: - 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划 - 断食状态实时追踪和倒计时显示 - 自定义开始时间选择器 - 断食通知提醒功能 - Redux状态管理和数据持久化 - 新增tab导航入口和路由配置
243 lines
7.1 KiB
TypeScript
243 lines
7.1 KiB
TypeScript
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',
|
|
},
|
|
});
|