From b93a863e257db2a179735eec5dd9fa40d9f5f236 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 21 Aug 2025 15:22:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E7=83=AD=E5=8A=9B=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在个人页面新增活动热力图展示组件,并实现浮动动画效果优化统计卡片交互体验。 --- app/(tabs)/personal.tsx | 38 +--- app/(tabs)/statistics.tsx | 138 +++++++++----- components/ActivityHeatMap.tsx | 329 +++++++++++++++++++++++++++++++++ store/userSlice.ts | 39 +++- 4 files changed, 454 insertions(+), 90 deletions(-) create mode 100644 components/ActivityHeatMap.tsx diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index 99a1df7..355a3ec 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -1,17 +1,17 @@ +import ActivityHeatMap from '@/components/ActivityHeatMap'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { Colors } from '@/constants/Colors'; import { getTabBarBottomPadding } from '@/constants/TabBar'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { DEFAULT_MEMBER_NAME, fetchMyProfile } from '@/store/userSlice'; +import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { Ionicons } from '@expo/vector-icons'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useFocusEffect } from '@react-navigation/native'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useMemo, useState } from 'react'; -import { Alert, Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { Image, Linking, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg'; @@ -40,6 +40,7 @@ export default function PersonalScreen() { useFocusEffect( React.useCallback(() => { dispatch(fetchMyProfile()); + dispatch(fetchActivityHistory()) }, [dispatch]) ); @@ -68,36 +69,6 @@ export default function PersonalScreen() { // 颜色令牌 const colorTokens = colors; - const handleResetOnboarding = () => { - Alert.alert( - '重置引导', - '确定要重置引导流程吗?下次启动应用时将重新显示引导页面。', - [ - { - text: '取消', - style: 'cancel', - }, - { - text: '确定', - style: 'destructive', - onPress: async () => { - try { - await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']); - Alert.alert('成功', '引导状态已重置,请重启应用查看效果。'); - } catch (error) { - console.error('重置引导状态失败:', error); - Alert.alert('错误', '重置失败,请稍后重试。'); - } - }, - }, - ] - ); - }; - - - - - const UserInfoSection = () => ( @@ -304,6 +275,7 @@ export default function PersonalScreen() { > + {menuSections.map((section, index) => ( ))} diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 64dd7e6..a26c739 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -19,6 +19,7 @@ import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { + Animated, SafeAreaView, ScrollView, StyleSheet, @@ -28,6 +29,56 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +// 浮动动画组件 +const FloatingCard = ({ children, delay = 0, style }: { + children: React.ReactNode; + delay?: number; + style?: any; +}) => { + const floatAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const startAnimation = () => { + Animated.loop( + Animated.sequence([ + Animated.timing(floatAnim, { + toValue: 1, + duration: 3000, + delay: delay, + useNativeDriver: true, + }), + Animated.timing(floatAnim, { + toValue: 0, + duration: 3000, + useNativeDriver: true, + }), + ]) + ).start(); + }; + + startAnimation(); + }, [floatAnim, delay]); + + const translateY = floatAnim.interpolate({ + inputRange: [0, 1], + outputRange: [-2, -6], + }); + + return ( + + {children} + + ); +}; + export default function ExploreScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; @@ -309,14 +360,15 @@ export default function ExploreScreen() { {/* 左列 */} - + + + - + 消耗卡路里 {activeCalories != null ? ( —— )} - + - + 步数 @@ -351,31 +403,33 @@ export default function ExploreScreen() { fillColor="#FFC365" showLabel={false} /> - + {/* 右列 */} - + + + - + + + - - + 睡眠 @@ -386,7 +440,7 @@ export default function ExploreScreen() { ) : ( —— )} - + @@ -713,11 +767,10 @@ const styles = StyleSheet.create({ masonryContainer: { marginBottom: 16, flexDirection: 'row', - justifyContent: 'space-between', + gap: 12, }, masonryColumn: { flex: 1, - marginHorizontal: 3, }, masonryCard: { width: '100%', @@ -727,26 +780,15 @@ const styles = StyleSheet.create({ shadowColor: '#000', shadowOffset: { width: 0, - height: 2, + height: 4, }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - marginBottom: 8, + shadowOpacity: 0.12, + shadowRadius: 12, + elevation: 6, }, - masonryCardNoBg: { - width: '100%', + bmiCardOverride: { + margin: -16, // 抵消 masonryCard 的 padding borderRadius: 16, - padding: 16, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - marginBottom: 8, }, compactStepsCard: { minHeight: 100, diff --git a/components/ActivityHeatMap.tsx b/components/ActivityHeatMap.tsx new file mode 100644 index 0000000..e66da9b --- /dev/null +++ b/components/ActivityHeatMap.tsx @@ -0,0 +1,329 @@ +import { Colors } from '@/constants/Colors'; +import { useAppSelector } from '@/hooks/redux'; +import { useColorScheme } from '@/hooks/useColorScheme'; +import dayjs from 'dayjs'; +import React, { useMemo } from 'react'; +import { Dimensions, StyleSheet, Text, View } from 'react-native'; + + +const ActivityHeatMap = () => { + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + const activityData = useAppSelector(stat => stat.user.activityHistory); + + // 获取屏幕宽度,计算适合的网格大小 + const screenWidth = Dimensions.get('window').width; + const containerPadding = 40; // 卡片的左右padding + const availableWidth = screenWidth - containerPadding; + + // 计算小方块的尺寸(显示最近26周,约6个月) + const weeksToShow = 26; + const gapBetweenWeeks = 4; // 周与周之间的间距 + const totalGaps = (weeksToShow - 1) * gapBetweenWeeks; + const cellWidth = (availableWidth - totalGaps) / weeksToShow; + const cellSize = Math.max(Math.floor(cellWidth), 8); // 确保最小尺寸 + + // 生成最近6个月的活动数据 + const generateActivityData = useMemo(() => { + const data: { date: string; level: number }[] = []; + + + const today = dayjs(); + const startDate = today.subtract(weeksToShow * 7, 'day'); + + // 创建服务端数据的日期映射,便于快速查找 + const activityMap = new Map(); + if (activityData && activityData.length > 0) { + activityData.forEach(item => { + activityMap.set(item.date, item.level); + }); + } + + for (let i = 0; i <= weeksToShow * 7; i++) { + const currentDate = startDate.add(i, 'day'); + const dateString = currentDate.format('YYYY-MM-DD'); + + // 如果服务端有对应日期的数据,使用服务端的 level;否则为 0 + const level = activityMap.get(dateString) || 0; + + data.push({ + date: dateString, + level, + }); + } + + return data; + }, [activityData, weeksToShow]); + + console.log('generateActivityData', generateActivityData); + + + // 根据活跃度计算颜色 + const getActivityColor = (level: number): string => { + // 由于useColorScheme总是返回'light',我们直接使用浅色主题的颜色 + switch (level) { + case 0: + return colors.separator; // 使用主题分隔线色 + case 1: + case 2: + return 'rgba(135,206,235,0.4)'; + case 3: + return 'rgba(135,206,235,0.65)'; + case 4: + default: + return colors.primary; + } + }; + + // 将数据按周组织 + const organizeDataByWeeks = useMemo(() => { + const weeks: { date: string; level: number }[][] = []; + + for (let week = 0; week < weeksToShow; week++) { + const weekData: { date: string; level: number }[] = []; + for (let day = 1; day <= 7; day++) { + const dataIndex = week * 7 + day; + if (dataIndex < generateActivityData.length) { + weekData.push(generateActivityData[dataIndex] as { date: string; level: number }); + } + } + weeks.push(weekData); + } + + return weeks; + }, [generateActivityData, weeksToShow]); + + console.log('organizeDataByWeeks', organizeDataByWeeks); + + + // 获取月份标签(简化的月份标签系统) + const getMonthLabels = useMemo(() => { + const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', + '7月', '8月', '9月', '10月', '11月', '12月']; + + // 简单策略:均匀分布4-5个月份标签 + const totalWeeks = weeksToShow; + const labelPositions: { label: string; position: number }[] = []; + + // 在25%、50%、75%位置放置标签 + const positions = [ + Math.floor(totalWeeks * 0.1), + Math.floor(totalWeeks * 0.35), + Math.floor(totalWeeks * 0.65), + Math.floor(totalWeeks * 0.9) + ]; + + positions.forEach((weekIndex) => { + if (weekIndex < organizeDataByWeeks.length && organizeDataByWeeks[weekIndex].length > 0) { + const firstDay = dayjs(organizeDataByWeeks[weekIndex][0].date); + const month = firstDay.month(); + labelPositions.push({ + label: monthNames[month], + position: weekIndex + }); + } + }); + + return labelPositions; + }, [organizeDataByWeeks, weeksToShow]); + + // 计算活动统计 + const activityStats = useMemo(() => { + const totalDays = generateActivityData.length; + const activeDays = generateActivityData.filter(d => d.level > 0).length; + const totalActivity = generateActivityData.reduce((sum, d) => sum + d.level, 0); + + return { + totalDays, + activeDays, + totalActivity, + activeRate: Math.round((activeDays / totalDays) * 100) + }; + }, [generateActivityData]); + + return ( + + {/* 标题和统计 */} + + + + 最近6个月活跃 {activityStats.activeDays} 天 + + + + {activityStats.activeRate}% + + + + + + + {/* 活动热力图 */} + + {/* 热力图网格 */} + + {organizeDataByWeeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => ( + + ))} + + ))} + + + {/* 月份标签 */} + + {getMonthLabels.map((monthData, index) => { + // 计算标签位置:基于周的位置,但要确保标签不重叠 + const basePosition = (monthData.position / (weeksToShow - 1)) * 100; + // 限制位置范围,避免标签超出边界 + const leftPercentage = Math.max(0, Math.min(90, basePosition)); + + return ( + + {monthData.label} + + ); + })} + + + + {/* 图例 */} + + + + {[0, 1, 2, 3, 4].map((level) => ( + + ))} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderRadius: 16, + padding: 20, + marginBottom: 20, + shadowColor: 'rgba(135,206,235,0.25)', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.12, + shadowRadius: 6, + elevation: 3, + borderWidth: 1, + borderColor: 'rgba(135,206,235,0.08)', + }, + header: { + marginBottom: 8, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + title: { + fontSize: 18, + fontWeight: 'bold', + }, + statsBadge: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + }, + statsText: { + fontSize: 13, + fontWeight: '600', + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + }, + heatMapContainer: { + marginBottom: 18, + }, + gridContainer: { + flexDirection: 'row', + marginBottom: 12, + paddingHorizontal: 2, + gap: 2, // 统一的周间距 + }, + week: { + flexDirection: 'column', + gap: 1.5, + flex: 1, + alignItems: 'center', + }, + cell: { + borderWidth: 0, + }, + monthLabelsContainer: { + position: 'relative', + height: 16, + marginTop: 4, + paddingHorizontal: 2, + }, + monthLabel: { + fontSize: 11, + fontWeight: '500', + opacity: 0.8, + transform: [{ translateX: -12 }], + minWidth: 24, + textAlign: 'center', + }, + legend: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 10, + }, + legendText: { + fontSize: 10, + opacity: 0.7, + }, + legendColors: { + flexDirection: 'row', + gap: 4, + }, + legendCell: { + width: 8, + height: 8, + borderRadius: 2.5, + borderWidth: 0.5, + }, +}); + +export default ActivityHeatMap; diff --git a/store/userSlice.ts b/store/userSlice.ts index 51e1fb5..0d21229 100644 --- a/store/userSlice.ts +++ b/store/userSlice.ts @@ -20,6 +20,17 @@ export type UserProfile = { maxUsageCount?: number; }; +export type WeightHistoryItem = { + weight: string; + source: string; + createdAt: string; +}; + +export type ActivityHistoryItem = { + date: string; + level: number; +}; + export type UserState = { token: string | null; profile: UserProfile; @@ -27,12 +38,7 @@ export type UserState = { error: string | null; privacyAgreed: boolean; weightHistory: WeightHistoryItem[]; -}; - -export type WeightHistoryItem = { - weight: string; - source: string; - createdAt: string; + activityHistory: ActivityHistoryItem[]; }; export const DEFAULT_MEMBER_NAME = '普拉提星球学员'; @@ -49,6 +55,7 @@ const initialState: UserState = { error: null, privacyAgreed: false, weightHistory: [], + activityHistory: [], }; export type LoginPayload = Record & { @@ -176,6 +183,16 @@ export const fetchWeightHistory = createAsyncThunk('user/fetchWeightHistory', as } }); +// 获取用户活动历史记录 +export const fetchActivityHistory = createAsyncThunk('user/fetchActivityHistory', async (_, { rejectWithValue }) => { + try { + const data: ActivityHistoryItem[] = await api.get('/api/users/activity-history'); + console.log('fetchActivityHistory', data); + return data; + } catch (err: any) { + return rejectWithValue(err?.message ?? '获取用户活动历史记录失败'); + } +}); const userSlice = createSlice({ name: 'user', @@ -240,11 +257,15 @@ const userSlice = createSlice({ }) .addCase(fetchWeightHistory.rejected, (state, action) => { state.error = (action.payload as string) ?? '获取用户体重历史记录失败'; + }) + .addCase(fetchActivityHistory.fulfilled, (state, action) => { + state.activityHistory = action.payload; + }) + .addCase(fetchActivityHistory.rejected, (state, action) => { + state.error = (action.payload as string) ?? '获取用户活动历史记录失败'; }); }, }); export const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions; -export default userSlice.reducer; - - +export default userSlice.reducer; \ No newline at end of file