2026-06-06 11:39:25 +02:00
|
|
|
/// Monthly calendar showing which days had workouts.
|
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
2026-06-28 08:11:43 +02:00
|
|
|
/// Monthly calendar widget that highlights days with completed workouts.
|
2026-06-06 11:39:25 +02:00
|
|
|
class WorkoutCalendar extends StatelessWidget {
|
2026-06-28 08:11:43 +02:00
|
|
|
/// Creates a [WorkoutCalendar].
|
2026-06-06 11:39:25 +02:00
|
|
|
const WorkoutCalendar({
|
|
|
|
|
required this.workoutDates,
|
|
|
|
|
required this.month,
|
|
|
|
|
required this.onPrevMonth,
|
|
|
|
|
required this.onNextMonth,
|
2026-06-28 08:11:43 +02:00
|
|
|
super.key,
|
2026-06-06 11:39:25 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-28 08:11:43 +02:00
|
|
|
/// Set of YYYY-MM-DD date strings that had at least one workout.
|
2026-06-06 11:39:25 +02:00
|
|
|
final Set<String> workoutDates;
|
|
|
|
|
|
|
|
|
|
/// Only the year and month of this DateTime are used.
|
|
|
|
|
final DateTime month;
|
2026-06-28 08:11:43 +02:00
|
|
|
|
|
|
|
|
/// Called when the user taps the previous-month chevron.
|
2026-06-06 11:39:25 +02:00
|
|
|
final VoidCallback onPrevMonth;
|
2026-06-28 08:11:43 +02:00
|
|
|
|
|
|
|
|
/// Called when the user taps the next-month chevron.
|
2026-06-06 11:39:25 +02:00
|
|
|
final VoidCallback onNextMonth;
|
|
|
|
|
|
|
|
|
|
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
|
|
|
|
|
|
|
|
static const _monthNames = [
|
2026-06-28 08:11:43 +02:00
|
|
|
'January',
|
|
|
|
|
'February',
|
|
|
|
|
'March',
|
|
|
|
|
'April',
|
|
|
|
|
'May',
|
|
|
|
|
'June',
|
|
|
|
|
'July',
|
|
|
|
|
'August',
|
|
|
|
|
'September',
|
|
|
|
|
'October',
|
|
|
|
|
'November',
|
|
|
|
|
'December',
|
2026-06-06 11:39:25 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
String _dateKey(int year, int m, int day) =>
|
|
|
|
|
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final year = month.year;
|
|
|
|
|
final m = month.month;
|
|
|
|
|
final daysInMonth = DateTime(year, m + 1, 0).day;
|
|
|
|
|
// weekday: 1=Mon..7=Sun → offset 0..6
|
2026-06-28 08:11:43 +02:00
|
|
|
final firstWeekday = DateTime(year, m).weekday - 1;
|
2026-06-06 11:39:25 +02:00
|
|
|
final totalCells = firstWeekday + daysInMonth;
|
|
|
|
|
final rows = (totalCells / 7).ceil();
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.grey.shade800,
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
// Month navigation header
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.chevron_left, color: Colors.white70),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
onPressed: onPrevMonth,
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
'${_monthNames[m - 1]} $year',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.chevron_right, color: Colors.white70),
|
|
|
|
|
visualDensity: VisualDensity.compact,
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
onPressed: onNextMonth,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
// Day-of-week headers
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
children: _weekHeaders
|
|
|
|
|
.map(
|
|
|
|
|
(h) => SizedBox(
|
|
|
|
|
width: 30,
|
|
|
|
|
child: Text(
|
|
|
|
|
h,
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white38,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.toList(),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
// Day grid
|
|
|
|
|
...List.generate(rows, (row) {
|
|
|
|
|
return Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
children: List.generate(7, (col) {
|
|
|
|
|
final cell = row * 7 + col;
|
|
|
|
|
final day = cell - firstWeekday + 1;
|
|
|
|
|
if (day < 1 || day > daysInMonth) {
|
|
|
|
|
return const SizedBox(width: 30, height: 30);
|
|
|
|
|
}
|
|
|
|
|
final key = _dateKey(year, m, day);
|
|
|
|
|
final worked = workoutDates.contains(key);
|
|
|
|
|
return Container(
|
|
|
|
|
width: 30,
|
|
|
|
|
height: 30,
|
|
|
|
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
|
|
|
|
decoration: worked
|
|
|
|
|
? BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
color: Colors.green.shade700,
|
|
|
|
|
)
|
|
|
|
|
: null,
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Text(
|
|
|
|
|
'$day',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
color: worked ? Colors.white : Colors.white38,
|
|
|
|
|
fontSize: 12,
|
2026-06-28 08:11:43 +02:00
|
|
|
fontWeight: worked ? FontWeight.bold : FontWeight.normal,
|
2026-06-06 11:39:25 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|