From 8ffebfb297ab90d6354e1bc4450233107185d607 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 12 Aug 2025 18:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=8A=9F=E8=83=BD=E5=92=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=BF=A1=E6=81=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Explore 页面中添加日期选择功能,允许用户查看指定日期的健康数据 - 重构健康数据获取逻辑,支持根据日期获取健康数据 - 在个人信息页面中集成用户资料编辑功能,支持姓名、性别、年龄、体重和身高的输入 - 新增 AnimatedNumber 和 CircularRing 组件,优化数据展示效果 - 更新 package.json 和 package-lock.json,添加 react-native-svg 依赖 - 修改布局以支持新功能的显示和交互 --- app/(tabs)/explore.tsx | 80 ++-- app/(tabs)/personal.tsx | 114 ++++- app/_layout.tsx | 9 +- app/onboarding/_layout.tsx | 6 +- app/profile/edit.tsx | 454 +++++++++++++++++++ components/AnimatedNumber.tsx | 47 ++ components/CircularRing.tsx | 121 +++++ ios/Podfile.lock | 53 +++ ios/digitalpilates.xcodeproj/project.pbxproj | 2 + package-lock.json | 149 ++++++ package.json | 5 +- tsconfig.json | 5 +- types/react-native-svg.d.ts | 39 ++ utils/health.ts | 22 +- 14 files changed, 1034 insertions(+), 72 deletions(-) create mode 100644 app/profile/edit.tsx create mode 100644 components/AnimatedNumber.tsx create mode 100644 components/CircularRing.tsx create mode 100644 types/react-native-svg.d.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 139af3e..fcf4f2c 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,8 +1,10 @@ +import { AnimatedNumber } from '@/components/AnimatedNumber'; +import { CircularRing } from '@/components/CircularRing'; import { ProgressBar } from '@/components/ProgressBar'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; -import { ensureHealthPermissions, fetchTodayHealthData } from '@/utils/health'; +import { ensureHealthPermissions, fetchHealthDataForDate, fetchTodayHealthData } from '@/utils/health'; import { Ionicons } from '@expo/vector-icons'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; @@ -52,8 +54,11 @@ export default function ExploreScreen() { const [stepCount, setStepCount] = useState(null); const [activeCalories, setActiveCalories] = useState(null); const [isLoading, setIsLoading] = useState(false); + // 用于触发动画重置的 token(当日期或数据变化时更新) + const [animToken, setAnimToken] = useState(0); + const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80% - const loadHealthData = async () => { + const loadHealthData = async (targetDate?: Date) => { try { console.log('=== 开始HealthKit初始化流程 ==='); setIsLoading(true); @@ -66,11 +71,12 @@ export default function ExploreScreen() { } console.log('权限获取成功,开始获取健康数据...'); - const data = await fetchTodayHealthData(); + const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData(); console.log('设置UI状态:', data); setStepCount(data.steps); setActiveCalories(Math.round(data.activeEnergyBurned)); + setAnimToken((t) => t + 1); console.log('=== HealthKit数据获取完成 ==='); } catch (error) { @@ -86,6 +92,16 @@ export default function ExploreScreen() { }, []) ); + // 日期点击时,加载对应日期数据 + const onSelectDate = (index: number) => { + setSelectedIndex(index); + scrollToIndex(index); + const target = days[index]?.date?.toDate(); + if (target) { + loadHealthData(target); + } + }; + return ( @@ -110,10 +126,7 @@ export default function ExploreScreen() { { - setSelectedIndex(i); - scrollToIndex(i); - }} + onPress={() => onSelectDate(i)} activeOpacity={0.8} > {d.weekdayZh} @@ -128,47 +141,52 @@ export default function ExploreScreen() { {/* 今日报告 标题 */} 今日报告 - {/* 健康数据错误提示 */} - {isLoading && ( - - - 加载中... - - - - - )} + {/* 取消卡片内 loading,保持静默刷新提升体验 */} {/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */} 训练时间 - - - 80% + 消耗卡路里 - - {isLoading ? '加载中...' : activeCalories != null ? `${activeCalories} 千卡` : '——'} - + {activeCalories != null ? ( + `${Math.round(v)} 千卡`} + /> + ) : ( + —— + )} 步数 - {isLoading ? '加载中.../2000' : stepCount != null ? `${stepCount}/2000` : '——/2000'} + {stepCount != null ? ( + `${Math.round(v)}/2000`} + /> + ) : ( + ——/2000 + )} diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 500434f..ff0d3f9 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -4,7 +4,9 @@ import { useColorScheme } from '@/hooks/useColorScheme'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import React, { useMemo, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { router } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; import { Alert, SafeAreaView, @@ -29,6 +31,71 @@ export default function PersonalScreen() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; + type WeightUnit = 'kg' | 'lb'; + type HeightUnit = 'cm' | 'ft'; + type UserProfile = { + fullName?: string; + email?: string; + gender?: 'male' | 'female' | ''; + age?: string; + weightKg?: number; + heightCm?: number; + unitPref?: { weight: WeightUnit; height: HeightUnit }; + }; + + const [profile, setProfile] = useState({}); + + const load = async () => { + try { + const [p, o] = await Promise.all([ + AsyncStorage.getItem('@user_profile'), + AsyncStorage.getItem('@user_personal_info'), + ]); + let next: UserProfile = {}; + if (o) { + try { + const parsed = JSON.parse(o); + next = { + ...next, + age: parsed?.age ? String(parsed.age) : undefined, + gender: parsed?.gender || '', + heightCm: parsed?.height ? parseFloat(parsed.height) : undefined, + weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined, + }; + } catch { } + } + if (p) { + try { next = { ...next, ...JSON.parse(p) }; } catch { } + } + setProfile(next); + } catch (e) { + console.warn('加载用户资料失败', e); + } + }; + + useEffect(() => { load(); }, []); + useFocusEffect(React.useCallback(() => { load(); return () => { }; }, [])); + + const formatHeight = () => { + if (profile.heightCm == null) return '--'; + const unit = profile.unitPref?.height ?? 'cm'; + if (unit === 'cm') return `${Math.round(profile.heightCm)}cm`; + return `${round(profile.heightCm / 30.48, 1)}ft`; + }; + + const formatWeight = () => { + if (profile.weightKg == null) return '--'; + const unit = profile.unitPref?.weight ?? 'kg'; + if (unit === 'kg') return `${round(profile.weightKg, 1)}kg`; + return `${round(profile.weightKg * 2.2046226218, 1)}lb`; + }; + + const formatAge = () => (profile.age ? `${profile.age}岁` : '--'); + + const round = (n: number, d = 0) => { + const p = Math.pow(10, d); return Math.round(n * p) / p; + }; + const handleResetOnboarding = () => { Alert.alert( '重置引导', @@ -74,13 +141,13 @@ export default function PersonalScreen() { {/* 用户信息 */} - Masi Ramezanzade - Lose a Fat Program + {profile.fullName || '未设置姓名'} + 减脂计划 {/* 编辑按钮 */} - - Edit + router.push('/profile/edit')}> + 编辑 @@ -89,16 +156,16 @@ export default function PersonalScreen() { const StatsSection = () => ( - 180cm - Height + {formatHeight()} + 身高 - 65kg - Weight + {formatWeight()} + 体重 - 22yo - Age + {formatAge()} + 年龄 ); @@ -176,25 +243,26 @@ export default function PersonalScreen() { icon: 'person-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Personal Data', + title: '个人资料', + onPress: () => router.push('/profile/edit'), }, { icon: 'trophy-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Achievement', + title: '成就', }, { icon: 'time-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Activity History', + title: '活动历史', }, { icon: 'stats-chart-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Workout Progress', + title: '训练进度', }, ]; @@ -203,7 +271,7 @@ export default function PersonalScreen() { icon: 'notifications-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Pop-up Notification', + title: '弹窗通知', type: 'switch', }, ]; @@ -213,19 +281,19 @@ export default function PersonalScreen() { icon: 'mail-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Contact Us', + title: '联系我们', }, { icon: 'shield-checkmark-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Privacy Policy', + title: '隐私政策', }, { icon: 'settings-outline', iconBg: '#E8F5E8', iconColor: '#4ADE80', - title: 'Settings', + title: '设置', }, ]; @@ -250,10 +318,10 @@ export default function PersonalScreen() { > - - - - + + + + {/* 底部浮动按钮 */} diff --git a/app/_layout.tsx b/app/_layout.tsx index a15fdaf..cb19f69 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -19,10 +19,11 @@ export default function RootLayout() { return ( - - - - + + + + + diff --git a/app/onboarding/_layout.tsx b/app/onboarding/_layout.tsx index 2de8510..74ab6f6 100644 --- a/app/onboarding/_layout.tsx +++ b/app/onboarding/_layout.tsx @@ -2,9 +2,9 @@ import { Stack } from 'expo-router'; export default function OnboardingLayout() { return ( - - - + + + ); } diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx new file mode 100644 index 0000000..ff4a5cd --- /dev/null +++ b/app/profile/edit.tsx @@ -0,0 +1,454 @@ +import { Colors } from '@/constants/Colors'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import { Ionicons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as ImagePicker from 'expo-image-picker'; +import { router } from 'expo-router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Image, + KeyboardAvoidingView, + Platform, + SafeAreaView, + ScrollView, + StatusBar, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type WeightUnit = 'kg' | 'lb'; +type HeightUnit = 'cm' | 'ft'; + +interface UserProfile { + fullName?: string; + gender?: 'male' | 'female' | ''; + age?: string; // 存储为字符串,方便非必填 + // 以公制为基准存储 + weightKg?: number; // kg + heightCm?: number; // cm + avatarUri?: string | null; + unitPref?: { + weight: WeightUnit; + height: HeightUnit; + }; +} + +const STORAGE_KEY = '@user_profile'; + +export default function EditProfileScreen() { + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + const insets = useSafeAreaInsets(); + + const [profile, setProfile] = useState({ + fullName: '', + gender: '', + age: '', + weightKg: undefined, + heightCm: undefined, + avatarUri: null, + unitPref: { weight: 'kg', height: 'cm' }, + }); + + const [weightInput, setWeightInput] = useState(''); + const [heightInput, setHeightInput] = useState(''); + + // 将存储的公制值转换为当前选择单位显示 + const displayedWeight = useMemo(() => { + if (profile.weightKg == null || isNaN(profile.weightKg)) return ''; + return profile.unitPref?.weight === 'kg' + ? String(round(profile.weightKg, 1)) + : String(round(kgToLb(profile.weightKg), 1)); + }, [profile.weightKg, profile.unitPref?.weight]); + + const displayedHeight = useMemo(() => { + if (profile.heightCm == null || isNaN(profile.heightCm)) return ''; + return profile.unitPref?.height === 'cm' + ? String(Math.round(profile.heightCm)) + : String(round(cmToFt(profile.heightCm), 1)); + }, [profile.heightCm, profile.unitPref?.height]); + + useEffect(() => { + (async () => { + try { + // 读取已保存资料;兼容引导页的个人信息 + const [p, fromOnboarding] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEY), + AsyncStorage.getItem('@user_personal_info'), + ]); + + let next: UserProfile = { + fullName: '', + gender: '', + age: '', + weightKg: undefined, + heightCm: undefined, + avatarUri: null, + unitPref: { weight: 'kg', height: 'cm' }, + }; + + if (fromOnboarding) { + try { + const o = JSON.parse(fromOnboarding); + if (o?.weight) next.weightKg = parseFloat(o.weight) || undefined; + if (o?.height) next.heightCm = parseFloat(o.height) || undefined; + if (o?.age) next.age = String(o.age); + if (o?.gender) next.gender = o.gender; + } catch { } + } + + if (p) { + try { + const parsed: UserProfile = JSON.parse(p); + next = { ...next, ...parsed }; + } catch { } + } + setProfile(next); + setWeightInput(next.weightKg != null ? (next.unitPref?.weight === 'kg' ? String(round(next.weightKg, 1)) : String(round(kgToLb(next.weightKg), 1))) : ''); + setHeightInput(next.heightCm != null ? (next.unitPref?.height === 'cm' ? String(Math.round(next.heightCm)) : String(round(cmToFt(next.heightCm), 1))) : ''); + } catch (e) { + console.warn('读取资料失败', e); + } + })(); + }, []); + + const textColor = colors.text; + const placeholderColor = colors.icon; + + const handleSave = async () => { + try { + const next: UserProfile = { ...profile }; + + // 将当前输入反向同步为公制 + const w = parseFloat(weightInput); + if (!isNaN(w)) { + next.weightKg = profile.unitPref?.weight === 'kg' ? w : lbToKg(w); + } else { + next.weightKg = undefined; + } + + const h = parseFloat(heightInput); + if (!isNaN(h)) { + next.heightCm = profile.unitPref?.height === 'cm' ? h : ftToCm(h); + } else { + next.heightCm = undefined; + } + + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + Alert.alert('已保存', '个人资料已更新。'); + router.back(); + } catch (e) { + Alert.alert('保存失败', '请稍后重试'); + } + }; + + const toggleWeightUnit = (unit: WeightUnit) => { + if (unit === profile.unitPref?.weight) return; + const current = parseFloat(weightInput); + let nextValueStr = weightInput; + if (!isNaN(current)) { + nextValueStr = unit === 'kg' ? String(round(lbToKg(current), 1)) : String(round(kgToLb(current), 1)); + } + setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), weight: unit } })); + setWeightInput(nextValueStr); + }; + + const toggleHeightUnit = (unit: HeightUnit) => { + if (unit === profile.unitPref?.height) return; + const current = parseFloat(heightInput); + let nextValueStr = heightInput; + if (!isNaN(current)) { + nextValueStr = unit === 'cm' ? String(Math.round(ftToCm(current))) : String(round(cmToFt(current), 1)); + } + setProfile((p) => ({ ...p, unitPref: { ...(p.unitPref || { weight: 'kg', height: 'cm' }), height: unit } })); + setHeightInput(nextValueStr); + }; + + const pickAvatarFromLibrary = async () => { + try { + const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); + const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited'; + if (!libGranted) { + Alert.alert('权限不足', '需要相册权限以选择头像'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: true, + quality: 0.9, + aspect: [1, 1], + mediaTypes: ImagePicker.MediaTypeOptions.Images, + }); + if (!result.canceled) { + setProfile((p) => ({ ...p, avatarUri: result.assets?.[0]?.uri ?? null })); + } + } catch (e) { + Alert.alert('发生错误', '选择头像失败,请重试'); + } + }; + + return ( + + + + + {/* 统一头部 */} + + router.back()} style={styles.backButton}> + + + 编辑资料 + + + + {/* 头像(带相机蒙层,点击从相册选择) */} + + + + {profile.avatarUri ? ( + + ) : ( + + )} + + + + + + + + {/* 姓名 */} + + + setProfile((p) => ({ ...p, fullName: t }))} + /> + {/* 校验勾无需强制,仅装饰 */} + {!!profile.fullName && } + + + {/* 体重 */} + + + + + + toggleWeightUnit(key as WeightUnit)} + /> + + + {/* 身高 */} + + + + + + toggleHeightUnit(key as HeightUnit)} + /> + + + {/* 性别 */} + + + setProfile((p) => ({ ...p, gender: 'female' }))} + > + + 女性 + + setProfile((p) => ({ ...p, gender: 'male' }))} + > + + 男性 + + + + {/* 年龄 */} + + + setProfile((p) => ({ ...p, age: t }))} + /> + + + {/* 保存按钮 */} + + 保存 + + + + + ); +} + +function FieldLabel({ text }: { text: string }) { + return ( + {text} + ); +} + +function SegmentedTwo(props: { + options: { key: string; label: string }[]; + activeKey: string; + onChange: (key: string) => void; +}) { + const { options, activeKey, onChange } = props; + return ( + + {options.map((opt) => ( + onChange(opt.key)} + activeOpacity={0.8} + > + {opt.label} + + ))} + + ); +} + +// 工具函数 +function kgToLb(kg: number) { return kg * 2.2046226218; } +function lbToKg(lb: number) { return lb / 2.2046226218; } +function cmToFt(cm: number) { return cm / 30.48; } +function ftToCm(ft: number) { return ft * 30.48; } +function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; } + +const styles = StyleSheet.create({ + container: { flex: 1 }, + avatarCircle: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: '#E8D4F0', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 12, + }, + avatarImage: { + width: 120, + height: 120, + borderRadius: 60, + }, + avatarOverlay: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.25)', + }, + inputWrapper: { + height: 52, + backgroundColor: '#fff', + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 16, + alignItems: 'center', + flexDirection: 'row', + }, + textInput: { + flex: 1, + fontSize: 16, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + flex1: { flex: 1 }, + segmented: { + flexDirection: 'row', + borderRadius: 12, + padding: 4, + backgroundColor: '#EFEFEF', + }, + segmentBtn: { + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 10, + backgroundColor: 'transparent', + minWidth: 64, + alignItems: 'center', + }, + selector: { + flexDirection: 'row', + gap: 12, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderRadius: 12, + padding: 8, + }, + selectorItem: { + flex: 1, + height: 48, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + selectorEmoji: { fontSize: 16, marginBottom: 2 }, + selectorText: { fontSize: 15, fontWeight: '600' }, + saveBtn: { + marginTop: 24, + height: 56, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 0, + marginBottom: 8, + }, + backButton: { padding: 4, width: 32 }, + headerTitle: { fontSize: 18, fontWeight: '700', color: '#192126' }, +}); + + diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx new file mode 100644 index 0000000..b115455 --- /dev/null +++ b/components/AnimatedNumber.tsx @@ -0,0 +1,47 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, Easing, TextStyle } from 'react-native'; + +type AnimatedNumberProps = { + value: number; // 最终值 + durationMs?: number; + format?: (v: number) => string; + style?: TextStyle; + /** 当该值变化时,从0重新动画 */ + resetToken?: unknown; +}; + +export function AnimatedNumber({ + value, + durationMs = 800, + format, + style, + resetToken, +}: AnimatedNumberProps) { + const animated = useRef(new Animated.Value(0)).current; + const [display, setDisplay] = useState('0'); + + useEffect(() => { + animated.stopAnimation(() => { + animated.setValue(0); + Animated.timing(animated, { + toValue: value, + duration: durationMs, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, resetToken]); + + useEffect(() => { + const id = animated.addListener(({ value: v }) => { + const num = Number(v) || 0; + setDisplay(format ? format(num) : `${Math.round(num)}`); + }); + return () => animated.removeListener(id); + }, [animated, format]); + + return {display}; +} + + diff --git a/components/CircularRing.tsx b/components/CircularRing.tsx new file mode 100644 index 0000000..e83fc39 --- /dev/null +++ b/components/CircularRing.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { Animated, Easing, StyleSheet, Text, View } from 'react-native'; +import Svg, { Circle, G } from 'react-native-svg'; + +type CircularRingProps = { + size?: number; + strokeWidth?: number; + trackColor?: string; + progressColor?: string; + progress: number; // 0..1 + durationMs?: number; + showCenterText?: boolean; + /** 当该值变化时,会从0重新动画到 progress */ + resetToken?: unknown; + /** 进度起始角度(度),默认 -90,使进度从正上方开始 */ + startAngleDeg?: number; +}; + +/** + * 纯 View 实现的圆环进度条(无依赖),通过两个半圆与旋转实现。 + */ +export function CircularRing({ + size = 120, + strokeWidth = 12, + trackColor = '#E2D9FD', + progressColor = '#8B74F3', + progress, + durationMs = 900, + showCenterText = true, + resetToken, + startAngleDeg = -90, +}: CircularRingProps) { + const clamped = useMemo(() => { + if (Number.isNaN(progress)) return 0; + return Math.min(1, Math.max(0, progress)); + }, [progress]); + + const animated = useRef(new Animated.Value(0)).current; + + useEffect(() => { + // 每次 resetToken 或目标进度变化时,从0动画到 clamped + animated.stopAnimation(() => { + animated.setValue(0); + Animated.timing(animated, { + toValue: clamped, + duration: durationMs, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clamped, resetToken]); + const radius = useMemo(() => (size - strokeWidth) / 2, [size, strokeWidth]); + const circumference = useMemo(() => 2 * Math.PI * radius, [radius]); + const dashOffset = animated.interpolate({ + inputRange: [0, 1], + outputRange: [circumference, 0], + }); + + const percentText = useMemo(() => `${Math.round(clamped * 100)}%`, [clamped]); + const containerStyle = useMemo(() => [styles.container, { width: size, height: size }], [size]); + + return ( + + + + {/* 轨道 */} + + {/* 进度 */} + + + + + {showCenterText && ( + + {percentText} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + }, + center: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + }, + centerText: { + fontSize: 18, + fontWeight: '800', + color: '#8B74F3', + }, +}); + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e1f741b..7939600 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2087,6 +2087,55 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNSVG (15.12.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNSVG/common (= 15.12.1) + - Yoga + - RNSVG/common (15.12.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - SDWebImage (5.21.1): - SDWebImage/Core (= 5.21.1) - SDWebImage/Core (5.21.1) @@ -2199,6 +2248,7 @@ DEPENDENCIES: - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSVG (from `../node_modules/react-native-svg`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -2404,6 +2454,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSVG: + :path: "../node_modules/react-native-svg" Yoga: :path: "../node_modules/react-native/ReactCommon/yoga" @@ -2506,6 +2558,7 @@ SPEC CHECKSUMS: RNGestureHandler: 7d0931a61d7ba0259f32db0ba7d0963c3ed15d2b RNReanimated: 2313402fe27fecb7237619e9c6fcee3177f08a65 RNScreens: 482e9707f9826230810c92e765751af53826d509 + RNSVG: ba53827311fd9f8a14e06626365a749ce7713975 SDWebImage: f29024626962457f3470184232766516dee8dfea SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj index 7e059f0..29cccf3 100644 --- a/ios/digitalpilates.xcodeproj/project.pbxproj +++ b/ios/digitalpilates.xcodeproj/project.pbxproj @@ -290,6 +290,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", @@ -304,6 +305,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", diff --git a/package-lock.json b/package-lock.json index a365080..f2a84e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-svg": "^15.12.1", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5" }, @@ -4543,6 +4544,11 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5204,6 +5210,52 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://mirrors.tencent.com/npm/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://mirrors.tencent.com/npm/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://mirrors.tencent.com/npm/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://mirrors.tencent.com/npm/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5423,6 +5475,58 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://mirrors.tencent.com/npm/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://mirrors.tencent.com/npm/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://mirrors.tencent.com/npm/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -5498,6 +5602,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://mirrors.tencent.com/npm/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -8703,6 +8819,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://mirrors.tencent.com/npm/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -9343,6 +9465,18 @@ "node": ">=10" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://mirrors.tencent.com/npm/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -10538,6 +10672,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://mirrors.tencent.com/npm/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.20.0", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", diff --git a/package.json b/package.json index 708a278..c7f8056 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "expo-font": "~13.3.2", "expo-haptics": "~14.1.4", "expo-image": "~2.4.0", + "expo-image-picker": "~16.1.4", "expo-linear-gradient": "^14.1.5", "expo-linking": "~7.1.7", "expo-router": "~5.1.4", @@ -39,9 +40,9 @@ "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-svg": "^15.12.1", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5", - "expo-image-picker": "~16.1.4" + "react-native-webview": "13.13.5" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/tsconfig.json b/tsconfig.json index 909e901..964bc20 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts" + "expo-env.d.ts", + "types/**/*.d.ts" ] -} +} \ No newline at end of file diff --git a/types/react-native-svg.d.ts b/types/react-native-svg.d.ts new file mode 100644 index 0000000..556e2db --- /dev/null +++ b/types/react-native-svg.d.ts @@ -0,0 +1,39 @@ +declare module 'react-native-svg' { + import * as React from 'react'; + import { ViewProps } from 'react-native'; + + export interface SvgProps extends ViewProps { + width?: number | string; + height?: number | string; + viewBox?: string; + } + export default function Svg(props: React.PropsWithChildren): React.ReactElement | null; + + export interface CommonProps { + fill?: string; + stroke?: string; + strokeWidth?: number; + strokeLinecap?: 'butt' | 'round' | 'square'; + strokeLinejoin?: 'miter' | 'round' | 'bevel'; + strokeDasharray?: string | number[]; + strokeDashoffset?: number; + } + + export interface CircleProps extends CommonProps { + cx?: number; + cy?: number; + r?: number; + originX?: number; + originY?: number; + } + export const Circle: React.ComponentType; + + export interface GProps extends CommonProps { + rotation?: number; + originX?: number; + originY?: number; + } + export const G: React.ComponentType>; +} + + diff --git a/utils/health.ts b/utils/health.ts index 1818eab..c92490d 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -38,12 +38,15 @@ export async function ensureHealthPermissions(): Promise { }); } -export async function fetchTodayHealthData(): Promise { - console.log('开始获取今日健康数据...'); +export async function fetchHealthDataForDate(date: Date): Promise { + console.log('开始获取指定日期健康数据...', date); - const start = new Date(); + const start = new Date(date); start.setHours(0, 0, 0, 0); - const options = { startDate: start.toISOString() } as any; + const end = new Date(date); + end.setHours(23, 59, 59, 999); + + const options = { startDate: start.toISOString(), endDate: end.toISOString() } as any; console.log('查询选项:', options); @@ -73,11 +76,16 @@ export async function fetchTodayHealthData(): Promise { return resolve(0); } console.log('卡路里数据:', res); - // library returns value as number in kilocalories - resolve(res[0]?.value || 0); + // 求和该日内的所有记录(单位:千卡) + const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0); + resolve(total); }); }); - console.log('今日健康数据获取完成:', { steps, calories }); + console.log('指定日期健康数据获取完成:', { steps, calories }); return { steps, activeEnergyBurned: calories }; +} + +export async function fetchTodayHealthData(): Promise { + return fetchHealthDataForDate(new Date()); } \ No newline at end of file