Files
digital-pilates/components/statistic/WristTemperatureCard.tsx
richarjiang 4836058d56 feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
2025-12-18 08:40:08 +08:00

569 lines
17 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 {
ensureHealthPermissions,
fetchWristTemperature,
fetchWristTemperatureHistory,
WristTemperatureHistoryPoint
} from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { Ionicons } from '@expo/vector-icons';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View
} from 'react-native';
import Svg, {
Circle,
Defs,
Line,
Path,
Stop,
LinearGradient as SvgLinearGradient
} from 'react-native-svg';
import HealthDataCard from './HealthDataCard';
interface WristTemperatureCardProps {
style?: object;
selectedDate?: Date;
}
const screenWidth = Dimensions.get('window').width;
const INITIAL_CHART_WIDTH = screenWidth - 32;
const CHART_HEIGHT = 240;
const CHART_HORIZONTAL_PADDING = 20;
const LABEL_ESTIMATED_WIDTH = 44;
const WristTemperatureCard: React.FC<WristTemperatureCardProps> = ({
style,
selectedDate
}) => {
const { t } = useTranslation();
const isFocused = useIsFocused();
const [temperature, setTemperature] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [history, setHistory] = useState<WristTemperatureHistoryPoint[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const historyLoadingRef = useRef(false);
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
useEffect(() => {
const loadData = async () => {
const dateToUse = selectedDate || new Date();
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setTemperature(null);
return;
}
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setTemperature(null);
return;
}
const dayStart = dayjs(dateToUse).startOf('day');
// wrist temperature samples often start于前一晚查询时向前扩展一天以包含跨夜数据
const options = {
startDate: dayStart.subtract(1, 'day').toDate().toISOString(),
endDate: dayStart.endOf('day').toDate().toISOString()
};
const data = await fetchWristTemperature(options, dateToUse);
setTemperature(data);
} catch (error) {
console.error('WristTemperatureCard: Failed to get wrist temperature data:', error);
setTemperature(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadData();
}, [isFocused, selectedDate]);
useEffect(() => {
if (!historyVisible || !isFocused) return;
const loadHistory = async () => {
if (historyLoadingRef.current) return;
if (!HealthKitUtils.isAvailable()) {
setHistory([]);
return;
}
try {
historyLoadingRef.current = true;
setHistoryLoading(true);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setHistory([]);
return;
}
const end = dayjs(selectedDate || new Date()).endOf('day');
const start = end.subtract(30, 'day').startOf('day').subtract(1, 'day');
const options = {
startDate: start.toDate().toISOString(),
endDate: end.toDate().toISOString(),
limit: 1200
};
const historyData = await fetchWristTemperatureHistory(options);
setHistory(historyData);
} catch (error) {
console.error('WristTemperatureCard: Failed to get wrist temperature history:', error);
setHistory([]);
} finally {
historyLoadingRef.current = false;
setHistoryLoading(false);
}
};
loadHistory();
}, [historyVisible, selectedDate, isFocused]);
const baseline = useMemo(() => {
if (!history.length) return null;
const avg = history.reduce((sum, point) => sum + point.value, 0) / history.length;
return Number(avg.toFixed(2));
}, [history]);
const chartRange = useMemo(() => {
if (!history.length) return { min: -1, max: 1 };
const values = history.map((p) => p.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const center = baseline ?? (minValue + maxValue) / 2;
const maxDeviation = Math.max(Math.abs(maxValue - center), Math.abs(minValue - center), 0.2);
const padding = Math.max(maxDeviation * 0.25, 0.15);
return {
min: center - maxDeviation - padding,
max: center + maxDeviation + padding
};
}, [baseline, history]);
const xStep = useMemo(() => {
if (history.length <= 1) return 0;
return (chartWidth - CHART_HORIZONTAL_PADDING * 2) / (history.length - 1);
}, [history.length, chartWidth]);
const valueToY = useCallback(
(value: number) => {
const range = chartRange.max - chartRange.min || 1;
return ((chartRange.max - value) / range) * CHART_HEIGHT;
},
[chartRange.max, chartRange.min]
);
const linePath = useMemo(() => {
if (!history.length) return '';
return history.reduce((path, point, index) => {
const x = CHART_HORIZONTAL_PADDING + xStep * index;
const y = valueToY(point.value);
if (index === 0) return `M ${x} ${y}`;
return `${path} L ${x} ${y}`;
}, '');
}, [history, valueToY, xStep]);
const latestValue = history.length ? history[history.length - 1].value : null;
const latestChange = baseline !== null && latestValue !== null ? latestValue - baseline : null;
const dateLabels = useMemo(() => {
if (!history.length) return [];
const first = history[0];
const middle = history[Math.floor(history.length / 2)];
const last = history[history.length - 1];
const uniqueDates = [first, middle, last].filter((item, idx, arr) => {
if (!item) return false;
return arr.findIndex((it) => it?.date === item.date) === idx;
});
return uniqueDates.map((point) => {
const index = history.findIndex((p) => p.date === point.date);
const positionIndex = index >= 0 ? index : 0;
return {
date: point.date,
label: dayjs(point.date).format('MM.DD'),
x: CHART_HORIZONTAL_PADDING + positionIndex * xStep
};
});
}, [history, xStep]);
const openHistory = useCallback(() => {
setHistoryVisible(true);
}, []);
const closeHistory = useCallback(() => {
setHistoryVisible(false);
}, []);
return (
<>
<HealthDataCard
title={t('statistics.components.wristTemperature.title')}
value={loading ? '--' : (temperature !== null && temperature !== undefined ? temperature.toFixed(1) : '--')}
unit="°C"
style={style}
onPress={openHistory}
/>
<Modal
visible={historyVisible}
animationType="slide"
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
onRequestClose={closeHistory}
>
<View style={styles.modalSafeArea}>
<LinearGradient
colors={['#F7F6FF', '#FFFFFF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<View>
<Text style={styles.modalTitle}>{t('statistics.components.wristTemperature.title')}</Text>
<Text style={styles.modalSubtitle}>{t('statistics.components.wristTemperature.last30Days')}</Text>
</View>
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
<View style={styles.closeButtonInner}>
<Ionicons name="close" size={18} color="#111827" />
</View>
</Pressable>
</View>
{historyLoading ? (
<Text style={styles.hintText}>{t('statistics.components.wristTemperature.syncing')}</Text>
) : null}
{history.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{t('statistics.components.wristTemperature.noData')}</Text>
</View>
) : (
<View
style={styles.chartCard}
onLayout={(event) => {
const nextWidth = event.nativeEvent.layout.width;
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
setChartWidth(nextWidth);
}
}}
>
<Svg width={chartWidth} height={CHART_HEIGHT + 36}>
<Defs>
<SvgLinearGradient id="lineFade" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="#1F2A44" stopOpacity="1" />
<Stop offset="100%" stopColor="#1F2A44" stopOpacity="0.78" />
</SvgLinearGradient>
</Defs>
<Line
x1={CHART_HORIZONTAL_PADDING}
y1={valueToY(baseline ?? 0)}
x2={chartWidth - CHART_HORIZONTAL_PADDING}
y2={valueToY(baseline ?? 0)}
stroke="#CBD5E1"
strokeDasharray="6 6"
strokeWidth={1.2}
/>
<Path d={linePath} stroke="url(#lineFade)" strokeWidth={2.6} fill="none" strokeLinecap="round" />
{history.map((point, index) => {
const x = CHART_HORIZONTAL_PADDING + xStep * index;
const y = valueToY(point.value);
return (
<Circle
key={point.date}
cx={x}
cy={y}
r={5}
stroke="#1F2A44"
strokeWidth={1.6}
fill="#FFFFFF"
/>
);
})}
</Svg>
<View style={styles.labelRow}>
{dateLabels.map((item) => {
const clampedLeft = Math.min(
Math.max(item.x - LABEL_ESTIMATED_WIDTH / 2, CHART_HORIZONTAL_PADDING),
chartWidth - CHART_HORIZONTAL_PADDING - LABEL_ESTIMATED_WIDTH
);
return (
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: LABEL_ESTIMATED_WIDTH }]}>
{item.label}
</Text>
);
})}
<View style={styles.baselineLabelWrapper}>
<View style={styles.baselinePill}>
<View style={styles.baselineDot} />
<Text style={styles.axisHint}>{t('statistics.components.wristTemperature.baseline')}</Text>
{baseline !== null && (
<Text style={styles.axisHintValue}>
{baseline.toFixed(1)}
°C
</Text>
)}
</View>
</View>
{latestChange !== null && (
<View style={styles.deviationBadge}>
<Text style={styles.deviationBadgeText}>
{latestChange >= 0 ? '+' : ''}
{latestChange.toFixed(1)}°C
</Text>
</View>
)}
</View>
</View>
)}
<View style={styles.metricsRow}>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.average')}</Text>
<Text style={styles.metricValue}>
{baseline !== null ? baseline.toFixed(1) : '--'}
<Text style={styles.metricUnit}>°C</Text>
</Text>
</View>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.latest')}</Text>
<Text style={styles.metricValue}>
{latestValue !== null ? latestValue.toFixed(1) : '--'}
<Text style={styles.metricUnit}>°C</Text>
</Text>
{latestChange !== null && (
<Text style={styles.metricHint}>
{latestChange >= 0 ? '+' : ''}
{latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
</Text>
)}
</View>
</View>
</View>
</View>
</Modal>
</>
);
};
export default WristTemperatureCard;
const styles = StyleSheet.create({
modalSafeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
paddingTop: Platform.OS === 'ios' ? 10 : 0
},
modalContainer: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 22
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14
},
modalTitle: {
fontSize: 22,
fontWeight: '700',
color: '#1C1C28',
fontFamily: 'AliBold'
},
modalSubtitle: {
fontSize: 13,
color: '#6B7280',
marginTop: 4,
fontFamily: 'AliRegular'
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.42)',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.6)',
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2
},
closeButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
chartCard: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
paddingVertical: 12,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
elevation: 4,
marginTop: 8,
marginBottom: 14,
borderWidth: 1,
borderColor: '#F1F5F9'
},
labelRow: {
marginTop: -6,
paddingHorizontal: 12,
height: 44,
justifyContent: 'center'
},
axisLabel: {
position: 'absolute',
bottom: 0,
fontSize: 11,
color: '#94A3B8',
fontFamily: 'AliRegular',
textAlign: 'center'
},
baselineLabelWrapper: {
position: 'absolute',
left: 0,
top: -4,
flexDirection: 'row',
alignItems: 'center'
},
baselinePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#F1F5F9',
borderRadius: 14,
borderWidth: 1,
borderColor: '#E2E8F0',
gap: 6
},
baselineDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#94A3B8'
},
axisHint: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular'
},
axisHintValue: {
fontSize: 13,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold'
},
deviationBadge: {
position: 'absolute',
right: 12,
bottom: 2,
backgroundColor: '#ECFEFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 5,
borderWidth: 1,
borderColor: '#CFFAFE'
},
deviationBadgeText: {
fontSize: 12,
color: '#0EA5E9',
fontWeight: '700',
fontFamily: 'AliBold'
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 6
},
metric: {
flex: 1,
backgroundColor: '#F8FAFC',
borderRadius: 18,
padding: 14,
borderWidth: 1,
borderColor: '#E2E8F0'
},
metricLabel: {
fontSize: 12,
color: '#6B7280',
marginBottom: 6,
fontFamily: 'AliRegular'
},
metricValue: {
fontSize: 20,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold'
},
metricUnit: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
fontWeight: '500',
fontFamily: 'AliRegular'
},
metricHint: {
marginTop: 6,
fontSize: 12,
color: '#6B21A8',
fontFamily: 'AliRegular'
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32
},
emptyText: {
fontSize: 14,
color: '#94A3B8',
fontFamily: 'AliRegular'
},
hintText: {
fontSize: 12,
color: '#6B7280',
marginBottom: 6,
fontFamily: 'AliRegular'
}
});