283 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			283 lines
		
	
	
		
			9.0 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:fl_heatmap/fl_heatmap.dart';
 | 
						|
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 '../services/responsive.dart';
 | 
						|
 | 
						|
/// 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<HeatmapItem?>(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 = <DateTime>[];
 | 
						|
    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 = <DateTime, double>{};
 | 
						|
    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 = <String>[];
 | 
						|
    final monthPositions = <int>[];
 | 
						|
    final processedMonths =
 | 
						|
        <String>{}; // 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);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    final heatmapData = HeatmapData(
 | 
						|
      rows: [
 | 
						|
        'Mon',
 | 
						|
        'Tue',
 | 
						|
        'Wed',
 | 
						|
        'Thu',
 | 
						|
        'Fri',
 | 
						|
        'Sat',
 | 
						|
        'Sun',
 | 
						|
      ], // Days of week vertically
 | 
						|
      columns:
 | 
						|
          weeks
 | 
						|
              .map(
 | 
						|
                (w) =>
 | 
						|
                    '${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}',
 | 
						|
              )
 | 
						|
              .toList(), // Weeks horizontally
 | 
						|
      items: [
 | 
						|
        for (int day = 0; day < 7; day++) // For each day of week (Mon-Sun)
 | 
						|
          for (final week in weeks) // For each week
 | 
						|
            HeatmapItem(
 | 
						|
              value: dataMap[week.add(Duration(days: day))] ?? 0.0,
 | 
						|
              unit: heatmap.unit,
 | 
						|
              xAxisLabel:
 | 
						|
                  '${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}',
 | 
						|
              yAxisLabel:
 | 
						|
                  day == 0
 | 
						|
                      ? 'Mon'
 | 
						|
                      : day == 1
 | 
						|
                      ? 'Tue'
 | 
						|
                      : day == 2
 | 
						|
                      ? 'Wed'
 | 
						|
                      : day == 3
 | 
						|
                      ? 'Thu'
 | 
						|
                      : day == 4
 | 
						|
                      ? 'Fri'
 | 
						|
                      : day == 5
 | 
						|
                      ? 'Sat'
 | 
						|
                      : 'Sun',
 | 
						|
            ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
 | 
						|
    return Card(
 | 
						|
      margin: EdgeInsets.zero,
 | 
						|
      child: Padding(
 | 
						|
        padding: const EdgeInsets.all(16),
 | 
						|
        child: Column(
 | 
						|
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
          children: [
 | 
						|
            Text(
 | 
						|
              'activityHeatmap',
 | 
						|
              style: Theme.of(context).textTheme.titleMedium,
 | 
						|
            ).tr(),
 | 
						|
            const Gap(8),
 | 
						|
            // Month labels row
 | 
						|
            Row(
 | 
						|
              children: [
 | 
						|
                const SizedBox(width: 30), // Space for day labels
 | 
						|
                ...monthLabels.asMap().entries.map((entry) {
 | 
						|
                  final month = entry.value;
 | 
						|
 | 
						|
                  return Expanded(
 | 
						|
                    child: Container(
 | 
						|
                      alignment: Alignment.center,
 | 
						|
                      child: Text(
 | 
						|
                        month,
 | 
						|
                        style: Theme.of(context).textTheme.bodySmall,
 | 
						|
                        textAlign: TextAlign.center,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  );
 | 
						|
                }),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
            const Gap(4),
 | 
						|
            Heatmap(
 | 
						|
              heatmapData: heatmapData,
 | 
						|
              rowsVisible: 7,
 | 
						|
              showXAxisLabels: false,
 | 
						|
              onItemSelectedListener: (item) {
 | 
						|
                selectedItem.value = item;
 | 
						|
              },
 | 
						|
            ),
 | 
						|
            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!.xAxisLabel ?? '',
 | 
						|
                          ),
 | 
						|
                          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
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 |