Compare commits
3 Commits
feb5052fcd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17664c679d | ||
|
|
e51aca2fdb | ||
|
|
76c37bfeb0 |
@@ -751,3 +751,44 @@ list: {
|
||||
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
|
||||
3. **定期审查**:定期检查是否有硬编码文本遗漏
|
||||
4. **测试验证**:在开发完成后测试语言切换功能是否正常
|
||||
|
||||
## Expo Image 封装与使用规范
|
||||
|
||||
**最后更新**: 2025-12-18
|
||||
|
||||
### 重要原则
|
||||
|
||||
**禁止直接使用 `expo-image` 的 `Image` 组件**,必须使用封装好的 `@/components/ui/Image` 组件。
|
||||
|
||||
### 问题描述
|
||||
|
||||
为了满足后端 API 安全要求,所有图片请求都需要携带特定的 `User-Agent` 和 `Referer` 请求头。`expo-image` 默认不会添加这些头信息。
|
||||
|
||||
### 解决方案
|
||||
|
||||
创建了一个封装组件 `@/components/ui/Image.tsx`,该组件自动拦截 `source` 属性并注入所需的请求头。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 替换导入语句
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
import { Image } from "expo-image";
|
||||
|
||||
// ✅ 正确写法
|
||||
import { Image } from "@/components/ui/Image";
|
||||
```
|
||||
|
||||
#### 2. 组件功能
|
||||
|
||||
封装的组件会自动处理以下逻辑:
|
||||
|
||||
1. **注入 User-Agent**: 使用 `Out Live/{version} (iOS)` 格式
|
||||
2. **注入 Referer**: 使用 `API_ORIGIN` 常量 (`https://pilate.richarjiang.com`)
|
||||
3. **支持多种 Source 类型**: 自动处理 `string` (URL), `object` (带 uri), `number` (本地资源) 以及它们的数组形式
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/ui/Image.tsx`: 核心封装实现
|
||||
- `components/WorkoutSummaryCard.tsx`: 使用示例
|
||||
|
||||
2
app.json
2
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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import dayjs from 'dayjs';
|
||||
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MedicationCard } from '@/components/medication/MedicationCard';
|
||||
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
@@ -20,7 +21,6 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { updateUser, type UserLanguage } from '@/services/users';
|
||||
@@ -24,7 +25,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -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<string[]>(DEFAULT_CARD_ORDER);
|
||||
|
||||
@@ -581,6 +583,15 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
sunlight: {
|
||||
visible: cardVisibility.showSunlight,
|
||||
component: (
|
||||
<SunlightCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
)
|
||||
},
|
||||
fitness: {
|
||||
visible: cardVisibility.showFitnessRings,
|
||||
component: (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
|
||||
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -33,7 +34,6 @@ import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import i18n from '@/i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
@@ -29,7 +30,6 @@ import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiC
|
||||
import { api, getAuthToken, postTextStream } from '@/services/api';
|
||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||
import { Image } from 'expo-image';
|
||||
import { HistoryModal } from '../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
|
||||
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -13,7 +14,6 @@ import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
||||
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
|
||||
import { saveNutritionToHealthKit } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
@@ -9,7 +10,6 @@ import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/servic
|
||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -6,7 +7,6 @@ import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -11,7 +12,6 @@ import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
@@ -13,7 +14,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
@@ -12,7 +13,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image as ExpoImage } from '@/components/ui/Image';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
@@ -9,7 +10,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
@@ -16,15 +17,14 @@ import {
|
||||
joinFamilyGroup,
|
||||
selectFamilyGroup,
|
||||
} from '@/store/familyHealthSlice';
|
||||
import {
|
||||
import {
|
||||
fetchHealthHistory,
|
||||
selectHealthHistoryProgress
|
||||
selectHealthHistoryProgress
|
||||
} from '@/store/healthSlice';
|
||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePicker
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import InfoCard from '@/components/ui/InfoCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
|
||||
@@ -37,7 +38,6 @@ import { Picker } from '@react-native-picker/picker';
|
||||
import Voice from '@react-native-voice/voice';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -15,7 +16,6 @@ import { Picker } from '@react-native-picker/picker';
|
||||
import Voice from '@react-native-voice/voice';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -10,7 +11,6 @@ import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||
import { MedicationRecognitionTask } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -18,7 +19,6 @@ import type { Medication, MedicationForm } from '@/types/medication';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
||||
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -27,7 +28,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -13,7 +14,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
||||
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
||||
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RectButton, Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { fetchHRVWithStatus } from '@/utils/health';
|
||||
import { convertHrvToStressIndex } from '@/utils/stress';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, Modal, Platform, Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { RankingItem } from '@/store/challengesSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import QuickChips from './QuickChips';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { MedicalRecordItem } from '@/services/healthProfile';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
|
||||
@@ -6,7 +7,6 @@ import type { MedicationDisplayItem } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
// 导入统一的食物类型定义
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||
import type { FoodItem } from '@/types/food';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
// 导入统一的食物类型定义
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
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<HealthDataCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
style,
|
||||
onPress
|
||||
onPress,
|
||||
icon,
|
||||
iconSource,
|
||||
subtitle
|
||||
}) => {
|
||||
const Container = onPress ? Pressable : View;
|
||||
|
||||
@@ -30,13 +38,22 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||
accessibilityHint={onPress ? `${title} details` : undefined}
|
||||
>
|
||||
<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>
|
||||
</View>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.unit}>{unit}</Text>
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Container>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ const HeartRateCard: React.FC<HeartRateCardProps> = ({
|
||||
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;
|
||||
export default HeartRateCard;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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'
|
||||
}
|
||||
});
|
||||
44
components/ui/Image.tsx
Normal file
44
components/ui/Image.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { API_ORIGIN } from '@/constants/Api';
|
||||
import Constants from 'expo-constants';
|
||||
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
|
||||
import React, { forwardRef, useMemo } from 'react';
|
||||
|
||||
// Construct User-Agent
|
||||
const APP_NAME = Constants.expoConfig?.name || 'Out Live';
|
||||
const APP_VERSION = Constants.expoConfig?.version || '1.1.5';
|
||||
const USER_AGENT = `${APP_NAME}/${APP_VERSION} (iOS)`;
|
||||
|
||||
export type ImageProps = ExpoImageProps;
|
||||
|
||||
export const Image = forwardRef<ExpoImage, ImageProps>(({ source, ...props }, ref) => {
|
||||
const finalSource = useMemo(() => {
|
||||
if (!source) return source;
|
||||
|
||||
const headers = {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Referer': API_ORIGIN,
|
||||
};
|
||||
|
||||
const addHeaders = (src: any) => {
|
||||
if (typeof src === 'number' || src === null || src === undefined) return src;
|
||||
if (typeof src === 'string') return { uri: src, headers };
|
||||
if (typeof src === 'object' && 'uri' in src) {
|
||||
return {
|
||||
...src,
|
||||
headers: { ...headers, ...(src.headers || {}) }
|
||||
};
|
||||
}
|
||||
return src;
|
||||
};
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
return source.map(addHeaders);
|
||||
}
|
||||
|
||||
return addHeaders(source);
|
||||
}, [source]);
|
||||
|
||||
return <ExpoImage {...props} source={finalSource} ref={ref} />;
|
||||
});
|
||||
|
||||
Image.displayName = 'Image';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
@@ -7,7 +8,6 @@ import { fetchWeightHistory } from '@/store/userSlice';
|
||||
import { BMI_CATEGORIES } from '@/utils/bmi';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -123,6 +123,7 @@ export const statisticsCustomization = {
|
||||
steps: 'Steps',
|
||||
stress: 'Stress',
|
||||
sleep: 'Sleep',
|
||||
sunlight: 'Sun',
|
||||
fitnessRings: 'Fitness Rings',
|
||||
water: 'Water Intake',
|
||||
basalMetabolism: 'Basal Metabolism',
|
||||
|
||||
@@ -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: '血氧饱和度',
|
||||
},
|
||||
|
||||
@@ -123,6 +123,7 @@ export const statisticsCustomization = {
|
||||
steps: '步数',
|
||||
stress: '压力',
|
||||
sleep: '睡眠',
|
||||
sunlight: '晒太阳',
|
||||
fitnessRings: '健身圆环',
|
||||
water: '饮水',
|
||||
basalMetabolism: '基础代谢',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<HKObjectType> {
|
||||
var types: Set<HKObjectType> = [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,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.5</string>
|
||||
<string>1.1.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<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> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
@@ -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<UserPreferences> => {
|
||||
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<UserPreferences> => {
|
||||
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<StatisticsCardsVis
|
||||
showWeight: userPreferences.showWeight,
|
||||
showCircumference: userPreferences.showCircumference,
|
||||
showWristTemperature: userPreferences.showWristTemperature,
|
||||
showSunlight: userPreferences.showSunlight,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取首页卡片显示设置失败:', error);
|
||||
@@ -634,6 +641,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
||||
showWeight: DEFAULT_PREFERENCES.showWeight,
|
||||
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
||||
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 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
||||
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
|
||||
case 'showSunlight': storageKey = PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD; break;
|
||||
default: return;
|
||||
}
|
||||
await AsyncStorage.setItem(storageKey, value.toString());
|
||||
|
||||
Reference in New Issue
Block a user