feat: 支持围度数据图表

This commit is contained in:
richarjiang
2025-09-24 18:04:12 +08:00
parent 028ef56caf
commit 6303795870
9 changed files with 1180 additions and 359 deletions

View File

@@ -0,0 +1,700 @@
import { DateSelector } from '@/components/DateSelector';
import { FloatingSelectionModal, SelectionItem } from '@/components/ui/FloatingSelectionModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchCircumferenceAnalysis, selectCircumferenceData, selectCircumferenceError, selectCircumferenceLoading } from '@/store/circumferenceSlice';
import { selectUserProfile, updateUserBodyMeasurements, UserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LineChart } from 'react-native-chart-kit';
dayjs.extend(weekOfYear);
// 围度类型数据
const CIRCUMFERENCE_TYPES = [
{ key: 'chestCircumference', label: '胸围', color: '#FF6B6B' },
{ key: 'waistCircumference', label: '腰围', color: '#4ECDC4' },
{ key: 'upperHipCircumference', label: '上臀围', color: '#45B7D1' },
{ key: 'armCircumference', label: '臂围', color: '#96CEB4' },
{ key: 'thighCircumference', label: '大腿围', color: '#FFEAA7' },
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
];
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
type TabType = CircumferencePeriod;
export default function CircumferenceDetailScreen() {
const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile);
const { ensureLoggedIn } = useAuthGuard();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const [activeTab, setActiveTab] = useState<TabType>('week');
// 弹窗状态
const [modalVisible, setModalVisible] = useState(false);
const [selectedMeasurement, setSelectedMeasurement] = useState<{
key: string;
label: string;
currentValue?: number;
} | null>(null);
// Redux状态
const chartData = useAppSelector(state => selectCircumferenceData(state, activeTab));
const isLoading = useAppSelector(state => selectCircumferenceLoading(state, activeTab));
const error = useAppSelector(selectCircumferenceError);
console.log('chartData', chartData);
// 图例显示状态 - 控制哪些维度显示在图表中
const [visibleTypes, setVisibleTypes] = useState<Set<string>>(
new Set(CIRCUMFERENCE_TYPES.map(type => type.key))
);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 判断选中日期是否是今天
const isSelectedDateToday = useMemo(() => {
const today = new Date();
const selectedDate = currentSelectedDate;
return dayjs(selectedDate).isSame(today, 'day');
}, [currentSelectedDate]);
// 当前围度数据
const measurements = [
{
key: 'chestCircumference',
label: '胸围',
value: userProfile?.chestCircumference,
color: '#FF6B6B',
},
{
key: 'waistCircumference',
label: '腰围',
value: userProfile?.waistCircumference,
color: '#4ECDC4',
},
{
key: 'upperHipCircumference',
label: '上臀围',
value: userProfile?.upperHipCircumference,
color: '#45B7D1',
},
{
key: 'armCircumference',
label: '臂围',
value: userProfile?.armCircumference,
color: '#96CEB4',
},
{
key: 'thighCircumference',
label: '大腿围',
value: userProfile?.thighCircumference,
color: '#FFEAA7',
},
{
key: 'calfCircumference',
label: '小腿围',
value: userProfile?.calfCircumference,
color: '#DDA0DD',
},
];
// 日期选择回调
const onSelectDate = (index: number) => {
setSelectedIndex(index);
};
// Tab切换
const handleTabPress = useCallback((tab: TabType) => {
setActiveTab(tab);
// 切换tab时重新获取数据
dispatch(fetchCircumferenceAnalysis(tab));
}, [dispatch]);
// 初始化加载数据
useEffect(() => {
dispatch(fetchCircumferenceAnalysis(activeTab));
}, [dispatch, activeTab]);
// 处理图例点击,切换显示/隐藏
const handleLegendPress = (typeKey: string) => {
const newVisibleTypes = new Set(visibleTypes);
if (newVisibleTypes.has(typeKey)) {
// 至少保留一个维度显示
if (newVisibleTypes.size > 1) {
newVisibleTypes.delete(typeKey);
}
} else {
newVisibleTypes.add(typeKey);
}
setVisibleTypes(newVisibleTypes);
};
// 根据不同围度类型获取合理的默认值
const getDefaultCircumferenceValue = (measurementKey: string, userProfile?: UserProfile): number => {
// 如果用户已有该围度数据,直接使用
const existingValue = userProfile?.[measurementKey as keyof UserProfile] as number;
if (existingValue) {
return existingValue;
}
// 根据性别设置合理的默认值
const isMale = userProfile?.gender === 'male';
switch (measurementKey) {
case 'chestCircumference':
// 胸围:男性 85-110cm女性 75-95cm
return isMale ? 95 : 80;
case 'waistCircumference':
// 腰围:男性 70-90cm女性 60-80cm
return isMale ? 80 : 70;
case 'upperHipCircumference':
// 上臀围:
return 30;
case 'armCircumference':
// 臂围:男性 25-35cm女性 20-30cm
return isMale ? 30 : 25;
case 'thighCircumference':
// 大腿围:男性 45-60cm女性 40-55cm
return isMale ? 50 : 45;
case 'calfCircumference':
// 小腿围:男性 30-40cm女性 25-35cm
return isMale ? 35 : 30;
default:
return 70; // 默认70cm
}
};
// Generate circumference options (30-150 cm)
const circumferenceOptions: SelectionItem[] = Array.from({ length: 121 }, (_, i) => {
const value = i + 30;
return {
label: `${value} cm`,
value: value,
};
});
// 处理围度数据点击
const handleMeasurementPress = async (measurement: typeof measurements[0]) => {
// 只有选中今天日期才能编辑
if (!isSelectedDateToday) {
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
// 使用智能默认值,如果用户已有数据则使用现有数据,否则使用基于性别的合理默认值
const defaultValue = getDefaultCircumferenceValue(measurement.key, userProfile);
setSelectedMeasurement({
key: measurement.key,
label: measurement.label,
currentValue: measurement.value || defaultValue,
});
setModalVisible(true);
};
// 处理围度数据更新
const handleUpdateMeasurement = (value: string | number) => {
if (!selectedMeasurement) return;
const updateData = {
[selectedMeasurement.key]: Number(value),
};
dispatch(updateUserBodyMeasurements(updateData));
setModalVisible(false);
setSelectedMeasurement(null);
};
// 处理图表数据
const processedChartData = useMemo(() => {
if (!chartData || chartData.length === 0) {
return { labels: [], datasets: [] };
}
// 根据activeTab生成标签
const labels = chartData.map(item => {
switch (activeTab) {
case 'week':
// 将YYYY-MM-DD格式转换为星期几
const weekDay = dayjs(item.label).format('dd');
return weekDay;
case 'month':
// 将YYYY-MM-DD格式转换为第几周
const weekOfYear = dayjs(item.label).week();
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
return `${weekOfYear - firstWeekOfMonth + 1}`;
case 'year':
// 将YYYY-MM格式转换为月份
return dayjs(item.label).format('M月');
default:
return item.label;
}
});
// 为每个可见的围度类型生成数据集
const datasets: any[] = [];
CIRCUMFERENCE_TYPES.forEach((type) => {
if (visibleTypes.has(type.key)) {
const data = chartData.map(item => {
const value = item[type.key as keyof typeof item] as number | null;
return value || 0; // null值转换为0图表会自动处理
});
// 只有数据中至少有一个非零值才添加到数据集
if (data.some(value => value > 0)) {
datasets.push({
data,
color: () => type.color,
strokeWidth: 2,
});
}
}
});
return { labels, datasets };
}, [chartData, activeTab, visibleTypes]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 头部导航 */}
<HeaderBar
title="围度统计"
transparent
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingBottom: 60,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<View style={styles.dateContainer}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={false}
disableFutureDates={true}
/>
</View>
{/* 当前日期围度数据 */}
<View style={styles.currentDataCard}>
<View style={styles.measurementsContainer}>
{measurements.map((measurement, index) => (
<TouchableOpacity
key={index}
style={[
styles.measurementItem,
!isSelectedDateToday && styles.measurementItemDisabled
]}
onPress={() => handleMeasurementPress(measurement)}
activeOpacity={isSelectedDateToday ? 0.7 : 1}
disabled={!isSelectedDateToday}
>
<View style={[styles.colorIndicator, { backgroundColor: measurement.color }]} />
<Text style={styles.label}>{measurement.label}</Text>
<View style={styles.valueContainer}>
<Text style={styles.value}>
{measurement.value ? measurement.value.toString() : '--'}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 围度统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === 'week' && styles.activeTab]}
onPress={() => handleTabPress('week')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
onPress={() => handleTabPress('month')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'year' && styles.activeTab]}
onPress={() => handleTabPress('year')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
</View>
{/* 图例 - 支持点击切换显示/隐藏 */}
<View style={styles.legendContainer}>
{CIRCUMFERENCE_TYPES.map((type, index) => {
const isVisible = visibleTypes.has(type.key);
return (
<TouchableOpacity
key={index}
style={[styles.legendItem, !isVisible && styles.legendItemHidden]}
onPress={() => handleLegendPress(type.key)}
activeOpacity={0.7}
>
<View style={[
styles.legendColor,
{ backgroundColor: isVisible ? type.color : '#E0E0E0' }
]} />
<Text style={[
styles.legendText,
!isVisible && styles.legendTextHidden
]}>
{type.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* 折线图 */}
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 ? (
<LineChart
data={{
labels: processedChartData.labels,
datasets: processedChartData.datasets,
}}
width={Dimensions.get('window').width - 80}
height={220}
yAxisSuffix="cm"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
fillShadowGradientFromOpacity: 0,
fillShadowGradientToOpacity: 0,
decimalPlaces: 0,
color: (opacity = 1) => `rgba(100, 100, 100, ${opacity * 0.8})`,
labelColor: (opacity = 1) => `rgba(60, 60, 60, ${opacity})`,
style: {
borderRadius: 16,
},
propsForDots: {
r: "3",
strokeWidth: "2",
stroke: "#ffffff"
},
propsForBackgroundLines: {
strokeDasharray: "2,2",
stroke: "#E0E0E0",
strokeWidth: 1
},
}}
bezier
style={styles.chart}
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}>
{processedChartData.datasets.length === 0 && !isLoading && !error
? '暂无数据'
: '请选择要显示的围度数据'
}
</Text>
</View>
)}
</View>
</ScrollView>
{/* 围度编辑弹窗 */}
<FloatingSelectionModal
visible={modalVisible}
onClose={() => {
setModalVisible(false);
setSelectedMeasurement(null);
}}
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
items={circumferenceOptions}
selectedValue={selectedMeasurement?.currentValue}
onValueChange={() => { }} // Real-time update not needed
onConfirm={handleUpdateMeasurement}
confirmButtonText="确认"
pickerHeight={180}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
dateContainer: {
marginTop: 16,
marginBottom: 20,
},
currentDataCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
currentDataTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
textAlign: 'center',
},
measurementsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'wrap',
},
measurementItem: {
alignItems: 'center',
width: '16%',
marginBottom: 12,
},
measurementItemDisabled: {
opacity: 0.6,
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginBottom: 4,
},
label: {
fontSize: 12,
color: '#888',
marginBottom: 8,
textAlign: 'center',
},
valueContainer: {
backgroundColor: '#F5F5F7',
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 6,
minWidth: 32,
alignItems: 'center',
justifyContent: 'center',
},
value: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
textAlign: 'center',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
statsTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#F5F5F7',
borderRadius: 12,
padding: 4,
marginBottom: 20,
},
tab: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#888',
},
activeTabText: {
color: '#192126',
},
legendContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 16,
gap: 6,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 8,
},
legendItemHidden: {
opacity: 0.5,
},
legendColor: {
width: 4,
height: 4,
borderRadius: 4,
marginRight: 4,
},
legendText: {
fontSize: 10,
color: '#666',
fontWeight: '500',
},
legendTextHidden: {
color: '#999',
},
chart: {
marginVertical: 8,
borderRadius: 16,
},
emptyChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
emptyChartText: {
fontSize: 14,
color: '#999',
fontWeight: '500',
},
loadingChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
loadingText: {
fontSize: 14,
color: '#666',
marginTop: 8,
fontWeight: '500',
},
errorChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFF5F5',
borderRadius: 16,
marginVertical: 8,
padding: 20,
},
errorText: {
fontSize: 14,
color: '#E53E3E',
textAlign: 'center',
marginBottom: 12,
},
retryButton: {
backgroundColor: '#4ECDC4',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
retryText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
});