diff --git a/app.json b/app.json index bec3213..ececf7e 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Out Live", "slug": "digital-pilates", - "version": "1.1.5", + "version": "1.1.6", "orientation": "portrait", "scheme": "digitalpilates", "userInterfaceStyle": "light", diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index ea8d343..c985157 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import CircumferenceCard from '@/components/statistic/CircumferenceCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import SleepCard from '@/components/statistic/SleepCard'; +import SunlightCard from '@/components/statistic/SunlightCard'; import WristTemperatureCard from '@/components/statistic/WristTemperatureCard'; import StepsCard from '@/components/StepsCard'; import { StressMeter } from '@/components/StressMeter'; @@ -114,6 +115,7 @@ export default function ExploreScreen() { showMenstrualCycle: true, showWeight: true, showCircumference: true, + showSunlight: true, }); const [cardOrder, setCardOrder] = useState(DEFAULT_CARD_ORDER); @@ -581,6 +583,15 @@ export default function ExploreScreen() { /> ) }, + sunlight: { + visible: cardVisibility.showSunlight, + component: ( + + ) + }, fitness: { visible: cardVisibility.showFitnessRings, component: ( diff --git a/app/statistics-customization.tsx b/app/statistics-customization.tsx index 9b9be9b..5a8585c 100644 --- a/app/statistics-customization.tsx +++ b/app/statistics-customization.tsx @@ -42,6 +42,7 @@ export default function StatisticsCustomizationScreen() { steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' }, stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' }, 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' }, water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' }, metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' }, @@ -355,4 +356,4 @@ const styles = StyleSheet.create({ switch: { transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }], }, -}); \ No newline at end of file +}); diff --git a/components/statistic/HealthDataCard.tsx b/components/statistic/HealthDataCard.tsx index 5815626..35ec589 100644 --- a/components/statistic/HealthDataCard.tsx +++ b/components/statistic/HealthDataCard.tsx @@ -1,6 +1,6 @@ import { Image } from '@/components/ui/Image'; 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'; interface HealthDataCardProps { @@ -9,14 +9,22 @@ interface HealthDataCardProps { unit: string; style?: object; onPress?: () => void; + icon?: React.ReactNode; + iconSource?: ImageSourcePropType; + subtitle?: string; } +const defaultIconSource = require('@/assets/images/icons/icon-blood-oxygen.png'); + const HealthDataCard: React.FC = ({ title, value, unit, style, - onPress + onPress, + icon, + iconSource, + subtitle }) => { const Container = onPress ? Pressable : View; @@ -30,13 +38,22 @@ const HealthDataCard: React.FC = ({ accessibilityHint={onPress ? `${title} details` : undefined} > - + {icon ? ( + {icon} + ) : ( + + )} {title} {value} {unit} + {subtitle ? ( + + {subtitle} + + ) : null} ); @@ -66,6 +83,13 @@ const styles = StyleSheet.create({ alignItems: 'center', marginBottom: 14, }, + iconWrapper: { + width: 16, + height: 16, + marginRight: 6, + alignItems: 'center', + justifyContent: 'center', + }, titleIcon: { width: 16, height: 16, @@ -96,6 +120,12 @@ const styles = StyleSheet.create({ fontWeight: '500', fontFamily: 'AliRegular', }, + subtitle: { + marginTop: 6, + fontSize: 12, + color: '#8A8A8A', + fontFamily: 'AliRegular', + }, }); export default HealthDataCard; diff --git a/components/statistic/HeartRateCard.tsx b/components/statistic/HeartRateCard.tsx index b307a7b..e1de35f 100644 --- a/components/statistic/HeartRateCard.tsx +++ b/components/statistic/HeartRateCard.tsx @@ -24,6 +24,7 @@ const HeartRateCard: React.FC = ({ value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'} unit="bpm" style={style} + icon={heartIcon} /> ); }; @@ -34,4 +35,4 @@ const styles = StyleSheet.create({ }, }); -export default HeartRateCard; \ No newline at end of file +export default HeartRateCard; diff --git a/components/statistic/SunlightCard.tsx b/components/statistic/SunlightCard.tsx new file mode 100644 index 0000000..d2e2dfe --- /dev/null +++ b/components/statistic/SunlightCard.tsx @@ -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 = ({ + 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' + } +}); diff --git a/i18n/en/health.ts b/i18n/en/health.ts index c355b92..f6cbfea 100644 --- a/i18n/en/health.ts +++ b/i18n/en/health.ts @@ -139,6 +139,19 @@ export const statistics = { title: 'Sleep', 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: { title: 'Blood Oxygen', }, diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts index 90a67a1..72b092c 100644 --- a/i18n/en/personal.ts +++ b/i18n/en/personal.ts @@ -123,6 +123,7 @@ export const statisticsCustomization = { steps: 'Steps', stress: 'Stress', sleep: 'Sleep', + sunlight: 'Sun', fitnessRings: 'Fitness Rings', water: 'Water Intake', basalMetabolism: 'Basal Metabolism', diff --git a/i18n/zh/health.ts b/i18n/zh/health.ts index 1c73d5b..eb3d77d 100644 --- a/i18n/zh/health.ts +++ b/i18n/zh/health.ts @@ -140,6 +140,19 @@ export const statistics = { title: '睡眠', loading: '加载中...', }, + sunlight: { + title: '晒太阳', + unit: '分钟', + compareIncrease: '与 {{date}} 相比增加 {{diff}} 分钟', + compareDecrease: '与 {{date}} 相比减少 {{diff}} 分钟', + compareSame: '与 {{date}} 相比无变化', + compareNone: '暂无对比', + last30Days: '最近30天', + syncing: '正在同步健康数据...', + noData: '暂无日照时间数据', + average: '30天均值', + latest: '最新值', + }, oxygen: { title: '血氧饱和度', }, diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts index cc77a91..9b585ff 100644 --- a/i18n/zh/personal.ts +++ b/i18n/zh/personal.ts @@ -123,6 +123,7 @@ export const statisticsCustomization = { steps: '步数', stress: '压力', sleep: '睡眠', + sunlight: '晒太阳', fitnessRings: '健身圆环', water: '饮水', basalMetabolism: '基础代谢', diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index fa38127..bc57444 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -30,6 +30,14 @@ RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver 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 resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index 2bbbf35..b090509 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -78,6 +78,13 @@ class HealthKitManager: RCTEventEmitter { return nil } } + static var timeInDaylight: HKQuantityType? { + if #available(iOS 17.0, *) { + return HKObjectType.quantityType(forIdentifier: .timeInDaylight) + } else { + return nil + } + } static var all: Set { var types: Set = [activitySummary, workout, dateOfBirth] @@ -95,6 +102,7 @@ class HealthKitManager: RCTEventEmitter { if let bodyMass = bodyMass { types.insert(bodyMass) } if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) } if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) } + if let timeInDaylight = timeInDaylight { types.insert(timeInDaylight) } return types } @@ -623,6 +631,151 @@ class HealthKitManager: RCTEventEmitter { 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 func getActivitySummary( _ options: NSDictionary, diff --git a/ios/OutLive/Info.plist b/ios/OutLive/Info.plist index 3edca22..ec4b4d8 100644 --- a/ios/OutLive/Info.plist +++ b/ios/OutLive/Info.plist @@ -27,7 +27,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1.5 + 1.1.6 CFBundleSignature ???? CFBundleURLTypes diff --git a/utils/health.ts b/utils/health.ts index e689789..e1b3734 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -801,6 +801,50 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise } } +export async function fetchTimeInDaylight(options: HealthDataOptions): Promise { + 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 { + 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 { try { const result = await HealthKitManager.getWristTemperatureSamples(options); diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 9ab5eb0..d250fad 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -29,6 +29,7 @@ const PREFERENCES_KEYS = { SHOW_WEIGHT_CARD: 'user_preference_show_weight_card', SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_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', @@ -48,6 +49,7 @@ export interface StatisticsCardsVisibility { showWeight: boolean; showCircumference: boolean; showWristTemperature: boolean; + showSunlight: boolean; } // 默认卡片顺序 @@ -56,6 +58,7 @@ export const DEFAULT_CARD_ORDER: string[] = [ 'steps', 'stress', 'sleep', + 'sunlight', 'fitness', 'water', 'metabolism', @@ -113,6 +116,7 @@ const DEFAULT_PREFERENCES: UserPreferences = { showWeight: true, showCircumference: true, showWristTemperature: true, + showSunlight: true, // 默认卡片顺序 cardOrder: DEFAULT_CARD_ORDER, @@ -150,6 +154,7 @@ export const getUserPreferences = async (): Promise => { const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD); const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_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 cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder; @@ -180,6 +185,7 @@ export const getUserPreferences = async (): Promise => { showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight, showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference, showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature, + showSunlight: showSunlight !== null ? showSunlight === 'true' : DEFAULT_PREFERENCES.showSunlight, cardOrder, }; } catch (error) { @@ -618,6 +624,7 @@ export const getStatisticsCardsVisibility = async (): Promise