feat(health): 新增日照时长监测卡片与 HealthKit 集成
- iOS 端集成 HealthKit 日照时间 (TimeInDaylight) 数据获取接口 - 新增 SunlightCard 组件,支持查看今日数据及最近30天历史趋势图表 - 更新统计页和自定义设置页,支持开启/关闭日照卡片 - 优化 HealthDataCard 组件,支持自定义图标组件和副标题显示 - 更新多语言文件及应用版本号至 1.1.6
This commit is contained in:
559
components/statistic/SunlightCard.tsx
Normal file
559
components/statistic/SunlightCard.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
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<SunlightCardProps> = ({
|
||||
style,
|
||||
selectedDate
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const isFocused = useIsFocused();
|
||||
const [sunlightMinutes, setSunlightMinutes] = useState<number | null>(null);
|
||||
const [comparisonText, setComparisonText] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [history, setHistory] = useState<SunlightHistoryPoint[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<HealthDataCard
|
||||
title={t('statistics.components.sunlight.title')}
|
||||
value={displayValue}
|
||||
unit={t('statistics.components.sunlight.unit')}
|
||||
style={style}
|
||||
icon={<Ionicons name="sunny-outline" size={16} color="#F59E0B" />}
|
||||
subtitle={loading ? undefined : comparisonText ?? undefined}
|
||||
onPress={openHistory}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={historyVisible}
|
||||
animationType="slide"
|
||||
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
|
||||
onRequestClose={closeHistory}
|
||||
>
|
||||
<View style={styles.modalSafeArea}>
|
||||
<LinearGradient
|
||||
colors={['#FFF7E8', '#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.sunlight.title')}</Text>
|
||||
<Text style={styles.modalSubtitle}>{t('statistics.components.sunlight.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.sunlight.syncing')}</Text>
|
||||
) : null}
|
||||
|
||||
{history.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{t('statistics.components.sunlight.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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.chartHeaderRow}>
|
||||
<Text style={styles.axisUnit}>{t('statistics.components.sunlight.unit')}</Text>
|
||||
</View>
|
||||
<View style={styles.chartContentRow}>
|
||||
<View style={styles.axisColumn}>
|
||||
{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 (
|
||||
<Text key={`tick-${index}`} style={[styles.axisTick, { top: Math.max(0, y - 6) }]}>
|
||||
{Math.round(value)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Svg width={chartAreaWidth} height={CHART_HEIGHT + 10}>
|
||||
<Defs>
|
||||
<SvgLinearGradient id="sunBar" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<Stop offset="0%" stopColor="#F59E0B" stopOpacity="0.95" />
|
||||
<Stop offset="100%" stopColor="#FDE68A" stopOpacity="0.8" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
|
||||
{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 (
|
||||
<React.Fragment key={`tick-${index}`}>
|
||||
<Line
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={chartAreaWidth}
|
||||
y2={y}
|
||||
stroke="#FEF3C7"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{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 (
|
||||
<Rect
|
||||
key={item.date}
|
||||
x={x}
|
||||
y={y}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
rx={barWidth > 8 ? 6 : 4}
|
||||
fill="url(#sunBar)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
</View>
|
||||
<View style={[styles.labelRow, { width: chartAreaWidth }]}>
|
||||
{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 (
|
||||
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: AXIS_LABEL_WIDTH }]}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metric}>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.average')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{averageValue !== null ? Math.round(averageValue) : '--'}
|
||||
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metric}>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.latest')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{latestValue !== null ? Math.round(latestValue) : '--'}
|
||||
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user