Compare commits
1 Commits
e51aca2fdb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17664c679d |
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.1.5",
|
"version": "1.1.6",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
|||||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import SleepCard from '@/components/statistic/SleepCard';
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
|
import SunlightCard from '@/components/statistic/SunlightCard';
|
||||||
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
|
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
@@ -114,6 +115,7 @@ export default function ExploreScreen() {
|
|||||||
showMenstrualCycle: true,
|
showMenstrualCycle: true,
|
||||||
showWeight: true,
|
showWeight: true,
|
||||||
showCircumference: true,
|
showCircumference: true,
|
||||||
|
showSunlight: true,
|
||||||
});
|
});
|
||||||
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
|
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
|
||||||
|
|
||||||
@@ -581,6 +583,15 @@ export default function ExploreScreen() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
sunlight: {
|
||||||
|
visible: cardVisibility.showSunlight,
|
||||||
|
component: (
|
||||||
|
<SunlightCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
fitness: {
|
fitness: {
|
||||||
visible: cardVisibility.showFitnessRings,
|
visible: cardVisibility.showFitnessRings,
|
||||||
component: (
|
component: (
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function StatisticsCustomizationScreen() {
|
|||||||
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
|
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
|
||||||
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
|
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
|
||||||
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
|
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
|
||||||
|
sunlight: { icon: 'sunny-outline', titleKey: 'statisticsCustomization.items.sunlight', visibilityKey: 'showSunlight' },
|
||||||
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
|
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
|
||||||
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
|
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
|
||||||
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
|
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
|
||||||
@@ -355,4 +356,4 @@ const styles = StyleSheet.create({
|
|||||||
switch: {
|
switch: {
|
||||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image } from '@/components/ui/Image';
|
import { Image } from '@/components/ui/Image';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { ImageSourcePropType, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
|
|
||||||
interface HealthDataCardProps {
|
interface HealthDataCardProps {
|
||||||
@@ -9,14 +9,22 @@ interface HealthDataCardProps {
|
|||||||
unit: string;
|
unit: string;
|
||||||
style?: object;
|
style?: object;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
iconSource?: ImageSourcePropType;
|
||||||
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultIconSource = require('@/assets/images/icons/icon-blood-oxygen.png');
|
||||||
|
|
||||||
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
unit,
|
unit,
|
||||||
style,
|
style,
|
||||||
onPress
|
onPress,
|
||||||
|
icon,
|
||||||
|
iconSource,
|
||||||
|
subtitle
|
||||||
}) => {
|
}) => {
|
||||||
const Container = onPress ? Pressable : View;
|
const Container = onPress ? Pressable : View;
|
||||||
|
|
||||||
@@ -30,13 +38,22 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
|||||||
accessibilityHint={onPress ? `${title} details` : undefined}
|
accessibilityHint={onPress ? `${title} details` : undefined}
|
||||||
>
|
>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<Image source={require('@/assets/images/icons/icon-blood-oxygen.png')} style={styles.titleIcon} />
|
{icon ? (
|
||||||
|
<View style={styles.iconWrapper}>{icon}</View>
|
||||||
|
) : (
|
||||||
|
<Image source={iconSource ?? defaultIconSource} style={styles.titleIcon} />
|
||||||
|
)}
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.valueContainer}>
|
<View style={styles.valueContainer}>
|
||||||
<Text style={styles.value}>{value}</Text>
|
<Text style={styles.value}>{value}</Text>
|
||||||
<Text style={styles.unit}>{unit}</Text>
|
<Text style={styles.unit}>{unit}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text style={styles.subtitle} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Container>
|
</Container>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -66,6 +83,13 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
},
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
marginRight: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
titleIcon: {
|
titleIcon: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
@@ -96,6 +120,12 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8A8A8A',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HealthDataCard;
|
export default HealthDataCard;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const HeartRateCard: React.FC<HeartRateCardProps> = ({
|
|||||||
value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'}
|
value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'}
|
||||||
unit="bpm"
|
unit="bpm"
|
||||||
style={style}
|
style={style}
|
||||||
|
icon={heartIcon}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -34,4 +35,4 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HeartRateCard;
|
export default HeartRateCard;
|
||||||
|
|||||||
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -139,6 +139,19 @@ export const statistics = {
|
|||||||
title: 'Sleep',
|
title: 'Sleep',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
},
|
},
|
||||||
|
sunlight: {
|
||||||
|
title: 'Sun',
|
||||||
|
unit: 'min',
|
||||||
|
compareIncrease: 'Up {{diff}} min vs {{date}}',
|
||||||
|
compareDecrease: 'Down {{diff}} min vs {{date}}',
|
||||||
|
compareSame: 'Same as {{date}}',
|
||||||
|
compareNone: 'No prior data',
|
||||||
|
last30Days: 'Last 30 days',
|
||||||
|
syncing: 'Syncing Health data...',
|
||||||
|
noData: 'No sunlight data yet',
|
||||||
|
average: '30-day avg',
|
||||||
|
latest: 'Latest',
|
||||||
|
},
|
||||||
oxygen: {
|
oxygen: {
|
||||||
title: 'Blood Oxygen',
|
title: 'Blood Oxygen',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const statisticsCustomization = {
|
|||||||
steps: 'Steps',
|
steps: 'Steps',
|
||||||
stress: 'Stress',
|
stress: 'Stress',
|
||||||
sleep: 'Sleep',
|
sleep: 'Sleep',
|
||||||
|
sunlight: 'Sun',
|
||||||
fitnessRings: 'Fitness Rings',
|
fitnessRings: 'Fitness Rings',
|
||||||
water: 'Water Intake',
|
water: 'Water Intake',
|
||||||
basalMetabolism: 'Basal Metabolism',
|
basalMetabolism: 'Basal Metabolism',
|
||||||
|
|||||||
@@ -140,6 +140,19 @@ export const statistics = {
|
|||||||
title: '睡眠',
|
title: '睡眠',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
},
|
},
|
||||||
|
sunlight: {
|
||||||
|
title: '晒太阳',
|
||||||
|
unit: '分钟',
|
||||||
|
compareIncrease: '与 {{date}} 相比增加 {{diff}} 分钟',
|
||||||
|
compareDecrease: '与 {{date}} 相比减少 {{diff}} 分钟',
|
||||||
|
compareSame: '与 {{date}} 相比无变化',
|
||||||
|
compareNone: '暂无对比',
|
||||||
|
last30Days: '最近30天',
|
||||||
|
syncing: '正在同步健康数据...',
|
||||||
|
noData: '暂无日照时间数据',
|
||||||
|
average: '30天均值',
|
||||||
|
latest: '最新值',
|
||||||
|
},
|
||||||
oxygen: {
|
oxygen: {
|
||||||
title: '血氧饱和度',
|
title: '血氧饱和度',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const statisticsCustomization = {
|
|||||||
steps: '步数',
|
steps: '步数',
|
||||||
stress: '压力',
|
stress: '压力',
|
||||||
sleep: '睡眠',
|
sleep: '睡眠',
|
||||||
|
sunlight: '晒太阳',
|
||||||
fitnessRings: '健身圆环',
|
fitnessRings: '健身圆环',
|
||||||
water: '饮水',
|
water: '饮水',
|
||||||
basalMetabolism: '基础代谢',
|
basalMetabolism: '基础代谢',
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options
|
|||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(getTimeInDaylight:(NSDictionary *)options
|
||||||
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(getTimeInDaylightSamples:(NSDictionary *)options
|
||||||
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options
|
RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options
|
||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
static var timeInDaylight: HKQuantityType? {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
return HKObjectType.quantityType(forIdentifier: .timeInDaylight)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static var all: Set<HKObjectType> {
|
static var all: Set<HKObjectType> {
|
||||||
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||||
@@ -95,6 +102,7 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||||
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||||
if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) }
|
if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) }
|
||||||
|
if let timeInDaylight = timeInDaylight { types.insert(timeInDaylight) }
|
||||||
return types
|
return types
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +631,151 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func getTimeInDaylight(
|
||||||
|
_ options: NSDictionary,
|
||||||
|
resolver: @escaping RCTPromiseResolveBlock,
|
||||||
|
rejecter: @escaping RCTPromiseRejectBlock
|
||||||
|
) {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let daylightType = ReadTypes.timeInDaylight else {
|
||||||
|
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight type is not available", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startDate: Date
|
||||||
|
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||||
|
startDate = d
|
||||||
|
} else {
|
||||||
|
startDate = Calendar.current.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
let endDate: Date
|
||||||
|
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||||
|
endDate = d
|
||||||
|
} else {
|
||||||
|
endDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||||
|
|
||||||
|
let query = HKStatisticsQuery(quantityType: daylightType,
|
||||||
|
quantitySamplePredicate: predicate,
|
||||||
|
options: .cumulativeSum) { [weak self] (query, statistics, error) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let error = error {
|
||||||
|
rejecter("QUERY_ERROR", "Failed to query time in daylight: \(error.localizedDescription)", error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let statistics = statistics else {
|
||||||
|
resolver([
|
||||||
|
"totalValue": 0,
|
||||||
|
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||||
|
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
|
||||||
|
|
||||||
|
let result: [String: Any] = [
|
||||||
|
"totalValue": totalValue,
|
||||||
|
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||||
|
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||||
|
]
|
||||||
|
resolver(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func getTimeInDaylightSamples(
|
||||||
|
_ options: NSDictionary,
|
||||||
|
resolver: @escaping RCTPromiseResolveBlock,
|
||||||
|
rejecter: @escaping RCTPromiseRejectBlock
|
||||||
|
) {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let daylightType = ReadTypes.timeInDaylight else {
|
||||||
|
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight type is not available", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let startDate: Date
|
||||||
|
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||||
|
startDate = d
|
||||||
|
} else {
|
||||||
|
startDate = Calendar.current.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
let endDate: Date
|
||||||
|
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||||
|
endDate = d
|
||||||
|
} else {
|
||||||
|
endDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||||
|
|
||||||
|
var interval = DateComponents()
|
||||||
|
interval.day = 1
|
||||||
|
|
||||||
|
let anchorDate = Calendar.current.startOfDay(for: startDate)
|
||||||
|
|
||||||
|
let query = HKStatisticsCollectionQuery(quantityType: daylightType,
|
||||||
|
quantitySamplePredicate: predicate,
|
||||||
|
options: .cumulativeSum,
|
||||||
|
anchorDate: anchorDate,
|
||||||
|
intervalComponents: interval)
|
||||||
|
|
||||||
|
query.initialResultsHandler = { [weak self] (_, results, error) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let error = error {
|
||||||
|
rejecter("QUERY_ERROR", "Failed to query time in daylight samples: \(error.localizedDescription)", error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let results = results else {
|
||||||
|
resolver([
|
||||||
|
"data": [],
|
||||||
|
"count": 0,
|
||||||
|
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||||
|
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: [[String: Any]] = []
|
||||||
|
results.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
|
||||||
|
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
|
||||||
|
data.append([
|
||||||
|
"date": self?.dateToISOString(statistics.startDate) ?? "",
|
||||||
|
"value": value
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: [String: Any] = [
|
||||||
|
"data": data,
|
||||||
|
"count": data.count,
|
||||||
|
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||||
|
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||||
|
]
|
||||||
|
resolver(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func getActivitySummary(
|
func getActivitySummary(
|
||||||
_ options: NSDictionary,
|
_ options: NSDictionary,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.1.5</string>
|
<string>1.1.6</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -801,6 +801,50 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTimeInDaylight(options: HealthDataOptions): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const result = await HealthKitManager.getTimeInDaylight(options);
|
||||||
|
|
||||||
|
if (result && result.totalValue !== undefined) {
|
||||||
|
logSuccess('晒太阳时长', result);
|
||||||
|
return result.totalValue;
|
||||||
|
} else {
|
||||||
|
logWarning('晒太阳时长', '为空或格式错误');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('晒太阳时长', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SunlightHistoryPoint {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTimeInDaylightHistory(options: HealthDataOptions): Promise<SunlightHistoryPoint[]> {
|
||||||
|
try {
|
||||||
|
const result = await HealthKitManager.getTimeInDaylightSamples(options);
|
||||||
|
|
||||||
|
if (result && result.data && Array.isArray(result.data)) {
|
||||||
|
logSuccess('晒太阳历史', result);
|
||||||
|
return result.data
|
||||||
|
.filter((item: any) => item && typeof item.value === 'number' && item.date)
|
||||||
|
.map((item: any) => ({
|
||||||
|
date: item.date,
|
||||||
|
value: Number(item.value)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
logWarning('晒太阳历史', '为空或格式错误');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logError('晒太阳历史', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise<number | null> {
|
export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const PREFERENCES_KEYS = {
|
|||||||
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
||||||
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
|
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
|
||||||
SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card',
|
SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card',
|
||||||
|
SHOW_SUNLIGHT_CARD: 'user_preference_show_sunlight_card',
|
||||||
|
|
||||||
// 首页身体指标卡片排序设置
|
// 首页身体指标卡片排序设置
|
||||||
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
||||||
@@ -48,6 +49,7 @@ export interface StatisticsCardsVisibility {
|
|||||||
showWeight: boolean;
|
showWeight: boolean;
|
||||||
showCircumference: boolean;
|
showCircumference: boolean;
|
||||||
showWristTemperature: boolean;
|
showWristTemperature: boolean;
|
||||||
|
showSunlight: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认卡片顺序
|
// 默认卡片顺序
|
||||||
@@ -56,6 +58,7 @@ export const DEFAULT_CARD_ORDER: string[] = [
|
|||||||
'steps',
|
'steps',
|
||||||
'stress',
|
'stress',
|
||||||
'sleep',
|
'sleep',
|
||||||
|
'sunlight',
|
||||||
'fitness',
|
'fitness',
|
||||||
'water',
|
'water',
|
||||||
'metabolism',
|
'metabolism',
|
||||||
@@ -113,6 +116,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
showWeight: true,
|
showWeight: true,
|
||||||
showCircumference: true,
|
showCircumference: true,
|
||||||
showWristTemperature: true,
|
showWristTemperature: true,
|
||||||
|
showSunlight: true,
|
||||||
|
|
||||||
// 默认卡片顺序
|
// 默认卡片顺序
|
||||||
cardOrder: DEFAULT_CARD_ORDER,
|
cardOrder: DEFAULT_CARD_ORDER,
|
||||||
@@ -150,6 +154,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
||||||
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
|
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
|
||||||
const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD);
|
const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD);
|
||||||
|
const showSunlight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD);
|
||||||
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
||||||
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
||||||
|
|
||||||
@@ -180,6 +185,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
||||||
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
||||||
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
|
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
|
||||||
|
showSunlight: showSunlight !== null ? showSunlight === 'true' : DEFAULT_PREFERENCES.showSunlight,
|
||||||
cardOrder,
|
cardOrder,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -618,6 +624,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
|||||||
showWeight: userPreferences.showWeight,
|
showWeight: userPreferences.showWeight,
|
||||||
showCircumference: userPreferences.showCircumference,
|
showCircumference: userPreferences.showCircumference,
|
||||||
showWristTemperature: userPreferences.showWristTemperature,
|
showWristTemperature: userPreferences.showWristTemperature,
|
||||||
|
showSunlight: userPreferences.showSunlight,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取首页卡片显示设置失败:', error);
|
console.error('获取首页卡片显示设置失败:', error);
|
||||||
@@ -634,6 +641,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
|||||||
showWeight: DEFAULT_PREFERENCES.showWeight,
|
showWeight: DEFAULT_PREFERENCES.showWeight,
|
||||||
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
||||||
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
|
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
|
||||||
|
showSunlight: DEFAULT_PREFERENCES.showSunlight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -682,6 +690,7 @@ export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisi
|
|||||||
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
|
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
|
||||||
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
||||||
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
|
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
|
||||||
|
case 'showSunlight': storageKey = PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD; break;
|
||||||
default: return;
|
default: return;
|
||||||
}
|
}
|
||||||
await AsyncStorage.setItem(storageKey, value.toString());
|
await AsyncStorage.setItem(storageKey, value.toString());
|
||||||
|
|||||||
Reference in New Issue
Block a user