import React, { useCallback, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../theme'; import { useT } from '../../i18n'; import { getMonthGrid, type DayCell } from '../../domain/stats/calendarGrid'; import { dailyExpenseMap } from '../../domain/stats/chartStats'; import type { Transaction } from '../../domain/core/types'; interface CalendarViewProps { transactions: Transaction[]; /** 当前展示的年份。 */ year: number; /** 当前展示的月份(1-12)。 */ month: number; /** 选中日期变化回调(YYYY-MM-DD)。 */ onDayPress?: (date: string) => void; /** 显示模式:interactive(默认,可交互日历)| heatmap(只读热力图) */ variant?: 'interactive' | 'heatmap'; /** heatmap 模式下的标题文本。 */ title?: string; } /** * 统一月历组件(合并原 CalendarView + CalendarHeatmap)。 * - variant="interactive":可交互日历,支持选中日期、收支圆点、热力背景。 * - variant="heatmap":只读热力图,按日期展示消费强度。 */ export function CalendarView({ transactions, year, month, onDayPress, variant = 'interactive', title }: CalendarViewProps) { const { theme } = useTheme(); const t = useT(); const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]); const [selectedDate, setSelectedDate] = useState(null); const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]); const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]); const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]); const handlePress = useCallback((cell: DayCell) => { if (!cell.isCurrentMonth) return; setSelectedDate(cell.date); onDayPress?.(cell.date); }, [onDayPress]); const isHeatmap = variant === 'heatmap'; return ( {isHeatmap && title ? ( {title} ) : null} {/* 星期表头 */} {weekdays.map(d => ( {d} ))} {/* 日期网格 */} {weeks.map((week, wi) => ( {week.map(cell => { const expense = dailyMap.get(cell.date) ?? 0; const intensity = expense / maxDaily; // ---- heatmap 只读模式 ---- if (isHeatmap) { return ( 0 ? theme.colors.financial.expense : 'transparent', borderColor: theme.colors.border, opacity: cell.isCurrentMonth ? (expense > 0 ? Math.min(0.2 + intensity * 0.8, 1) : 1) : 0.3, borderRadius: theme.radii.sm / 2, }, ]}> {cell.day} ); } // ---- interactive 交互模式 ---- const isSelected = cell.date === selectedDate; return ( handlePress(cell)} disabled={!cell.isCurrentMonth} hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }} accessibilityRole="button" accessibilityLabel={cell.isCurrentMonth ? year + '-' + String(month).padStart(2, '0') + '-' + String(cell.day).padStart(2, '0') : undefined} style={[ styles.dayCell, { backgroundColor: isSelected ? theme.colors.accent : 'transparent', borderRadius: theme.radii.sm, }, ]} > {/* 消费热力图背景叠加层 */} {cell.isCurrentMonth && expense > 0 && !isSelected && ( )} {cell.day} {/* 交易标记圆点 */} {cell.count > 0 && cell.isCurrentMonth && ( {cell.hasIncome && ( )} {cell.hasExpense && (expense === 0 || isSelected) && ( )} )} ); })} ))} ); } const styles = StyleSheet.create({ container: { gap: 4 }, weekdayRow: { flexDirection: 'row', marginBottom: 4 }, weekRow: { flexDirection: 'row' }, dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 4, position: 'relative' }, heatCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderWidth: 0.5 }, dots: { flexDirection: 'row', gap: 3, marginTop: 2 }, dot: { width: 5, height: 5, borderRadius: 2.5 }, });