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(); 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 ( {/* 标题和统计 */} 最近6个月活跃 {activityStats.activeDays} 天 {activityStats.activeRate}% setShowPopover(false)} from={( setShowPopover(true)} > )} > 能量值的积攒后续可以用来兑换 AI 相关权益 获取说明 1. 每日登录获得能量值+1 2. 每日记录心情获得能量值+1 3. 记饮食获得能量值+1 4. 完成一次目标获得能量值+1 {/* 活动热力图 */} {/* 热力图网格 */} {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, 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;