Files
digital-pilates/app/basal-metabolism-detail.tsx
richarjiang 79ab354f31 feat: 新增基础代谢详情页面并优化HRV数据获取逻辑
- 新增基础代谢详情页面,包含图表展示、数据缓存和防抖机制
- 优化HRV数据获取逻辑,支持实时、近期和历史数据的智能获取
- 移除WaterIntakeCard和WaterSettings中的登录验证逻辑
- 更新饮水数据管理hook,直接使用HealthKit数据
- 添加饮水目标存储和获取功能
- 更新依赖包版本
2025-09-25 14:15:42 +08:00

820 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
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, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BarChart } from 'react-native-chart-kit';
dayjs.extend(weekOfYear);
type TabType = 'week' | 'month';
type BasalMetabolismData = {
date: Date;
value: number | null;
};
export default function BasalMetabolismDetailScreen() {
const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge);
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const [activeTab, setActiveTab] = useState<TabType>('week');
// 说明弹窗状态
const [infoModalVisible, setInfoModalVisible] = useState(false);
// 数据状态
const [chartData, setChartData] = useState<BasalMetabolismData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 缓存和防抖相关参照BasalMetabolismCard
const [cacheRef] = useState(() => new Map<string, { data: BasalMetabolismData[]; timestamp: number }>());
const [loadingRef] = useState(() => new Map<string, Promise<BasalMetabolismData[]>>());
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
console.log('basal metabolism chartData', chartData);
// 生成日期范围的函数
const generateDateRange = useCallback((tab: TabType): Date[] => {
const today = new Date();
const dates: Date[] = [];
switch (tab) {
case 'week':
// 获取最近7天
for (let i = 6; i >= 0; i--) {
const date = dayjs(today).subtract(i, 'day').toDate();
dates.push(date);
}
break;
case 'month':
// 获取最近30天按周分组
for (let i = 3; i >= 0; i--) {
const date = dayjs(today).subtract(i * 7, 'day').toDate();
dates.push(date);
}
break;
}
return dates;
}, []);
// 优化的数据获取函数,包含缓存和去重复请求
const fetchBasalMetabolismData = useCallback(async (tab: TabType): Promise<BasalMetabolismData[]> => {
const cacheKey = `${tab}-${dayjs().format('YYYY-MM-DD')}`;
const now = Date.now();
// 检查缓存
const cached = cacheRef.get(cacheKey);
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
// 检查是否已经在请求中(防止重复请求)
const existingRequest = loadingRef.get(cacheKey);
if (existingRequest) {
return existingRequest;
}
// 创建新的请求
const request = (async () => {
try {
const dates = generateDateRange(tab);
const results: BasalMetabolismData[] = [];
// 并行获取所有日期的数据
const promises = dates.map(async (date) => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
});
const data = await Promise.all(promises);
results.push(...data);
// 更新缓存
cacheRef.set(cacheKey, { data: results, timestamp: now });
return results;
} catch (error) {
console.error('获取基础代谢数据失败:', error);
return [];
} finally {
// 清理请求记录
loadingRef.delete(cacheKey);
}
})();
// 记录请求
loadingRef.set(cacheKey, request);
return request;
}, [generateDateRange, cacheRef, loadingRef, CACHE_DURATION]);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 计算BMR范围
const bmrRange = useMemo(() => {
const { gender, weight, height } = userProfile;
// 检查是否有足够的信息来计算BMR
if (!gender || !weight || !height || !userAge) {
return null;
}
// 将体重和身高转换为数字
const weightNum = parseFloat(weight);
const heightNum = parseFloat(height);
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
return null;
}
// 使用Mifflin-St Jeor公式计算BMR
let bmr: number;
if (gender === 'male') {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
} else {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
}
// 计算正常范围±15%
const minBMR = Math.round(bmr * 0.85);
const maxBMR = Math.round(bmr * 1.15);
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
// 获取单个日期的代谢数据
const fetchSingleDateData = useCallback(async (date: Date): Promise<BasalMetabolismData> => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
}, []);
// 日期选择回调
const onSelectDate = useCallback(async (index: number) => {
setSelectedIndex(index);
// 获取选中日期
const days = getMonthDaysZh();
const selectedDate = days[index]?.date?.toDate();
if (selectedDate) {
// 检查是否已经有该日期的数据
const existingData = chartData.find(item =>
dayjs(item.date).isSame(selectedDate, 'day')
);
// 如果没有数据,则获取该日期的数据
if (!existingData) {
try {
const newData = await fetchSingleDateData(selectedDate);
// 更新chartData添加新数据并按日期排序
setChartData(prevData => {
const updatedData = [...prevData, newData];
return updatedData.sort((a, b) => a.date.getTime() - b.date.getTime());
});
} catch (error) {
console.error('获取选中日期数据失败:', error);
}
}
}
}, [chartData, fetchSingleDateData]);
// Tab切换
const handleTabPress = useCallback((tab: TabType) => {
setActiveTab(tab);
}, []);
// 初始化和Tab切换时加载数据
useEffect(() => {
let isCancelled = false;
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchBasalMetabolismData(activeTab);
if (!isCancelled) {
setChartData(data);
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败');
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadData();
// 清理函数,防止组件卸载后的状态更新
return () => {
isCancelled = true;
};
}, [activeTab, fetchBasalMetabolismData]);
// 处理图表数据
const processedChartData = useMemo(() => {
if (!chartData || chartData.length === 0) {
return { labels: [], datasets: [] };
}
// 根据activeTab生成标签和数据
const labels = chartData.map(item => {
switch (activeTab) {
case 'week':
// 显示星期几
return dayjs(item.date).format('dd');
case 'month':
// 显示周数
const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`;
default:
return dayjs(item.date).format('MM-DD');
}
});
// 生成基础代谢数据集
const data = chartData.map(item => {
const value = item.value;
if (value === null || value === undefined) {
return 0; // 明确处理null/undefined值
}
// 对于非常小的正值保证至少显示1但对于0值保持为0
const roundedValue = Math.round(value);
return value > 0 && roundedValue === 0 ? 1 : roundedValue;
});
console.log('processedChartData:', { labels, data, originalValues: chartData.map(item => item.value) });
return {
labels,
datasets: [{
data
}]
};
}, [chartData, activeTab]);
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
right={
<TouchableOpacity
onPress={() => setInfoModalVisible(true)}
style={styles.infoButton}
>
<Ionicons name="information-circle-outline" size={24} color="#666" />
</TouchableOpacity>
}
/>
<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}>
<Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')}
</Text>
<View style={styles.currentValueContainer}>
<Text style={styles.currentValue}>
{(() => {
const selectedDateData = chartData.find(item =>
dayjs(item.date).isSame(currentSelectedDate, 'day')
);
if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString();
}
return '--';
})()}
</Text>
<Text style={styles.currentUnit}></Text>
</View>
{bmrRange && (
<Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max}
</Text>
)}
</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>
</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={() => {
// 重新加载数据
setIsLoading(true);
setError(null);
fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data);
setIsLoading(false);
}).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败');
setIsLoading(false);
});
}}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
<BarChart
data={{
labels: processedChartData.labels,
datasets: processedChartData.datasets,
}}
width={Dimensions.get('window').width - 80}
height={220}
yAxisLabel=""
yAxisSuffix="千卡"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
decimalPlaces: 0,
color: (opacity = 1) => `${Colors.light.primary}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题紫色
labelColor: (opacity = 1) => `${Colors.light.text}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题文字颜色
style: {
borderRadius: 16,
},
barPercentage: 0.7, // 增加柱体宽度
propsForBackgroundLines: {
strokeDasharray: "2,2",
stroke: Colors.light.border, // 使用主题边框颜色
strokeWidth: 1
},
propsForLabels: {
fontSize: 12,
fontWeight: '500',
},
}}
style={styles.chart}
showValuesOnTopOfBars={true}
fromZero={false}
segments={4}
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text>
</View>
)}
</View>
</ScrollView>
{/* 基础代谢说明弹窗 */}
<Modal
animationType="fade"
transparent={true}
visible={infoModalVisible}
onRequestClose={() => setInfoModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* 关闭按钮 */}
<TouchableOpacity
style={styles.closeButton}
onPress={() => setInfoModalVisible(false)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
{/* 标题 */}
<Text style={styles.modalTitle}></Text>
{/* 基础代谢定义 */}
<Text style={styles.modalDescription}>
BMR
</Text>
{/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionContent}>
60-75%
</Text>
{/* 正常范围 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5
</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161
</Text>
{bmrRange ? (
<>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text>
<Text style={styles.rangeNote}>
(15%)
</Text>
<Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg
</Text>
</>
) : (
<Text style={styles.rangeText}></Text>
)}
{/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.strategyText}></Text>
<View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text>
<Text style={styles.strategyItem}>2. (HIIT)</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text>
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
infoButton: {
padding: 4,
},
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,
alignItems: 'center',
},
currentDataTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
textAlign: 'center',
},
currentValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
currentValue: {
fontSize: 36,
fontWeight: '700',
color: '#4ECDC4',
},
currentUnit: {
fontSize: 16,
color: '#666',
marginLeft: 8,
},
rangeText: {
fontSize: 14,
color: '#059669',
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',
},
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',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
maxHeight: '90%',
width: '100%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -5,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
},
closeButton: {
position: 'absolute',
top: 16,
right: 16,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
closeButtonText: {
fontSize: 20,
color: '#64748B',
fontWeight: '600',
},
modalTitle: {
fontSize: 24,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
textAlign: 'center',
},
modalDescription: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
marginTop: 8,
},
sectionContent: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 20,
},
formulaText: {
fontSize: 14,
color: '#64748B',
fontFamily: 'monospace',
marginBottom: 4,
paddingLeft: 8,
},
rangeNote: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
userInfoText: {
fontSize: 13,
color: '#6B7280',
textAlign: 'center',
marginTop: 8,
marginBottom: 16,
fontStyle: 'italic',
},
strategyText: {
fontSize: 15,
color: '#475569',
marginBottom: 12,
},
strategyList: {
marginBottom: 20,
},
strategyItem: {
fontSize: 14,
color: '#64748B',
lineHeight: 20,
marginBottom: 8,
paddingLeft: 8,
},
});