From 10c15e5c90a927a9a561e1ba28d5e94671faa8c5 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Sun, 26 Apr 2026 14:25:20 +1000 Subject: [PATCH] feat: implement history screen with calendar heatmap and day detail Adds a month grid heatmap (colour-coded by daily mood average), prev/next month navigation, and a scrollable entry list below for the selected day showing mood, time, note, and metric chips (sleep, energy, positivity, self-worth). --- lib/features/calendar/calendar_screen.dart | 144 +++++++++++++++++- .../calendar/widgets/calendar_heatmap.dart | 142 +++++++++++++++++ .../calendar/widgets/day_entry_card.dart | 137 +++++++++++++++++ 3 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 lib/features/calendar/widgets/calendar_heatmap.dart create mode 100644 lib/features/calendar/widgets/day_entry_card.dart diff --git a/lib/features/calendar/calendar_screen.dart b/lib/features/calendar/calendar_screen.dart index e1b5500..f7d4a2b 100644 --- a/lib/features/calendar/calendar_screen.dart +++ b/lib/features/calendar/calendar_screen.dart @@ -1,12 +1,150 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/mood_provider.dart'; +import 'widgets/calendar_heatmap.dart'; +import 'widgets/day_entry_card.dart'; -class CalendarScreen extends StatelessWidget { +const _months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +class CalendarScreen extends ConsumerStatefulWidget { const CalendarScreen({super.key}); + @override + ConsumerState createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends ConsumerState { + late DateTime _month; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _month = DateTime(now.year, now.month); + } + + void _prevMonth() => setState(() => _month = DateTime(_month.year, _month.month - 1)); + + void _nextMonth() { + final now = DateTime.now(); + final next = DateTime(_month.year, _month.month + 1); + if (!next.isAfter(DateTime(now.year, now.month))) { + setState(() => _month = next); + } + } + + bool get _canGoForward { + final now = DateTime.now(); + return _month.year < now.year || + (_month.year == now.year && _month.month < now.month); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text('History coming soon')), + final selectedDay = ref.watch(selectedDayProvider); + final entriesAsync = ref.watch(moodEntriesForDayProvider(selectedDay)); + + return Scaffold( + appBar: AppBar( + title: const Text('History'), + centerTitle: false, + ), + body: Column( + children: [ + _MonthNav( + label: '${_months[_month.month - 1]} ${_month.year}', + onPrev: _prevMonth, + onNext: _canGoForward ? _nextMonth : null, + ), + CalendarHeatmap( + month: _month, + selectedDay: selectedDay, + onDayTap: (day) { + ref.read(selectedDayProvider.notifier).select(day); + // Follow the selected day's month + final m = DateTime(day.year, day.month); + if (m != _month) setState(() => _month = m); + }, + ), + const Divider(height: 1), + Expanded( + child: entriesAsync.when( + data: (entries) => entries.isEmpty + ? _EmptyDay(day: selectedDay) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: entries.length, + itemBuilder: (context, i) => + DayEntryCard(entry: entries[i]), + ), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (_, __) => const SizedBox.shrink(), + ), + ), + ], + ), + ); + } +} + +class _MonthNav extends StatelessWidget { + const _MonthNav({ + required this.label, + required this.onPrev, + required this.onNext, + }); + + final String label; + final VoidCallback onPrev; + final VoidCallback? onNext; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + IconButton(icon: const Icon(Icons.chevron_left), onPressed: onPrev), + Expanded( + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: onNext, + ), + ], + ), + ); + } +} + +class _EmptyDay extends StatelessWidget { + const _EmptyDay({required this.day}); + + final DateTime day; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final label = day == today ? 'today' : '${day.day}/${day.month}/${day.year}'; + + return Center( + child: Text( + 'No entries for $label', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.outline), + ), ); } } diff --git a/lib/features/calendar/widgets/calendar_heatmap.dart b/lib/features/calendar/widgets/calendar_heatmap.dart new file mode 100644 index 0000000..4d28cfc --- /dev/null +++ b/lib/features/calendar/widgets/calendar_heatmap.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/providers/mood_provider.dart'; + +const _moodColors = [ + Color(0xFFE57373), + Color(0xFFFFB74D), + Color(0xFFFFD54F), + Color(0xFF81C784), + Color(0xFF4DB6AC), +]; + +class CalendarHeatmap extends ConsumerWidget { + const CalendarHeatmap({ + super.key, + required this.month, + required this.selectedDay, + required this.onDayTap, + }); + + final DateTime month; + final DateTime selectedDay; + final ValueChanged onDayTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final start = DateTime(month.year, month.month, 1); + final end = DateTime(month.year, month.month + 1, 0, 23, 59, 59); + final averagesAsync = + ref.watch(dailyAveragesProvider((start: start, end: end))); + + return averagesAsync.when( + data: (averages) => _HeatmapGrid( + month: month, + averages: averages, + selectedDay: selectedDay, + onDayTap: onDayTap, + ), + loading: () => + const SizedBox(height: 280, child: Center(child: CircularProgressIndicator())), + error: (_, __) => const SizedBox(height: 280), + ); + } +} + +class _HeatmapGrid extends StatelessWidget { + const _HeatmapGrid({ + required this.month, + required this.averages, + required this.selectedDay, + required this.onDayTap, + }); + + final DateTime month; + final Map averages; + final DateTime selectedDay; + final ValueChanged onDayTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final leadingBlanks = DateTime(month.year, month.month, 1).weekday - 1; + final today = DateTime.now(); + final todayNorm = DateTime(today.year, today.month, today.day); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + children: [ + Row( + children: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] + .map((l) => Expanded( + child: Center( + child: Text( + l, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: cs.outline), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 6), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemCount: leadingBlanks + daysInMonth, + itemBuilder: (context, i) { + if (i < leadingBlanks) return const SizedBox.shrink(); + final day = i - leadingBlanks + 1; + final date = DateTime(month.year, month.month, day); + final avg = averages[date]; + final isSelected = date == selectedDay; + final isToday = date == todayNorm; + + Color? fill; + if (avg != null) { + final idx = (avg.clamp(1.0, 5.0) - 1).round().clamp(0, 4); + fill = _moodColors[idx].withValues(alpha: 0.85); + } + + return GestureDetector( + onTap: () => onDayTap(date), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: fill ?? + cs.surfaceContainerHighest.withValues(alpha: 0.5), + border: isSelected + ? Border.all(color: cs.primary, width: 2.5) + : isToday + ? Border.all(color: cs.outline, width: 1) + : null, + ), + child: Center( + child: Text( + '$day', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: fill != null ? Colors.white : cs.onSurfaceVariant, + fontWeight: isSelected || isToday + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/calendar/widgets/day_entry_card.dart b/lib/features/calendar/widgets/day_entry_card.dart new file mode 100644 index 0000000..c1ce901 --- /dev/null +++ b/lib/features/calendar/widgets/day_entry_card.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import '../../../core/database/app_database.dart'; + +const _moodEmojis = ['😞', '😔', '😐', '🙂', '😄']; +const _moodLabels = ['Awful', 'Bad', 'Okay', 'Good', 'Great']; + +class DayEntryCard extends StatelessWidget { + const DayEntryCard({super.key, required this.entry}); + + final MoodEntry entry; + + @override + Widget build(BuildContext context) { + final idx = (entry.moodLevel - 1).clamp(0, 4); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_moodEmojis[idx], style: const TextStyle(fontSize: 28)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(_moodLabels[idx], + style: Theme.of(context).textTheme.titleSmall), + const Spacer(), + Text( + _formatTime(entry.timestamp), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + if (entry.note != null && entry.note!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + entry.note!, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (_hasMetrics) ...[ + const SizedBox(height: 8), + _MetricsRow(entry: entry), + ], + ], + ), + ), + ], + ), + ), + ); + } + + bool get _hasMetrics => + entry.sleepMinutes != null || + entry.energyLevel != null || + entry.positivityLevel != null || + entry.selfWorthLevel != null; + + static String _formatTime(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} + +class _MetricsRow extends StatelessWidget { + const _MetricsRow({required this.entry}); + + final MoodEntry entry; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (entry.sleepMinutes != null) + _Chip( + icon: Icons.bedtime_outlined, + label: _sleepLabel(entry.sleepMinutes!), + ), + if (entry.energyLevel != null) + _Chip(label: ['💤', '🥱', '⚡', '🔥', '🚀'][(entry.energyLevel! - 1).clamp(0, 4)]), + if (entry.positivityLevel != null) + _Chip(label: ['🌧', '☁️', '🌤', '☀️', '🌈'][(entry.positivityLevel! - 1).clamp(0, 4)]), + if (entry.selfWorthLevel != null) + _Chip(label: ['💔', '😕', '😐', '💪', '⭐'][(entry.selfWorthLevel! - 1).clamp(0, 4)]), + ], + ); + } + + static String _sleepLabel(int minutes) { + final h = minutes ~/ 60; + final m = (minutes % 60).toString().padLeft(2, '0'); + return '$h:$m'; + } +} + +class _Chip extends StatelessWidget { + const _Chip({this.icon, required this.label}); + + final IconData? icon; + final String label; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: cs.outline), + const SizedBox(width: 3), + ], + Text(label, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ); + } +}