Files
digital-pilates/components/DateSelector.tsx
richarjiang 8b9689b269 Refactor components and enhance background task management
- Updated font sizes and weights in BasalMetabolismCard, MoodCard, HealthDataCard, and NutritionRadarCard for improved readability.
- Removed loading state from MoodCard to simplify the component.
- Adjusted styles in WeightHistoryCard for better layout and spacing.
- Integrated expo-background-fetch for improved background task handling.
- Updated Info.plist to include background fetch capability.
- Enhanced background task registration and execution logic in backgroundTaskManager.
- Added debug function to manually trigger background task execution for testing purposes.
2025-09-03 16:17:29 +08:00

247 lines
6.5 KiB
TypeScript

import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import dayjs from 'dayjs';
import React, { useEffect, useRef, useState } from 'react';
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
export interface DateSelectorProps {
/** 当前选中的日期索引 */
selectedIndex?: number;
/** 日期选择回调 */
onDateSelect?: (index: number, date: Date) => void;
/** 是否显示月份标题 */
showMonthTitle?: boolean;
/** 自定义月份标题 */
monthTitle?: string;
/** 是否禁用未来日期 */
disableFutureDates?: boolean;
/** 自定义样式 */
style?: any;
/** 容器样式 */
containerStyle?: any;
/** 日期项样式 */
dayItemStyle?: any;
/** 是否自动滚动到选中项 */
autoScrollToSelected?: boolean;
}
export const DateSelector: React.FC<DateSelectorProps> = ({
selectedIndex: externalSelectedIndex,
onDateSelect,
showMonthTitle = true,
monthTitle: externalMonthTitle,
disableFutureDates = true,
style,
containerStyle,
dayItemStyle,
autoScrollToSelected = true,
}) => {
// 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
// 获取日期数据
const days = getMonthDaysZh();
const monthTitle = externalMonthTitle ?? getMonthTitleZh();
// 滚动相关
const daysScrollRef = useRef<ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 48;
const DAY_PILL_SPACING = 8;
// 滚动到指定索引
const scrollToIndex = (index: number, animated = true) => {
if (!daysScrollRef.current || scrollWidth === 0) return;
const itemWidth = DAY_PILL_WIDTH + DAY_PILL_SPACING;
const baseOffset = index * itemWidth;
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
// 确保不会滚动超出边界
const maxScrollOffset = Math.max(0, (days.length * itemWidth) - scrollWidth);
const finalOffset = Math.min(centerOffset, maxScrollOffset);
if (animated) {
// 使用原生动画API实现更平滑的滚动
requestAnimationFrame(() => {
daysScrollRef.current?.scrollTo({
x: finalOffset,
animated: true
});
});
} else {
// 非动画情况直接跳转
daysScrollRef.current?.scrollTo({
x: finalOffset,
animated: false
});
}
};
// 初始化时滚动到选中项
useEffect(() => {
if (scrollWidth > 0 && autoScrollToSelected) {
scrollToIndex(selectedIndex, true);
}
}, [scrollWidth, selectedIndex, autoScrollToSelected]);
// 当选中索引变化时,滚动到对应位置
useEffect(() => {
if (scrollWidth > 0 && autoScrollToSelected) {
// 添加微小延迟以确保动画效果更明显
const timer = setTimeout(() => {
scrollToIndex(selectedIndex, true);
}, 50);
return () => clearTimeout(timer);
}
}, [selectedIndex, autoScrollToSelected]);
// 处理日期选择
const handleDateSelect = (index: number) => {
const targetDate = days[index]?.date?.toDate();
if (!targetDate) return;
// 检查是否为未来日期
if (disableFutureDates && days[index].date.isAfter(dayjs(), 'day')) {
return;
}
// 先滚动到目标位置,再更新状态
if (autoScrollToSelected) {
scrollToIndex(index, true);
}
// 更新内部状态(如果使用外部控制则不更新)
if (externalSelectedIndex === undefined) {
setInternalSelectedIndex(index);
}
// 调用回调
onDateSelect?.(index, targetDate);
};
return (
<View style={[styles.container, containerStyle]}>
{showMonthTitle && (
<Text style={styles.monthTitle}>{monthTitle}</Text>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
style={style}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
const isFutureDate = disableFutureDates && d.date.isAfter(dayjs(), 'day');
return (
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
<TouchableOpacity
style={[
styles.dayPill,
selected ? styles.dayPillSelected : styles.dayPillNormal,
isFutureDate && styles.dayPillDisabled
]}
onPress={() => !isFutureDate && handleDateSelect(i)}
activeOpacity={isFutureDate ? 1 : 0.8}
disabled={isFutureDate}
>
<Text style={[
styles.dayLabel,
selected && styles.dayLabelSelected,
isFutureDate && styles.dayLabelDisabled
]}>
{d.weekdayZh}
</Text>
<Text style={[
styles.dayDate,
selected && styles.dayDateSelected,
isFutureDate && styles.dayDateDisabled
]}>
{d.dayOfMonth}
</Text>
</TouchableOpacity>
</View>
);
})}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
},
monthTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 48,
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
},
dayPillNormal: {
backgroundColor: 'transparent',
},
dayPillSelected: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
dayPillDisabled: {
backgroundColor: 'transparent',
opacity: 0.4,
},
dayLabel: {
fontSize: 11,
fontWeight: '700',
color: 'gray',
marginBottom: 2,
},
dayLabelSelected: {
color: '#192126',
},
dayLabelDisabled: {
color: 'gray',
},
dayDate: {
fontSize: 12,
fontWeight: '600',
color: 'gray',
},
dayDateSelected: {
color: '#192126',
},
dayDateDisabled: {
color: 'gray',
},
});