feat(health): 新增手腕温度监测和经期双向同步功能

新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
richarjiang
2025-12-18 08:40:08 +08:00
parent 9b4a300380
commit 4836058d56
31 changed files with 2249 additions and 539 deletions

View File

@@ -0,0 +1,568 @@
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'
}
});