Files
digital-pilates/components/ActivityHeatMap.tsx
richarjiang 63ed820e93 feat(ui): 统一健康卡片标题图标并优化语音录音稳定性
- 为所有健康数据卡片添加对应功能图标,提升视觉一致性
- 将“小鱼干”文案统一为“能量值”,并更新获取说明
- 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑
- 个人页支持毛玻璃按钮样式,默认用户名置空
- 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源
- 升级 react-native-purchases 至 9.4.3
- 移除 useAuthGuard 调试日志
2025-09-16 09:35:50 +08:00

415 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import dayjs from 'dayjs';
import React, { useMemo, useState } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Popover from 'react-native-popover-view';
const ActivityHeatMap = () => {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const [showPopover, setShowPopover] = useState(false);
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]);
// 根据活跃度计算颜色 - 优化配色方案
const getActivityColor = (level: number): string => {
switch (level) {
case 0:
// 无活动:使用淡蓝色,更有活力
return 'rgba(173, 216, 230, 0.3)'; // 淡蓝色,有活力但不突兀
case 1:
// 低活动:使用主题主色的浅色版本
return 'rgba(122, 90, 248, 0.15)'; // 浅色模式下的浅紫色
case 2:
// 中等活动:使用主题主色的中等透明度
return 'rgba(122, 90, 248, 0.35)'; // 浅色模式下的中等紫色
case 3:
// 高活动:使用主题主色的较高透明度
return 'rgba(122, 90, 248, 0.55)'; // 浅色模式下的较深紫色
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]);
// 获取月份标签(简化的月份标签系统)
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,
borderColor: colors.border,
shadowColor: 'rgba(122, 90, 248, 0.08)',
}]}>
{/* 标题和统计 */}
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
6 {activityStats.activeDays}
</Text>
<View style={styles.rightSection}>
<View style={[styles.statsBadge, {
backgroundColor: 'rgba(122, 90, 248, 0.1)'
}]}>
<Text style={[styles.statsText, { color: colors.primary }]}>
{activityStats.activeRate}%
</Text>
</View>
<Popover
isVisible={showPopover}
onRequestClose={() => setShowPopover(false)}
from={(
<TouchableOpacity
style={styles.infoButton}
onPress={() => setShowPopover(true)}
>
<IconSymbol
name="info.circle"
size={16}
color={colors.textMuted}
/>
</TouchableOpacity>
)}
>
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
<Text style={[styles.popoverTitle, { color: colors.text }]}>
AI
</Text>
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
</Text>
<View style={styles.popoverList}>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
1. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
2. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
3. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
4. +1
</Text>
</View>
</View>
</Popover>
</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,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 3,
},
header: {
marginBottom: 8,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
infoButton: {
padding: 4,
borderRadius: 8,
},
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,
},
popoverContent: {
padding: 16,
borderRadius: 12,
maxWidth: 280,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5,
},
popoverTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
popoverSubtitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
popoverList: {
gap: 6,
},
popoverItem: {
fontSize: 14,
lineHeight: 20,
},
});
export default ActivityHeatMap;