Files
App/lib/widgets/activity_heatmap.dart
2025-11-22 18:36:10 +08:00

328 lines
11 KiB
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 '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<SelectedHeatmapItem?>(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);
}
}
}
// 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
}
}
}