新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
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'
|
||
}
|
||
});
|