import { ensureHealthPermissions, fetchTimeInDaylight, fetchTimeInDaylightHistory, SunlightHistoryPoint } 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, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dimensions, Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; import Svg, { Defs, LinearGradient as SvgLinearGradient, Line, Rect, Stop, Text as SvgText } from 'react-native-svg'; import HealthDataCard from './HealthDataCard'; interface SunlightCardProps { style?: object; selectedDate?: Date; } const screenWidth = Dimensions.get('window').width; const INITIAL_CHART_WIDTH = screenWidth - 32; const CHART_HEIGHT = 190; const CHART_RIGHT_PADDING = 12; const AXIS_COLUMN_WIDTH = 36; const CHART_INNER_PADDING = 4; const AXIS_LABEL_WIDTH = 48; const Y_TICK_COUNT = 4; const BAR_GAP = 6; const MIN_BAR_HEIGHT = 4; const SunlightCard: React.FC = ({ style, selectedDate }) => { const { t, i18n } = useTranslation(); const locale = i18n.language; const isFocused = useIsFocused(); const [sunlightMinutes, setSunlightMinutes] = useState(null); const [comparisonText, setComparisonText] = useState(null); const [loading, setLoading] = useState(false); const loadingRef = useRef(false); const [historyVisible, setHistoryVisible] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [history, setHistory] = useState([]); const historyLoadingRef = useRef(false); const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH); const formatCompareDate = (date: Date) => { if (locale?.startsWith('zh')) { return dayjs(date).format('M月D日'); } return dayjs(date).format('MMM D'); }; useEffect(() => { const loadSunlightData = async () => { const dateToUse = selectedDate || new Date(); if (!isFocused) return; if (!HealthKitUtils.isAvailable()) { setSunlightMinutes(null); setComparisonText(null); return; } if (loadingRef.current) return; try { loadingRef.current = true; setLoading(true); setComparisonText(null); const hasPermission = await ensureHealthPermissions(); if (!hasPermission) { setSunlightMinutes(null); setComparisonText(null); setLoading(false); return; } const options = { startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(), endDate: dayjs(dateToUse).endOf('day').toDate().toISOString() }; const totalMinutes = await fetchTimeInDaylight(options); setSunlightMinutes(totalMinutes); setLoading(false); if (totalMinutes !== null && totalMinutes !== undefined) { try { let previousMinutes: number | null = null; let previousDate: Date | null = null; for (let i = 1; i <= 30; i += 1) { const targetDate = dayjs(dateToUse).subtract(i, 'day'); const previousOptions = { startDate: targetDate.startOf('day').toDate().toISOString(), endDate: targetDate.endOf('day').toDate().toISOString() }; const candidateMinutes = await fetchTimeInDaylight(previousOptions); if (candidateMinutes !== null && candidateMinutes !== undefined && candidateMinutes > 0) { previousMinutes = candidateMinutes; previousDate = targetDate.toDate(); break; } } if (previousMinutes !== null && previousDate) { const diff = Math.round(totalMinutes - previousMinutes); const dateLabel = formatCompareDate(previousDate); if (diff > 0) { setComparisonText(t('statistics.components.sunlight.compareIncrease', { date: dateLabel, diff })); } else if (diff < 0) { setComparisonText(t('statistics.components.sunlight.compareDecrease', { date: dateLabel, diff: Math.abs(diff) })); } else { setComparisonText(t('statistics.components.sunlight.compareSame', { date: dateLabel })); } } else { setComparisonText(t('statistics.components.sunlight.compareNone')); } } catch (error) { console.error('SunlightCard: Failed to compare time in daylight:', error); setComparisonText(t('statistics.components.sunlight.compareNone')); } } else { setComparisonText(null); } } catch (error) { console.error('SunlightCard: Failed to get time in daylight:', error); setSunlightMinutes(null); setComparisonText(null); setLoading(false); } finally { loadingRef.current = false; } }; loadSunlightData(); }, [isFocused, selectedDate, t, locale]); 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(29, 'day').startOf('day'); const options = { startDate: start.toDate().toISOString(), endDate: end.toDate().toISOString() }; const historyData = await fetchTimeInDaylightHistory(options); const sorted = historyData .filter((item) => item && item.date) .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf()); setHistory(sorted); } catch (error) { console.error('SunlightCard: Failed to get time in daylight history:', error); setHistory([]); } finally { historyLoadingRef.current = false; setHistoryLoading(false); } }; loadHistory(); }, [historyVisible, selectedDate, isFocused]); const displayValue = loading ? '--' : (sunlightMinutes !== null && sunlightMinutes !== undefined ? Math.max(0, Math.round(sunlightMinutes)).toString() : '--'); const openHistory = () => setHistoryVisible(true); const closeHistory = () => setHistoryVisible(false); const maxValue = history.length ? Math.max(...history.map((item) => item.value), 10) : 10; const averageValue = history.length ? history.reduce((sum, item) => sum + item.value, 0) / history.length : null; const latestValue = history.length ? history[history.length - 1].value : null; const barCount = history.length || 1; const chartInnerWidth = Math.max(0, chartWidth - 24); const chartAreaWidth = Math.max( 0, chartInnerWidth - AXIS_COLUMN_WIDTH - CHART_RIGHT_PADDING ); const barWidth = Math.max( 6, (chartAreaWidth - CHART_INNER_PADDING * 2 - BAR_GAP * (barCount - 1)) / barCount ); const dateLabels = history.length ? [ history[0], history[Math.floor(history.length / 2)], history[history.length - 1] ].filter(Boolean) : []; return ( <> } subtitle={loading ? undefined : comparisonText ?? undefined} onPress={openHistory} /> {t('statistics.components.sunlight.title')} {t('statistics.components.sunlight.last30Days')} {historyLoading ? ( {t('statistics.components.sunlight.syncing')} ) : null} {history.length === 0 ? ( {t('statistics.components.sunlight.noData')} ) : ( { const nextWidth = event.nativeEvent.layout.width; if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) { setChartWidth(nextWidth); } }} > {t('statistics.components.sunlight.unit')} {Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => { const value = (maxValue / Y_TICK_COUNT) * (Y_TICK_COUNT - index); const y = (CHART_HEIGHT / Y_TICK_COUNT) * index; return ( {Math.round(value)} ); })} {Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => { const value = (maxValue / Y_TICK_COUNT) * index; const y = CHART_HEIGHT - (value / maxValue) * CHART_HEIGHT; return ( ); })} {history.map((item, index) => { const value = item.value; const barHeight = Math.max((value / maxValue) * CHART_HEIGHT, MIN_BAR_HEIGHT); const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP); const y = CHART_HEIGHT - barHeight; return ( 8 ? 6 : 4} fill="url(#sunBar)" /> ); })} {dateLabels.map((item) => { const index = history.findIndex((point) => point.date === item.date); const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP) + barWidth / 2; const label = dayjs(item.date).format(locale?.startsWith('zh') ? 'M.D' : 'MMM D'); const maxLeft = Math.max(0, chartAreaWidth - AXIS_LABEL_WIDTH); const clampedLeft = Math.min( Math.max(x - AXIS_LABEL_WIDTH / 2, 0), maxLeft ); return ( {label} ); })} )} {t('statistics.components.sunlight.average')} {averageValue !== null ? Math.round(averageValue) : '--'} {t('statistics.components.sunlight.unit')} {t('statistics.components.sunlight.latest')} {latestValue !== null ? Math.round(latestValue) : '--'} {t('statistics.components.sunlight.unit')} ); }; export default SunlightCard; 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.05, shadowRadius: 14, shadowOffset: { width: 0, height: 12 }, elevation: 4, marginTop: 8, marginBottom: 14, borderWidth: 1, borderColor: '#FEF3C7' }, chartHeaderRow: { paddingLeft: AXIS_COLUMN_WIDTH, paddingBottom: 6 }, axisUnit: { fontSize: 10, color: '#B45309', fontFamily: 'AliRegular' }, chartContentRow: { flexDirection: 'row', alignItems: 'flex-start' }, axisColumn: { width: AXIS_COLUMN_WIDTH, height: CHART_HEIGHT, position: 'relative', justifyContent: 'space-between', paddingRight: 6 }, axisTick: { position: 'absolute', right: 6, fontSize: 10, color: '#B45309', fontFamily: 'AliRegular' }, labelRow: { marginTop: 4, marginLeft: AXIS_COLUMN_WIDTH, height: 24, justifyContent: 'center' }, axisLabel: { position: 'absolute', bottom: 0, fontSize: 11, color: '#9A6B2F', fontFamily: 'AliRegular', textAlign: 'center', width: 48 }, metricsRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, paddingVertical: 6 }, metric: { flex: 1, padding: 14, backgroundColor: 'rgba(255, 247, 237, 0.8)', borderRadius: 18, borderWidth: 1, borderColor: '#FED7AA' }, metricLabel: { fontSize: 12, color: '#92400E', marginBottom: 8, fontFamily: 'AliRegular' }, metricValue: { fontSize: 20, fontWeight: '700', color: '#7C2D12', fontFamily: 'AliBold' }, metricUnit: { fontSize: 12, color: '#9A6B2F', fontWeight: '500', fontFamily: 'AliRegular' }, emptyState: { marginTop: 32, padding: 20, borderRadius: 20, backgroundColor: 'rgba(255, 247, 237, 0.9)', borderWidth: 1, borderColor: '#FED7AA', alignItems: 'center' }, emptyText: { fontSize: 14, color: '#9A3412', fontFamily: 'AliRegular' }, hintText: { fontSize: 13, color: '#9CA3AF', fontFamily: 'AliRegular' } });