- 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.
247 lines
6.5 KiB
TypeScript
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',
|
|
},
|
|
});
|