feat: 添加用户活动热力图组件
在个人页面新增活动热力图展示组件,并实现浮动动画效果优化统计卡片交互体验。
This commit is contained in:
@@ -1,17 +1,17 @@
|
|||||||
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useMemo, useState } from 'react';
|
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';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/avatarGirl01.jpeg';
|
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(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
dispatch(fetchMyProfile());
|
dispatch(fetchMyProfile());
|
||||||
|
dispatch(fetchActivityHistory())
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,36 +69,6 @@ export default function PersonalScreen() {
|
|||||||
// 颜色令牌
|
// 颜色令牌
|
||||||
const colorTokens = colors;
|
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 = () => (
|
const UserInfoSection = () => (
|
||||||
<View style={[styles.userInfoCard, { backgroundColor: colorTokens.card }]}>
|
<View style={[styles.userInfoCard, { backgroundColor: colorTokens.card }]}>
|
||||||
<View style={styles.userInfoContainer}>
|
<View style={styles.userInfoContainer}>
|
||||||
@@ -304,6 +275,7 @@ export default function PersonalScreen() {
|
|||||||
>
|
>
|
||||||
<UserInfoSection />
|
<UserInfoSection />
|
||||||
<StatsSection />
|
<StatsSection />
|
||||||
|
<ActivityHeatMap />
|
||||||
{menuSections.map((section, index) => (
|
{menuSections.map((section, index) => (
|
||||||
<MenuSection key={index} title={section.title} items={section.items} />
|
<MenuSection key={index} title={section.title} items={section.items} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import dayjs from 'dayjs';
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Animated,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -28,6 +29,56 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
transform: [{ translateY }],
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
@@ -309,14 +360,15 @@ export default function ExploreScreen() {
|
|||||||
<View style={styles.masonryContainer}>
|
<View style={styles.masonryContainer}>
|
||||||
{/* 左列 */}
|
{/* 左列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
<StressMeter
|
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||||
value={hrvValue}
|
<StressMeter
|
||||||
updateTime={hrvUpdateTime}
|
value={hrvValue}
|
||||||
style={styles.masonryCard}
|
updateTime={hrvUpdateTime}
|
||||||
hrvValue={hrvValue}
|
hrvValue={hrvValue}
|
||||||
/>
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
<View style={[styles.masonryCard, styles.caloriesCard]}>
|
<FloatingCard style={[styles.masonryCard, styles.caloriesCard]} delay={500}>
|
||||||
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
||||||
{activeCalories != null ? (
|
{activeCalories != null ? (
|
||||||
<AnimatedNumber
|
<AnimatedNumber
|
||||||
@@ -328,9 +380,9 @@ export default function ExploreScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<Text style={styles.caloriesValue}>——</Text>
|
<Text style={styles.caloriesValue}>——</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</FloatingCard>
|
||||||
|
|
||||||
<View style={[styles.masonryCard, styles.stepsCard]}>
|
<FloatingCard style={[styles.masonryCard, styles.stepsCard]} delay={1000}>
|
||||||
<View style={styles.cardHeaderRow}>
|
<View style={styles.cardHeaderRow}>
|
||||||
<Text style={styles.cardTitle}>步数</Text>
|
<Text style={styles.cardTitle}>步数</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -351,31 +403,33 @@ export default function ExploreScreen() {
|
|||||||
fillColor="#FFC365"
|
fillColor="#FFC365"
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 右列 */}
|
{/* 右列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
<BMICard
|
<FloatingCard style={styles.masonryCard} delay={250}>
|
||||||
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
|
<BMICard
|
||||||
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
|
||||||
style={styles.masonryCardNoBg}
|
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
||||||
// compact={true}
|
style={styles.bmiCardOverride}
|
||||||
/>
|
// compact={true}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
<FitnessRingsCard
|
<FloatingCard style={styles.masonryCard} delay={750}>
|
||||||
activeCalories={fitnessRingsData.activeCalories}
|
<FitnessRingsCard
|
||||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
activeCalories={fitnessRingsData.activeCalories}
|
||||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||||
standHours={fitnessRingsData.standHours}
|
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
standHours={fitnessRingsData.standHours}
|
||||||
resetToken={animToken}
|
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||||
style={styles.masonryCard}
|
resetToken={animToken}
|
||||||
/>
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
<FloatingCard style={[styles.masonryCard, styles.sleepCard]} delay={1250}>
|
||||||
<View style={[styles.masonryCard, styles.sleepCard]}>
|
|
||||||
<View style={styles.cardHeaderRow}>
|
<View style={styles.cardHeaderRow}>
|
||||||
<Text style={styles.cardTitle}>睡眠</Text>
|
<Text style={styles.cardTitle}>睡眠</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -386,7 +440,7 @@ export default function ExploreScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<Text style={styles.sleepValue}>——</Text>
|
<Text style={styles.sleepValue}>——</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -713,11 +767,10 @@ const styles = StyleSheet.create({
|
|||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
gap: 12,
|
||||||
},
|
},
|
||||||
masonryColumn: {
|
masonryColumn: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginHorizontal: 3,
|
|
||||||
},
|
},
|
||||||
masonryCard: {
|
masonryCard: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -727,26 +780,15 @@ const styles = StyleSheet.create({
|
|||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 2,
|
height: 4,
|
||||||
},
|
},
|
||||||
shadowOpacity: 0.08,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 8,
|
shadowRadius: 12,
|
||||||
elevation: 3,
|
elevation: 6,
|
||||||
marginBottom: 8,
|
|
||||||
},
|
},
|
||||||
masonryCardNoBg: {
|
bmiCardOverride: {
|
||||||
width: '100%',
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 16,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 3,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
},
|
||||||
compactStepsCard: {
|
compactStepsCard: {
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
|
|||||||
329
components/ActivityHeatMap.tsx
Normal file
329
components/ActivityHeatMap.tsx
Normal file
@@ -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<string, number>();
|
||||||
|
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 (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||||
|
{/* 标题和统计 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||||
|
最近6个月活跃 {activityStats.activeDays} 天
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.statsBadge, { backgroundColor: colors.ornamentPrimary }]}>
|
||||||
|
<Text style={[styles.statsText, { color: colors.primary }]}>
|
||||||
|
{activityStats.activeRate}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 活动热力图 */}
|
||||||
|
<View style={styles.heatMapContainer}>
|
||||||
|
{/* 热力图网格 */}
|
||||||
|
<View style={styles.gridContainer}>
|
||||||
|
{organizeDataByWeeks.map((week, weekIndex) => (
|
||||||
|
<View key={weekIndex} style={[styles.week, { width: cellSize }]}>
|
||||||
|
{week.map((day, dayIndex) => (
|
||||||
|
<View
|
||||||
|
key={day.date}
|
||||||
|
style={[
|
||||||
|
styles.cell,
|
||||||
|
{
|
||||||
|
backgroundColor: getActivityColor(day.level),
|
||||||
|
width: cellSize,
|
||||||
|
height: cellSize,
|
||||||
|
borderRadius: Math.max(cellSize * 0.15, 1.5),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 月份标签 */}
|
||||||
|
<View style={styles.monthLabelsContainer}>
|
||||||
|
{getMonthLabels.map((monthData, index) => {
|
||||||
|
// 计算标签位置:基于周的位置,但要确保标签不重叠
|
||||||
|
const basePosition = (monthData.position / (weeksToShow - 1)) * 100;
|
||||||
|
// 限制位置范围,避免标签超出边界
|
||||||
|
const leftPercentage = Math.max(0, Math.min(90, basePosition));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${monthData.label}-${index}`}
|
||||||
|
style={[
|
||||||
|
styles.monthLabel,
|
||||||
|
{
|
||||||
|
color: colors.textMuted,
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${leftPercentage}%`
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{monthData.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<Text style={[styles.legendText, { color: colors.textMuted }]}>少</Text>
|
||||||
|
<View style={styles.legendColors}>
|
||||||
|
{[0, 1, 2, 3, 4].map((level) => (
|
||||||
|
<View
|
||||||
|
key={level}
|
||||||
|
style={[
|
||||||
|
styles.legendCell,
|
||||||
|
{
|
||||||
|
backgroundColor: getActivityColor(level),
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.legendText, { color: colors.textMuted }]}>多</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -20,6 +20,17 @@ export type UserProfile = {
|
|||||||
maxUsageCount?: number;
|
maxUsageCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WeightHistoryItem = {
|
||||||
|
weight: string;
|
||||||
|
source: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivityHistoryItem = {
|
||||||
|
date: string;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserState = {
|
export type UserState = {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -27,12 +38,7 @@ export type UserState = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
privacyAgreed: boolean;
|
privacyAgreed: boolean;
|
||||||
weightHistory: WeightHistoryItem[];
|
weightHistory: WeightHistoryItem[];
|
||||||
};
|
activityHistory: ActivityHistoryItem[];
|
||||||
|
|
||||||
export type WeightHistoryItem = {
|
|
||||||
weight: string;
|
|
||||||
source: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
|
export const DEFAULT_MEMBER_NAME = '普拉提星球学员';
|
||||||
@@ -49,6 +55,7 @@ const initialState: UserState = {
|
|||||||
error: null,
|
error: null,
|
||||||
privacyAgreed: false,
|
privacyAgreed: false,
|
||||||
weightHistory: [],
|
weightHistory: [],
|
||||||
|
activityHistory: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginPayload = Record<string, any> & {
|
export type LoginPayload = Record<string, any> & {
|
||||||
@@ -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({
|
const userSlice = createSlice({
|
||||||
name: 'user',
|
name: 'user',
|
||||||
@@ -240,11 +257,15 @@ const userSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchWeightHistory.rejected, (state, action) => {
|
.addCase(fetchWeightHistory.rejected, (state, action) => {
|
||||||
state.error = (action.payload as string) ?? '获取用户体重历史记录失败';
|
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 const { updateProfile, setDailyStepsGoal, setDailyCaloriesGoal, setPilatesPurposes } = userSlice.actions;
|
||||||
export default userSlice.reducer;
|
export default userSlice.reducer;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user