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 = ({ style, selectedDate }) => { const { t } = useTranslation(); const isFocused = useIsFocused(); const [temperature, setTemperature] = useState(null); const [loading, setLoading] = useState(false); const loadingRef = useRef(false); const [historyVisible, setHistoryVisible] = useState(false); const [history, setHistory] = useState([]); 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 ( <> {t('statistics.components.wristTemperature.title')} {t('statistics.components.wristTemperature.last30Days')} {historyLoading ? ( {t('statistics.components.wristTemperature.syncing')} ) : null} {history.length === 0 ? ( {t('statistics.components.wristTemperature.noData')} ) : ( { const nextWidth = event.nativeEvent.layout.width; if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) { setChartWidth(nextWidth); } }} > {history.map((point, index) => { const x = CHART_HORIZONTAL_PADDING + xStep * index; const y = valueToY(point.value); return ( ); })} {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 ( {item.label} ); })} {t('statistics.components.wristTemperature.baseline')} {baseline !== null && ( {baseline.toFixed(1)} °C )} {latestChange !== null && ( {latestChange >= 0 ? '+' : ''} {latestChange.toFixed(1)}°C )} )} {t('statistics.components.wristTemperature.average')} {baseline !== null ? baseline.toFixed(1) : '--'} °C {t('statistics.components.wristTemperature.latest')} {latestValue !== null ? latestValue.toFixed(1) : '--'} °C {latestChange !== null && ( {latestChange >= 0 ? '+' : ''} {latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')} )} ); }; 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' } });