/// GitHub-style contribution heatmap for one calendar month of transactions. library; import 'package:flutter/material.dart'; import '../utils/helpers.dart'; import '../utils/tx_calendar.dart'; class TxHeatmap extends StatelessWidget { const TxHeatmap({ super.key, required this.month, required this.counts, required this.canGoForward, this.selectedDay, required this.onPreviousMonth, required this.onNextMonth, required this.onSelectDay, }); final DateTime month; final Map counts; final bool canGoForward; final DateTime? selectedDay; final VoidCallback onPreviousMonth; final VoidCallback onNextMonth; final ValueChanged onSelectDay; static const _weekdays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final cells = monthGrid(month); final today = dateOnly(DateTime.now()); final weeks = cells.length ~/ 7; return Card( margin: const EdgeInsets.fromLTRB(0, 8, 0, 8), child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ IconButton( tooltip: 'Previous month', onPressed: onPreviousMonth, icon: const Icon(Icons.chevron_left), ), Expanded( child: Text( formatMonthYear(month), textAlign: TextAlign.center, style: theme.textTheme.titleMedium, ), ), IconButton( tooltip: 'Next month', onPressed: canGoForward ? onNextMonth : null, icon: const Icon(Icons.chevron_right), ), ], ), const SizedBox(height: 4), Row( children: [ for (final label in _weekdays) Expanded( child: Text( label, textAlign: TextAlign.center, style: theme.textTheme.labelMedium, ), ), ], ), const SizedBox(height: 4), for (var week = 0; week < weeks; week++) Padding( padding: const EdgeInsets.only(top: 4), child: Row( children: [ for (var weekday = 0; weekday < 7; weekday++) Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: _HeatCell( date: cells[week * 7 + weekday], count: cells[week * 7 + weekday] == null ? 0 : counts[cells[week * 7 + weekday]] ?? 0, today: today, selectedDay: selectedDay, onSelect: onSelectDay, ), ), ), ], ), ), const SizedBox(height: 12), Row( children: [ Text('Less', style: theme.textTheme.labelMedium), const SizedBox(width: 6), for (var level = 0; level <= 4; level++) Padding( padding: const EdgeInsets.only(right: 3), child: Container( width: 12, height: 12, decoration: BoxDecoration( color: _fillForLevel(scheme, level), borderRadius: BorderRadius.circular(3), ), ), ), Text('More', style: theme.textTheme.labelMedium), ], ), ], ), ), ); } } class _HeatCell extends StatelessWidget { const _HeatCell({ required this.date, required this.count, required this.today, required this.selectedDay, required this.onSelect, }); final DateTime? date; final int count; final DateTime today; final DateTime? selectedDay; final ValueChanged onSelect; @override Widget build(BuildContext context) { if (date == null) { return const SizedBox(height: 36); } final scheme = Theme.of(context).colorScheme; final level = heatmapLevel(count); final selected = selectedDay != null && isSameDay(date!, selectedDay!); final isToday = isSameDay(date!, today); final fill = _fillForLevel(scheme, level); final textColor = level >= 3 ? scheme.onPrimary : scheme.onSurface.withValues(alpha: count == 0 ? 0.45 : 1); return Tooltip( message: count == 0 ? formatDate(date!) : '${formatDate(date!)} ยท ${count == 1 ? '1 tx' : '$count txs'}', child: InkWell( onTap: () => onSelect(selected ? null : date), borderRadius: BorderRadius.circular(6), child: AnimatedContainer( duration: const Duration(milliseconds: 150), height: 36, alignment: Alignment.center, decoration: BoxDecoration( color: fill, borderRadius: BorderRadius.circular(6), border: Border.all( color: selected ? scheme.primary : isToday ? scheme.outline : Colors.transparent, width: selected ? 2 : 1, ), ), child: Text( '${date!.day}', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: textColor, fontWeight: isToday || selected ? FontWeight.w700 : FontWeight.w500, fontSize: 11, ), ), ), ), ); } } Color _fillForLevel(ColorScheme scheme, int level) { if (level <= 0) return scheme.surfaceContainerHighest; final t = (level / 4).clamp(0.28, 1.0); return Color.lerp(scheme.primary.withValues(alpha: 0.22), scheme.primary, t)!; }