import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/heatmap.dart'; import 'package:island/services/responsive.dart'; /// Custom data class for selected heatmap item class SelectedHeatmapItem { final double value; final String unit; final String dateString; final String dayLabel; SelectedHeatmapItem({ required this.value, required this.unit, required this.dateString, required this.dayLabel, }); } /// A reusable heatmap widget for displaying activity data in GitHub-style layout. /// Shows exactly 365 days (wide screen) or 90 days (non-wide screen) of data ending at the current date. class ActivityHeatmapWidget extends HookConsumerWidget { final SnHeatmap heatmap; final bool forceDense; const ActivityHeatmapWidget({ super.key, required this.heatmap, this.forceDense = false, }); @override Widget build(BuildContext context, WidgetRef ref) { final selectedItem = useState(null); final now = DateTime.now(); final isWide = isWideScreen(context); final days = (isWide && !forceDense) ? 365 : 90; // Start from exactly the selected days ago final startDate = now.subtract(Duration(days: days)); // End at current date final endDate = now; // Find monday of the week containing start date final startMonday = startDate.subtract( Duration(days: startDate.weekday - 1), ); // Find sunday of the week containing end date final endSunday = endDate.add(Duration(days: 7 - endDate.weekday)); // Generate weeks to cover the selected date range final weeks = []; var current = startMonday; while (current.isBefore(endSunday) || current.isAtSameMomentAs(endSunday)) { weeks.add(current); current = current.add(const Duration(days: 7)); } // Create data map for all dates in the range final dataMap = {}; for (final week in weeks) { for (var i = 0; i < 7; i++) { final date = week.add(Duration(days: i)); // Only include dates within our selected range if (date.isAfter(startDate.subtract(const Duration(days: 1))) && date.isBefore(endDate.add(const Duration(days: 1)))) { final item = heatmap.items.firstWhere( (e) => e.date.year == date.year && e.date.month == date.month && e.date.day == date.day, orElse: () => SnHeatmapItem(date: date, count: 0), ); dataMap[date] = item.count.toDouble(); } } } // Generate month labels for the top final monthLabels = []; final monthPositions = []; final processedMonths = {}; // Track processed months to avoid duplicates for (final week in weeks) { final monthKey = '${week.year}-${week.month.toString().padLeft(2, '0')}'; // Only process each month once if (!processedMonths.contains(monthKey)) { processedMonths.add(monthKey); // Find which week this month starts in final firstDayOfMonth = DateTime(week.year, week.month, 1); final monthStartMonday = firstDayOfMonth.subtract( Duration(days: firstDayOfMonth.weekday - 1), ); final monthStartWeekIndex = weeks.indexWhere( (w) => w.year == monthStartMonday.year && w.month == monthStartMonday.month && w.day == monthStartMonday.day, ); if (monthStartWeekIndex != -1) { monthLabels.add(_getMonthAbbreviation(week.month)); monthPositions.add(monthStartWeekIndex); } } } // Find maximum value for color scaling final maxValue = dataMap.values.isNotEmpty ? dataMap.values.reduce((a, b) => a > b ? a : b) : 1.0; // Helper function to get color based on activity level Color getActivityColor(double value) { if (value == 0) return Colors.grey.withOpacity(0.1); final intensity = value / maxValue; return Colors.green.withOpacity(0.2 + (intensity * 0.8)); } return Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Month labels row - aligned with month start positions Row( children: [ const SizedBox(width: 30), // Space for day labels ...List.generate(weeks.length, (weekIndex) { // Check if this week is the start of a month final monthIndex = monthPositions.indexOf(weekIndex); final monthText = monthIndex != -1 ? monthLabels[monthIndex] : null; return monthText != null ? Expanded( child: Text( monthText, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ) : SizedBox.shrink(); }), ], ), const Gap(4), // Custom heatmap grid Column( children: List.generate(7, (dayIndex) { final dayLabels = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', ]; final dayLabel = dayLabels[dayIndex]; return Row( children: [ // Day label SizedBox( width: 30, child: Text( dayLabel, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ), // Activity squares for each week - evenly distributed Expanded( child: Row( children: List.generate(weeks.length, (weekIndex) { final week = weeks[weekIndex]; final date = week.add(Duration(days: dayIndex)); final value = dataMap[date] ?? 0.0; final dateString = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return Expanded( child: GestureDetector( onTap: () { selectedItem.value = SelectedHeatmapItem( value: value, unit: heatmap.unit, dateString: dateString, dayLabel: dayLabel, ); }, child: Container( height: 12, margin: const EdgeInsets.all(1), decoration: BoxDecoration( color: getActivityColor(value), borderRadius: BorderRadius.circular(2), border: selectedItem.value != null && selectedItem.value!.dateString == dateString && selectedItem.value!.dayLabel == dayLabel ? Border.all( color: Colors.blue, width: 1, ) : null, ), ), ), ); }), ), ), ], ); }), ), const Gap(8), // Legend Row( children: [ if (selectedItem.value != null) RichText( text: TextSpan( children: [ TextSpan( text: selectedItem.value!.value.toInt().toString(), style: Theme.of(context).textTheme.bodySmall ?.copyWith(fontWeight: FontWeight.bold), ), TextSpan( text: ' activities on ', style: Theme.of(context).textTheme.bodySmall, ), TextSpan( text: _formatDate(selectedItem.value!.dateString), style: Theme.of(context).textTheme.bodySmall, ), ], ), ), const Spacer(), Text( 'Less', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const Gap(4), // Color indicators (light to dark green) ...[ Colors.green.withOpacity(0.2), Colors.green.withOpacity(0.4), Colors.green.withOpacity(0.6), Colors.green.withOpacity(0.8), Colors.green, ].map( (color) => Container( width: 8, height: 8, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(2), ), ), ), const Gap(4), Text( 'More', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ], ), ), ); } String _getMonthAbbreviation(int month) { const monthNames = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; return monthNames[month - 1]; } String _formatDate(String dateString) { try { final date = DateTime.parse(dateString); final monthAbbrev = _getMonthAbbreviation(date.month); return '$monthAbbrev ${date.day}, ${date.year}'; } catch (e) { return dateString; // Fallback to original string if parsing fails } } }