From a70cb1e407a018dbd1f39f333ead10b8619481ea Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 2 Sep 2025 19:22:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AD=A5=E6=95=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E9=80=89=E6=8B=A9=E5=92=8C=E6=AD=A5=E6=95=B0?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=B1=95=E7=A4=BA=20feat:=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0StepsCard=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E4=BA=8B=E4=BB=B6=E5=9B=9E=E8=B0=83=20feat:?= =?UTF-8?q?=20=E5=9C=A8WaterIntakeCard=E4=B8=AD=E6=B7=BB=E5=8A=A0=E9=9C=87?= =?UTF-8?q?=E5=8A=A8=E5=8F=8D=E9=A6=88=E5=8A=9F=E8=83=BD=20fix:=20?= =?UTF-8?q?=E5=9C=A8=E7=94=A8=E6=88=B7=E9=87=8D=E5=BB=BA=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E5=AD=98authToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/statistics.tsx | 13 +- app/steps/detail.tsx | 544 +++++++++++++++++++++++++++++++++ components/StepsCard.tsx | 29 +- components/WaterIntakeCard.tsx | 11 + store/userSlice.ts | 12 +- 5 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 app/steps/detail.tsx diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 45478b5..b4da961 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -19,17 +19,17 @@ import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice'; import { fetchTodayWaterStats, selectTodayStats } from '@/store/waterSlice'; -import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { ensureHealthPermissions, fetchHealthDataForDate, fetchRecentHRV } from '@/utils/health'; import { getTestHealthData } from '@/utils/mockHealthData'; +import { WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { calculateNutritionGoals } from '@/utils/nutrition'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import dayjs from 'dayjs'; -import { debounce } from 'lodash'; import { LinearGradient } from 'expo-linear-gradient'; +import { debounce } from 'lodash'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { AppState, @@ -85,14 +85,14 @@ export default function ExploreScreen() { const days = getMonthDaysZh(); return days[selectedIndex]?.date?.toDate() ?? new Date(); }, [selectedIndex]); - + const currentSelectedDateString = useMemo(() => { return dayjs(currentSelectedDate).format('YYYY-MM-DD'); }, [currentSelectedDate]); // 从 Redux 获取指定日期的健康数据 const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); - + // 获取今日喝水统计数据 const todayWaterStats = useAppSelector(selectTodayStats); @@ -173,7 +173,7 @@ export default function ExploreScreen() { const cacheKey = `${dateKey}-${dataType}`; const lastUpdate = dataTimestampRef.current[cacheKey]; const now = Date.now(); - + // 营养数据使用更短的缓存时间,其他数据使用5分钟 const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000; @@ -427,7 +427,7 @@ export default function ExploreScreen() { // 执行压力检查 await checkStressLevelAndNotify(); - + // 执行喝水目标检查 await checkWaterGoalAndNotify(); } catch (error) { @@ -625,6 +625,7 @@ export default function ExploreScreen() { stepGoal={stepGoal} hourlySteps={hourlySteps} style={styles.stepsCardOverride} + onPress={() => pushIfAuthedElseLogin('/steps/detail')} /> diff --git a/app/steps/detail.tsx b/app/steps/detail.tsx new file mode 100644 index 0000000..95d46c1 --- /dev/null +++ b/app/steps/detail.tsx @@ -0,0 +1,544 @@ +import { DateSelector } from '@/components/DateSelector'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux'; +import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice'; +import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; +import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health'; +import { getTestHealthData } from '@/utils/mockHealthData'; +import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Animated, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function StepsDetailScreen() { + const router = useRouter(); + const dispatch = useAppDispatch(); + const insets = useSafeAreaInsets(); + + // 开发调试:设置为true来使用mock数据 + const useMockData = __DEV__; + + // 日期选择相关状态 + const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); + + // 数据加载状态 + const [isLoading, setIsLoading] = useState(false); + + // 获取当前选中日期 + const currentSelectedDate = useMemo(() => { + const days = getMonthDaysZh(); + return days[selectedIndex]?.date?.toDate() ?? new Date(); + }, [selectedIndex]); + + const currentSelectedDateString = useMemo(() => { + return dayjs(currentSelectedDate).format('YYYY-MM-DD'); + }, [currentSelectedDate]); + + // 从 Redux 获取指定日期的健康数据 + const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); + + // 解构健康数据(支持mock数据) + const mockData = useMockData ? getTestHealthData('mock') : null; + const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null); + const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []); + + + // 为每个柱体创建独立的动画值 + const animatedValues = useRef( + Array.from({ length: 24 }, () => new Animated.Value(0)) + ).current; + + // 计算柱状图数据 + const chartData = useMemo(() => { + if (!hourlySteps || hourlySteps.length === 0) { + return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 })); + } + + // 找到最大步数用于计算高度比例 + const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1); + const maxHeight = 120; // 详情页面使用更大的高度 + + return hourlySteps.map(data => ({ + ...data, + height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0 + })); + }, [hourlySteps]); + + // 计算平均值刻度线位置 + const averageLinePosition = useMemo(() => { + if (!hourlySteps || hourlySteps.length === 0 || !chartData || chartData.length === 0) return 0; + + const activeHours = hourlySteps.filter(h => h.steps > 0); + if (activeHours.length === 0) return 0; + + const avgSteps = activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length; + const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1); + const maxHeight = 120; + + return maxSteps > 0 ? (avgSteps / maxSteps) * maxHeight : 0; + }, [hourlySteps, chartData]); + + // 获取当前小时 + const currentHour = new Date().getHours(); + + // 触发柱体动画 + useEffect(() => { + if (chartData && chartData.length > 0) { + // 重置所有动画值 + animatedValues.forEach(animValue => animValue.setValue(0)); + + // 延迟启动动画,创建波浪效果 + chartData.forEach((data, index) => { + if (data.steps > 0) { + setTimeout(() => { + Animated.spring(animatedValues[index], { + toValue: 1, + tension: 120, + friction: 8, + useNativeDriver: false, + }).start(); + }, index * 50); // 每个柱体延迟50ms + } + }); + } + }, [chartData, animatedValues]); + + // 加载健康数据 + const loadHealthData = async (targetDate: Date) => { + if (useMockData) return; // 如果使用mock数据,不需要加载 + + try { + setIsLoading(true); + console.log('加载步数详情数据...', targetDate); + + const ok = await ensureHealthPermissions(); + if (!ok) { + console.warn('无法获取健康权限'); + return; + } + + const data = await fetchHealthDataForDate(targetDate); + + console.log('data', data); + + const dateString = dayjs(targetDate).format('YYYY-MM-DD'); + + // 使用 Redux 存储健康数据 + dispatch(setHealthData({ + date: dateString, + data: data + })); + + console.log('步数详情数据加载完成'); + } catch (error) { + console.error('加载步数详情数据失败:', error); + } finally { + setIsLoading(false); + } + }; + + // 日期选择处理 + const onSelectDate = (index: number, date: Date) => { + setSelectedIndex(index); + loadHealthData(date); + }; + + // 页面初始化时加载当前日期数据 + useEffect(() => { + loadHealthData(currentSelectedDate); + }, []); + + // 计算总步数和平均步数 + const totalSteps = stepCount || 0; + const averageHourlySteps = useMemo(() => { + if (!hourlySteps || hourlySteps.length === 0) return 0; + const activeHours = hourlySteps.filter(h => h.steps > 0); + if (activeHours.length === 0) return 0; + return Math.round(activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length); + }, [hourlySteps]); + + // 找出最活跃的时间段 + const mostActiveHour = useMemo(() => { + if (!hourlySteps || hourlySteps.length === 0) return null; + const maxStepsData = hourlySteps.reduce((max, current) => + current.steps > max.steps ? current : max + ); + return maxStepsData.steps > 0 ? maxStepsData : null; + }, [hourlySteps]); + + return ( + + {/* 背景渐变 */} + + + + {/* 顶部导航栏 */} + + router.back()} + > + + + 步数详情 + + + + + {/* 日期选择器 */} + + + {/* 统计卡片 */} + + {isLoading ? ( + + 加载中... + + ) : ( + + + {totalSteps.toLocaleString()} + 总步数 + + + {averageHourlySteps} + 平均每小时 + + + + {mostActiveHour ? `${mostActiveHour.hour}:00` : '--'} + + 最活跃时段 + + + )} + + + {/* 详细柱状图卡片 */} + + + 每小时步数分布 + + {dayjs(currentSelectedDate).format('YYYY年MM月DD日')} + + + + {/* 柱状图容器 */} + + {/* 平均值刻度线 - 放在chartArea外面,相对于chartContainer定位 */} + {averageLinePosition > 0 && ( + + + {/* 创建更多的虚线段来确保完整覆盖 */} + {Array.from({ length: 80 }, (_, index) => ( + 0 ? 2 : 0, + flex: 0 // 防止 flex 拉伸 + } + ]} + /> + ))} + + + 平均 {averageHourlySteps}步 + + + )} + + {/* 柱状图区域 */} + + {chartData.map((data, index) => { + const isActive = data.steps > 0; + const isCurrent = index <= currentHour; + const isKeyTime = index === 0 || index === 12 || index === 23; + + // 动画变换 + const animatedHeight = animatedValues[index].interpolate({ + inputRange: [0, 1], + outputRange: [0, data.height], + }); + + const animatedOpacity = animatedValues[index].interpolate({ + inputRange: [0, 1], + outputRange: [0, 1], + }); + + return ( + + {/* 背景柱体 */} + + + {/* 数据柱体 */} + {isActive && ( + + )} + + {/* 步数标签(仅在有数据且是关键时间点时显示) */} + {/* {isActive && isKeyTime && ( + + {data.steps} + + )} */} + + ); + })} + + + {/* 底部时间轴标签 */} + + 0:00 + 12:00 + 24:00 + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradientBackground: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + safeArea: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingVertical: 16, + backgroundColor: 'transparent', + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: '#192126', + }, + headerRight: { + width: 40, + }, + scrollView: { + flex: 1, + paddingHorizontal: 20, + }, + statsCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginVertical: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 6, + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 24, + fontWeight: '700', + color: '#192126', + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + color: '#64748B', + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 20, + }, + loadingText: { + fontSize: 16, + color: '#64748B', + }, + chartCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.08, + shadowRadius: 12, + elevation: 6, + }, + chartHeader: { + marginBottom: 20, + }, + chartTitle: { + fontSize: 18, + fontWeight: '600', + color: '#192126', + marginBottom: 4, + }, + chartSubtitle: { + fontSize: 14, + color: '#64748B', + }, + chartContainer: { + position: 'relative', + }, + timeLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 8, + paddingHorizontal: 8, + }, + timeLabel: { + fontSize: 12, + color: '#64748B', + fontWeight: '500', + }, + chartArea: { + flexDirection: 'row', + alignItems: 'flex-end', + height: 120, + justifyContent: 'space-between', + paddingHorizontal: 4, + }, + barContainer: { + width: 8, + height: 120, + alignItems: 'center', + justifyContent: 'flex-end', + position: 'relative', + }, + backgroundBar: { + width: 8, + height: 120, + borderRadius: 2, + position: 'absolute', + bottom: 0, + }, + dataBar: { + width: 8, + borderRadius: 2, + position: 'absolute', + bottom: 0, + }, + stepLabel: { + position: 'absolute', + top: -20, + alignItems: 'center', + }, + stepLabelText: { + fontSize: 10, + color: '#64748B', + fontWeight: '500', + }, + averageLine: { + position: 'absolute', + left: 4, // 匹配 chartArea 的 paddingHorizontal + right: 4, // 匹配 chartArea 的 paddingHorizontal + flexDirection: 'row', + alignItems: 'center', + zIndex: 1, + }, + averageLineDashContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginRight: 8, + overflow: 'hidden', // 防止虚线段溢出容器 + }, + dashSegment: { + width: 3, + height: 1.5, + backgroundColor: '#FFA726', + opacity: 0.8, + }, + averageLineLabel: { + fontSize: 10, + color: '#FFA726', + fontWeight: '600', + backgroundColor: '#FFFFFF', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + borderWidth: 0.5, + borderColor: '#FFA726', + }, +}); \ No newline at end of file diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx index 3a0e40e..13cc461 100644 --- a/components/StepsCard.tsx +++ b/components/StepsCard.tsx @@ -3,6 +3,7 @@ import { Animated, StyleSheet, Text, + TouchableOpacity, View, ViewStyle } from 'react-native'; @@ -17,13 +18,15 @@ interface StepsCardProps { stepGoal: number; hourlySteps: HourlyStepData[]; style?: ViewStyle; + onPress?: () => void; // 新增点击事件回调 } const StepsCard: React.FC = ({ stepCount, stepGoal, hourlySteps, - style + style, + onPress }) => { // 为每个柱体创建独立的动画值 const animatedValues = useRef( @@ -69,8 +72,8 @@ const StepsCard: React.FC = ({ } }, [chartData, animatedValues]); - return ( - + const CardContent = () => ( + <> {/* 标题和步数显示 */} 步数 @@ -140,6 +143,26 @@ const StepsCard: React.FC = ({ resetToken={stepCount} /> + + ); + + // 如果有点击事件,包装在TouchableOpacity中 + if (onPress) { + return ( + + + + ); + } + + // 否则使用普通View + return ( + + ); }; diff --git a/components/WaterIntakeCard.tsx b/components/WaterIntakeCard.tsx index d38cdce..f839680 100644 --- a/components/WaterIntakeCard.tsx +++ b/components/WaterIntakeCard.tsx @@ -1,6 +1,7 @@ import { useWaterDataByDate } from '@/hooks/useWaterData'; import { getQuickWaterAmount } from '@/utils/userPreferences'; import dayjs from 'dayjs'; +import * as Haptics from 'expo-haptics'; import React, { useEffect, useMemo, useState } from 'react'; import { Animated, @@ -113,6 +114,11 @@ const WaterIntakeCard: React.FC = ({ // 处理添加喝水 - 右上角按钮直接添加 const handleQuickAddWater = async () => { + // 触发震动反馈 + if (process.env.EXPO_OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } + // 使用用户配置的快速添加饮水量 const waterAmount = quickWaterAmount; // 如果有选中日期,则为该日期添加记录;否则为今天添加记录 @@ -122,6 +128,11 @@ const WaterIntakeCard: React.FC = ({ // 处理卡片点击 - 打开配置饮水弹窗 const handleCardPress = () => { + // 触发震动反馈 + if (process.env.EXPO_OS === 'ios') { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + setIsModalVisible(true); }; diff --git a/store/userSlice.ts b/store/userSlice.ts index 3a26abe..4ee4625 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -133,9 +133,10 @@ export const login = createAsyncThunk( ); export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { - const [profileStr, privacyAgreedStr] = await Promise.all([ + const [profileStr, privacyAgreedStr, token] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.userProfile), AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed), + AsyncStorage.getItem(STORAGE_KEYS.authToken), ]); let profile: UserProfile = {}; @@ -143,7 +144,13 @@ export const rehydrateUser = createAsyncThunk('user/rehydrate', async () => { try { profile = JSON.parse(profileStr) as UserProfile; } catch { profile = {}; } } const privacyAgreed = privacyAgreedStr === 'true'; - return { profile, privacyAgreed } as { profile: UserProfile; privacyAgreed: boolean }; + + // 如果有 token,需要设置到 API 客户端 + if (token) { + await setAuthToken(token); + } + + return { profile, privacyAgreed, token } as { profile: UserProfile; privacyAgreed: boolean; token: string | null }; }); export const setPrivacyAgreed = createAsyncThunk('user/setPrivacyAgreed', async () => { @@ -272,6 +279,7 @@ const userSlice = createSlice({ .addCase(rehydrateUser.fulfilled, (state, action) => { state.profile = action.payload.profile; state.privacyAgreed = action.payload.privacyAgreed; + state.token = action.payload.token; if (!state.profile?.name || !state.profile.name.trim()) { state.profile.name = DEFAULT_MEMBER_NAME; }