From c8e0a30e37e7d9ac4bd48457295d54d2ad6b71ff Mon Sep 17 00:00:00 2001 From: Luca-Wiehe Date: Sun, 14 Dec 2025 14:16:17 +0100 Subject: [PATCH 1/2] Implemented Redesign of CalendarWidget --- lib/helpers/consts.dart | 1 + lib/l10n/app_de.arb | 4 + lib/l10n/app_en.arb | 7 + lib/l10n/app_es.arb | 4 + lib/l10n/app_fr.arb | 4 + lib/l10n/app_it.arb | 4 + lib/l10n/app_pl.arb | 4 + lib/l10n/app_pt.arb | 4 + lib/providers/user.dart | 23 + lib/widgets/core/settings.dart | 2 + .../core/settings/first_day_of_week.dart | 50 ++ lib/widgets/dashboard/calendar.dart | 565 +++++++++++++----- 12 files changed, 534 insertions(+), 138 deletions(-) create mode 100644 lib/widgets/core/settings/first_day_of_week.dart diff --git a/lib/helpers/consts.dart b/lib/helpers/consts.dart index 2b976165b..45e493e0b 100644 --- a/lib/helpers/consts.dart +++ b/lib/helpers/consts.dart @@ -57,6 +57,7 @@ const PREFS_INGREDIENTS = 'ingredientData'; const PREFS_WORKOUT_UNITS = 'workoutUnits'; const PREFS_USER = 'userData'; const PREFS_USER_DARK_THEME = 'userDarkMode'; +const PREFS_FIRST_DAY_OF_WEEK = 'firstDayOfWeek'; const PREFS_LAST_SERVER = 'lastServer'; const DEFAULT_ANIMATION_DURATION = Duration(milliseconds: 200); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f768dcd6a..95ae2e209 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -166,6 +166,10 @@ }, "calendar": "Kalender", "@calendar": {}, + "firstDayOfWeek": "Erster Wochentag", + "monday": "Montag", + "sunday": "Sonntag", + "saturday": "Samstag", "noWeightEntries": "Du hast keine Gewichtseinträge", "@noWeightEntries": { "description": "Message shown when the user has no logged weight entries" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 003c43ea6..664a08489 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -647,6 +647,13 @@ "others": "Others", "calendar": "Calendar", "@calendar": {}, + "firstDayOfWeek": "First day of week", + "@firstDayOfWeek": { + "description": "Setting label for choosing the first day of the week in the calendar" + }, + "monday": "Monday", + "sunday": "Sunday", + "saturday": "Saturday", "goToToday": "Go to today", "@goToToday": { "description": "Label on button to jump back to 'today' in the calendar widget" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 395cd1e2f..c5f18c4b5 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -56,6 +56,10 @@ }, "calendar": "Calendario", "@calendar": {}, + "firstDayOfWeek": "Primer día de la semana", + "monday": "Lunes", + "sunday": "Domingo", + "saturday": "Sábado", "aboutDescription": "¡Gracias por usar wger! wger es un proyecto colaborativo de código abierto, realizado por entusiastas del fitness de todo el planeta.", "@aboutDescription": { "description": "Text in the about dialog" diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 902d4c582..cbaa239dd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -35,6 +35,10 @@ }, "calendar": "Calendrier", "@calendar": {}, + "firstDayOfWeek": "Premier jour de la semaine", + "monday": "Lundi", + "sunday": "Dimanche", + "saturday": "Samedi", "toggleDetails": "Afficher les détails", "@toggleDetails": { "description": "Switch to toggle detail / overview" diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7277acf93..7ebe4efb1 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -19,6 +19,10 @@ }, "calendar": "Calendario", "@calendar": {}, + "firstDayOfWeek": "Primo giorno della settimana", + "monday": "Lunedì", + "sunday": "Domenica", + "saturday": "Sabato", "toggleDetails": "Scegli dettagli", "@toggleDetails": { "description": "Switch to toggle detail / overview" diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4cba50c47..aea5731bf 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -117,6 +117,10 @@ }, "calendar": "Kalendarz", "@calendar": {}, + "firstDayOfWeek": "Pierwszy dzień tygodnia", + "monday": "Poniedziałek", + "sunday": "Niedziela", + "saturday": "Sobota", "selectExercise": "Wybierz ćwiczenie", "@selectExercise": { "description": "Error message when the user hasn't selected an exercise in the form" diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 86bf31714..ed0aa5e64 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -201,6 +201,10 @@ }, "calendar": "Calendário", "@calendar": {}, + "firstDayOfWeek": "Primeiro dia da semana", + "monday": "Segunda-feira", + "sunday": "Domingo", + "saturday": "Sábado", "goToToday": "Voltar para hoje", "@goToToday": { "description": "Label on button to jump back to 'today' in the calendar widget" diff --git a/lib/providers/user.dart b/lib/providers/user.dart index 3765bfe26..66e5c4524 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -20,6 +20,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:table_calendar/table_calendar.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/helpers/shared_preferences.dart'; import 'package:wger/models/user/profile.dart'; @@ -27,12 +28,14 @@ import 'package:wger/providers/base_provider.dart'; class UserProvider with ChangeNotifier { ThemeMode themeMode = ThemeMode.system; + StartingDayOfWeek firstDayOfWeek = StartingDayOfWeek.monday; final WgerBaseProvider baseProvider; late SharedPreferencesAsync prefs; UserProvider(this.baseProvider, {SharedPreferencesAsync? prefs}) { this.prefs = prefs ?? PreferenceHelper.asyncPref; _loadThemeMode(); + _loadFirstDayOfWeek(); } static const PROFILE_URL = 'userprofile'; @@ -81,6 +84,26 @@ class UserProvider with ChangeNotifier { notifyListeners(); } + // Load first day of week from SharedPreferences + Future _loadFirstDayOfWeek() async { + final prefsFirstDay = await prefs.getInt(PREFS_FIRST_DAY_OF_WEEK); + + if (prefsFirstDay == null) { + firstDayOfWeek = StartingDayOfWeek.monday; + } else { + firstDayOfWeek = StartingDayOfWeek.values[prefsFirstDay]; + } + + notifyListeners(); + } + + // Change first day of week + void setFirstDayOfWeek(StartingDayOfWeek day) async { + firstDayOfWeek = day; + await prefs.setInt(PREFS_FIRST_DAY_OF_WEEK, day.index); + notifyListeners(); + } + /// Fetch the current user's profile Future fetchAndSetProfile() async { final userData = await baseProvider.fetch(baseProvider.makeUrl(PROFILE_URL)); diff --git a/lib/widgets/core/settings.dart b/lib/widgets/core/settings.dart index 3562c545d..48a698f7f 100644 --- a/lib/widgets/core/settings.dart +++ b/lib/widgets/core/settings.dart @@ -22,6 +22,7 @@ import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/screens/configure_plates_screen.dart'; import 'package:wger/widgets/core/settings/exercise_cache.dart'; +import 'package:wger/widgets/core/settings/first_day_of_week.dart'; import 'package:wger/widgets/core/settings/ingredient_cache.dart'; import 'package:wger/widgets/core/settings/theme.dart'; @@ -49,6 +50,7 @@ class SettingsPage extends StatelessWidget { const SettingsIngredientCache(), ListTile(title: Text(i18n.others, style: Theme.of(context).textTheme.headlineSmall)), const SettingsTheme(), + const SettingsFirstDayOfWeek(), ListTile( title: Text(i18n.selectAvailablePlates), onTap: () { diff --git a/lib/widgets/core/settings/first_day_of_week.dart b/lib/widgets/core/settings/first_day_of_week.dart new file mode 100644 index 000000000..3f672dd2c --- /dev/null +++ b/lib/widgets/core/settings/first_day_of_week.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:table_calendar/table_calendar.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/user.dart'; + +class SettingsFirstDayOfWeek extends StatelessWidget { + const SettingsFirstDayOfWeek({super.key}); + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + final userProvider = Provider.of(context); + + return ListTile( + title: Text(i18n.firstDayOfWeek), + trailing: DropdownButton( + key: const ValueKey('firstDayOfWeekDropdown'), + value: userProvider.firstDayOfWeek, + onChanged: (StartingDayOfWeek? newValue) { + if (newValue != null) { + userProvider.setFirstDayOfWeek(newValue); + } + }, + items: + [ + StartingDayOfWeek.monday, + StartingDayOfWeek.sunday, + StartingDayOfWeek.saturday, + ].map>((StartingDayOfWeek value) { + final label = _getDayLabel(value, i18n); + return DropdownMenuItem(value: value, child: Text(label)); + }).toList(), + ), + ); + } + + String _getDayLabel(StartingDayOfWeek day, AppLocalizations i18n) { + switch (day) { + case StartingDayOfWeek.monday: + return i18n.monday; + case StartingDayOfWeek.sunday: + return i18n.sunday; + case StartingDayOfWeek.saturday: + return i18n.saturday; + default: + return i18n.monday; + } + } +} diff --git a/lib/widgets/dashboard/calendar.dart b/lib/widgets/dashboard/calendar.dart index e7175fd15..1af5d0133 100644 --- a/lib/widgets/dashboard/calendar.dart +++ b/lib/widgets/dashboard/calendar.dart @@ -16,23 +16,60 @@ * along with this program. If not, see . */ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:wger/helpers/consts.dart'; -import 'package:wger/helpers/date.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/measurement.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/routines.dart'; +import 'package:wger/providers/user.dart'; import 'package:wger/theme/theme.dart'; /// Types of events enum EventType { weight, measurement, session, caloriesDiary } +/// Color constants for event types - consistent across rings and modal +/// Modern complementary palette with distinct hues for easy differentiation +const Color eventColorSession = Color(0xFF22C55E); // Green - workouts +const Color eventColorNutrition = Color(0xFFF97316); // Orange - nutrition +const Color eventColorWeight = Color(0xFF3B82F6); // Blue - weight +const Color eventColorMeasurement = Color(0xFFEC4899); // Pink - measurements + +/// Returns the color for an event type +Color getEventColor(EventType type) { + switch (type) { + case EventType.weight: + return eventColorWeight; + case EventType.measurement: + return eventColorMeasurement; + case EventType.session: + return eventColorSession; + case EventType.caloriesDiary: + return eventColorNutrition; + } +} + +/// Returns the icon for an event type +IconData getEventIcon(EventType type) { + switch (type) { + case EventType.weight: + return Icons.monitor_weight_outlined; + case EventType.measurement: + return Icons.straighten_outlined; + case EventType.session: + return Icons.fitness_center_outlined; + case EventType.caloriesDiary: + return Icons.restaurant_outlined; + } +} + /// An event in the dashboard calendar class Event { final EventType _type; @@ -59,43 +96,18 @@ class DashboardCalendarWidget extends StatefulWidget { class _DashboardCalendarWidgetState extends State with TickerProviderStateMixin { late Map> _events; - late final ValueNotifier> _selectedEvents; - RangeSelectionMode _rangeSelectionMode = - RangeSelectionMode.toggledOff; // Can be toggled on/off by longpressing a date DateTime _focusedDay = DateTime.now(); - DateTime? _selectedDay; - DateTime? _rangeStart; - DateTime? _rangeEnd; @override void initState() { super.initState(); - _events = >{}; - _selectedDay = _focusedDay; - _selectedEvents = ValueNotifier(_getEventsForDay(_selectedDay!)); - //Fix: Defer context-dependent loadEvents() until after build WidgetsBinding.instance.addPostFrameCallback((_) { loadEvents(); }); } /// Loads and organizes all events from various providers into the calendar. - /// - /// This method asynchronously fetches and processes data from multiple sources: - /// - **Weight entries**: Retrieves weight measurements from [BodyWeightProvider] - /// - **Measurements**: Retrieves body measurements from [MeasurementProvider] - /// - **Workout sessions**: Fetches workout session data from [RoutinesProvider] - /// - **Nutritional plans**: Retrieves calorie diary entries from [NutritionPlansProvider] - /// - /// Each event is formatted according to the current locale and stored in the - /// [_events] map, keyed by date. The date format is determined by [DateFormatLists.format]. - /// - /// After loading all events, the [_selectedEvents] value is updated with events - /// for the currently selected day, if any. - /// - /// **Note**: This method checks if the widget is still mounted before updating - /// the state after the async workout session fetch operation. void loadEvents() async { final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); final i18n = AppLocalizations.of(context); @@ -109,7 +121,6 @@ class _DashboardCalendarWidgetState extends State _events[date] = []; } - // Add events to lists _events[date]?.add(Event(EventType.weight, '${numberFormat.format(entry.weight)} kg')); } @@ -134,27 +145,24 @@ class _DashboardCalendarWidgetState extends State // Process workout sessions final routinesProvider = context.read(); - await routinesProvider.fetchSessionData().then((sessions) { - for (final session in sessions) { - final date = DateFormatLists.format(session.date); - if (!_events.containsKey(date)) { - _events[date] = []; - } - var time = ''; - time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'; - - // Add events to lists - _events[date]?.add( - Event( - EventType.session, - '${i18n.impression}: ${session.impressionAsString(context)} $time', - ), - ); - } - }); + final sessions = await routinesProvider.fetchSessionData(); if (!mounted) { return; } + for (final session in sessions) { + final date = DateFormatLists.format(session.date); + if (!_events.containsKey(date)) { + _events[date] = []; + } + final time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'; + + _events[date]?.add( + Event( + EventType.session, + '${i18n.impression}: ${session.impressionAsString(context)} $time', + ), + ); + } // Process nutritional plans final NutritionPlansProvider nutritionProvider = Provider.of( @@ -168,133 +176,414 @@ class _DashboardCalendarWidgetState extends State _events[date] = []; } - // Add events to lists _events[date]?.add( Event(EventType.caloriesDiary, i18n.kcalValue(entry.value.energy.toStringAsFixed(0))), ); } } - // Add initial selected day to events list - _selectedEvents.value = _selectedDay != null ? _getEventsForDay(_selectedDay!) : []; - } - - @override - void dispose() { - _selectedEvents.dispose(); - super.dispose(); + // Trigger rebuild to show loaded events + if (mounted) { + setState(() {}); + } } List _getEventsForDay(DateTime day) { return _events[DateFormatLists.format(day)] ?? []; } - List _getEventsForRange(DateTime start, DateTime end) { - final days = daysInRange(start, end); - - return [for (final d in days) ..._getEventsForDay(d)]; + /// Get unique event types for a day (for ring display) + Set _getEventTypesForDay(DateTime day) { + final events = _getEventsForDay(day); + return events.map((e) => e.type).toSet(); } void _onDaySelected(DateTime selectedDay, DateTime focusedDay) { - if (!isSameDay(_selectedDay, selectedDay)) { - setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - _rangeStart = null; // Important to clean those - _rangeEnd = null; - _rangeSelectionMode = RangeSelectionMode.toggledOff; - }); - - _selectedEvents.value = _getEventsForDay(selectedDay); - } - } - - void _onRangeSelected(DateTime? start, DateTime? end, DateTime focusedDay) { setState(() { - _selectedDay = null; _focusedDay = focusedDay; - _rangeStart = start; - _rangeEnd = end; - _rangeSelectionMode = RangeSelectionMode.toggledOn; }); - // `start` or `end` could be null - if (start != null && end != null) { - _selectedEvents.value = _getEventsForRange(start, end); - } else if (start != null) { - _selectedEvents.value = _getEventsForDay(start); - } else if (end != null) { - _selectedEvents.value = _getEventsForDay(end); + final events = _getEventsForDay(selectedDay); + if (events.isNotEmpty) { + _showEventsModal(context, selectedDay, events); } } + void _showEventsModal(BuildContext context, DateTime day, List events) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final dateFormat = DateFormat.yMMMMd(Localizations.localeOf(context).toString()); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF1C1C1E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey.shade700 : Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header with date + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + dateFormat.format(day), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Divider( + height: 1, + color: isDarkMode ? Colors.grey.shade800 : Colors.grey.shade200, + ), + // Event list + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + separatorBuilder: (context, index) => Divider( + height: 1, + indent: 68, + color: isDarkMode ? Colors.grey.shade800 : Colors.grey.shade200, + ), + itemBuilder: (context, index) { + final event = events[index]; + return _EventModalItem(event: event); + }, + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 8), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { - return Card( - child: Column( + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.all(4.0), + child: Material( + elevation: 0, + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: isDarkMode ? Theme.of(context).cardColor : Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 2), + spreadRadius: 0, + ), + ], + ), + child: Column( + children: [ + // Modern header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + AppLocalizations.of(context).calendar, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + // Calendar with custom day builder + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 16), + child: TableCalendar( + locale: Localizations.localeOf(context).languageCode, + firstDay: DateTime.now().subtract(const Duration(days: 1000)), + lastDay: DateTime.now().add(const Duration(days: 365)), + focusedDay: _focusedDay, + calendarFormat: CalendarFormat.month, + availableGestures: AvailableGestures.horizontalSwipe, + availableCalendarFormats: const {CalendarFormat.month: ''}, + startingDayOfWeek: context.watch().firstDayOfWeek, + headerStyle: HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + titleTextStyle: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: isDarkMode ? Colors.white : Colors.black87, + ), + leftChevronIcon: Icon( + Icons.chevron_left, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + rightChevronIcon: Icon( + Icons.chevron_right, + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + ), + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + weekendStyle: TextStyle( + color: wgerSecondaryColor.withValues(alpha: 0.8), + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + calendarStyle: const CalendarStyle( + outsideDaysVisible: false, + cellMargin: EdgeInsets.all(4), + // Hide default markers - we use custom rings + markersMaxCount: 0, + ), + calendarBuilders: CalendarBuilders( + defaultBuilder: (context, day, focusedDay) { + return _buildDayCell(context, day, isToday: false); + }, + todayBuilder: (context, day, focusedDay) { + return _buildDayCell(context, day, isToday: true); + }, + outsideBuilder: (context, day, focusedDay) { + return const SizedBox.shrink(); + }, + ), + onDaySelected: _onDaySelected, + onPageChanged: (focusedDay) { + _focusedDay = focusedDay; + }, + ), + ), + ], + ), + ), + ), + ); + } + + /// Builds a day cell with activity rings + Widget _buildDayCell(BuildContext context, DateTime day, {required bool isToday}) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final eventTypes = _getEventTypesForDay(day); + + return GestureDetector( + onTap: () => _onDaySelected(day, day), + child: Container( + margin: const EdgeInsets.all(2), + child: CustomPaint( + painter: _ActivityRingsPainter( + eventTypes: eventTypes, + isToday: isToday, + isDarkMode: isDarkMode, + ), + child: Center( + child: Container( + width: 22, + height: 22, + decoration: isToday + ? BoxDecoration( + color: isDarkMode ? const Color(0xFF374151) : Colors.grey.shade300, + shape: BoxShape.circle, + ) + : null, + child: Center( + child: Text( + '${day.day}', + style: TextStyle( + color: isToday + ? (isDarkMode ? Colors.white : Colors.black87) + : (isDarkMode ? Colors.grey.shade300 : Colors.grey.shade700), + fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + fontSize: 12, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Custom painter for activity rings around day cells +class _ActivityRingsPainter extends CustomPainter { + final Set eventTypes; + final bool isToday; + final bool isDarkMode; + + // Ring order from outside to inside: session, nutrition, weight, measurement + static const List ringOrder = [ + EventType.session, + EventType.caloriesDiary, + EventType.weight, + EventType.measurement, + ]; + + _ActivityRingsPainter({ + required this.eventTypes, + required this.isToday, + required this.isDarkMode, + }); + + @override + void paint(Canvas canvas, Size size) { + if (eventTypes.isEmpty) { + return; + } + + final center = Offset(size.width / 2, size.height / 2); + final baseRadius = min(size.width, size.height) / 2; + + // Draw rings at fixed positions based on their index in ringOrder + // Each type always has the same radius regardless of which other types are present + for (int i = 0; i < ringOrder.length; i++) { + final type = ringOrder[i]; + + // Only draw if this event type is present + if (!eventTypes.contains(type)) { + continue; + } + + final color = getEventColor(type); + + // Fixed radius for each ring position (outermost to innermost) + final ringRadius = baseRadius - 1 - (i * 3.0); + const strokeWidth = 2.0; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + // Draw full circle ring + canvas.drawCircle(center, ringRadius, paint); + } + } + + @override + bool shouldRepaint(_ActivityRingsPainter oldDelegate) { + return oldDelegate.eventTypes != eventTypes || + oldDelegate.isToday != isToday || + oldDelegate.isDarkMode != isDarkMode; + } +} + +/// Event item displayed in the modal +class _EventModalItem extends StatelessWidget { + final Event event; + + const _EventModalItem({required this.event}); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final eventColor = getEventColor(event.type); + final eventIcon = getEventIcon(event.type); + + String eventTitle; + switch (event.type) { + case EventType.caloriesDiary: + eventTitle = AppLocalizations.of(context).nutritionalDiary; + break; + case EventType.session: + eventTitle = AppLocalizations.of(context).workoutSession; + break; + case EventType.weight: + eventTitle = AppLocalizations.of(context).weight; + break; + case EventType.measurement: + eventTitle = AppLocalizations.of(context).measurement; + break; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( children: [ - ListTile( - title: Text( - AppLocalizations.of(context).calendar, - style: Theme.of(context).textTheme.headlineMedium, + // Colored icon container + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: eventColor.withValues(alpha: isDarkMode ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(12), ), - leading: Icon( - Icons.calendar_today, - color: Theme.of(context).textTheme.headlineMedium!.color, + child: Icon( + eventIcon, + color: eventColor, + size: 20, ), ), - TableCalendar( - locale: Localizations.localeOf(context).languageCode, - firstDay: DateTime.now().subtract(const Duration(days: 1000)), - lastDay: DateTime.now().add(const Duration(days: 365)), - focusedDay: _focusedDay, - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - rangeStartDay: _rangeStart, - rangeEndDay: _rangeEnd, - calendarFormat: CalendarFormat.month, - availableGestures: AvailableGestures.horizontalSwipe, - availableCalendarFormats: const {CalendarFormat.month: ''}, - rangeSelectionMode: _rangeSelectionMode, - eventLoader: _getEventsForDay, - startingDayOfWeek: StartingDayOfWeek.monday, - calendarStyle: getWgerCalendarStyle(Theme.of(context)), - onDaySelected: _onDaySelected, - onRangeSelected: _onRangeSelected, - onPageChanged: (focusedDay) { - _focusedDay = focusedDay; - }, - ), - const SizedBox(height: 8.0), - ValueListenableBuilder>( - valueListenable: _selectedEvents, - builder: (context, value, _) => Column( + const SizedBox(width: 14), + // Event info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...value.map( - (event) => ListTile( - title: Text( - (() { - switch (event.type) { - case EventType.caloriesDiary: - return AppLocalizations.of(context).nutritionalDiary; - - case EventType.session: - return AppLocalizations.of(context).workoutSession; - - case EventType.weight: - return AppLocalizations.of(context).weight; - - case EventType.measurement: - return AppLocalizations.of(context).measurement; - } - })(), - ), - subtitle: Text(event.description), - //onTap: () => print('$event tapped!'), + Text( + eventTitle, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + event.description, + style: TextStyle( + color: isDarkMode ? Colors.grey.shade400 : Colors.grey.shade600, + fontSize: 13, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ], ), ), + // Color indicator dot + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: eventColor, + shape: BoxShape.circle, + ), + ), ], ), ); From 5b6db4ecbfd42c61c6ceb323811b1e11770b0d69 Mon Sep 17 00:00:00 2001 From: Luca-Wiehe Date: Tue, 16 Dec 2025 20:32:04 +0100 Subject: [PATCH 2/2] Fix settings tests by adding firstDayOfWeek mock stubs --- test/core/settings_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/core/settings_test.dart b/test/core/settings_test.dart index 474603c92..aa8d0775d 100644 --- a/test/core/settings_test.dart +++ b/test/core/settings_test.dart @@ -28,6 +28,7 @@ import 'package:wger/providers/base_provider.dart'; import 'package:wger/providers/exercises.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/user.dart'; +import 'package:table_calendar/table_calendar.dart'; import 'package:wger/widgets/core/settings.dart'; import '../../test_data/exercises.dart'; @@ -49,6 +50,7 @@ void main() { setUp(() { when(mockUserProvider.themeMode).thenReturn(ThemeMode.system); + when(mockUserProvider.firstDayOfWeek).thenReturn(StartingDayOfWeek.monday); when(mockExerciseProvider.exercises).thenReturn(getTestExercises()); when(mockNutritionProvider.ingredients).thenReturn([ingredient1, ingredient2]); }); @@ -100,18 +102,21 @@ void main() { group('Theme settings', () { test('Default theme is system', () async { when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => null); + when(mockSharedPreferences.getInt(PREFS_FIRST_DAY_OF_WEEK)).thenAnswer((_) async => null); final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); expect(userProvider.themeMode, ThemeMode.system); }); test('Loads light theme', () async { when(mockSharedPreferences.getBool(PREFS_USER_DARK_THEME)).thenAnswer((_) async => false); + when(mockSharedPreferences.getInt(PREFS_FIRST_DAY_OF_WEEK)).thenAnswer((_) async => null); final userProvider = await UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); expect(userProvider.themeMode, ThemeMode.light); }); test('Saves theme to prefs', () { when(mockSharedPreferences.getBool(any)).thenAnswer((_) async => null); + when(mockSharedPreferences.getInt(any)).thenAnswer((_) async => null); final userProvider = UserProvider(MockWgerBaseProvider(), prefs: mockSharedPreferences); userProvider.setThemeMode(ThemeMode.dark); verify(mockSharedPreferences.setBool(PREFS_USER_DARK_THEME, true)).called(1);