Files
flutter/lib/widgets/dashboard/calendar.dart
Roland Geider 381d28d044 Slightly refactor the fetch session data part in the calendar.
In general, it doesn't make much sense that the sessions are the only data points
that are loaded live every time, all the others are simply read from the respective
providers. Hopefully all this can be removed when (if) we move to using a local sqlite
db with powersync.
2026-01-16 15:40:27 +01:00

299 lines
10 KiB
Dart

/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2026 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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/theme/theme.dart';
/// Types of events
enum EventType { weight, measurement, session, caloriesDiary }
/// An event in the dashboard calendar
class Event {
final EventType _type;
final String _description;
const Event(this._type, this._description);
String get description {
return _description;
}
EventType get type {
return _type;
}
}
class DashboardCalendarWidget extends StatefulWidget {
const DashboardCalendarWidget();
@override
_DashboardCalendarWidgetState createState() => _DashboardCalendarWidgetState();
}
class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
with TickerProviderStateMixin {
late Map<String, List<Event>> _events;
late final ValueNotifier<List<Event>> _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 = <String, List<Event>>{};
_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);
// Process weight entries
final weightProvider = context.read<BodyWeightProvider>();
for (final entry in weightProvider.items) {
final date = DateFormatLists.format(entry.date);
if (!_events.containsKey(date)) {
_events[date] = [];
}
// Add events to lists
_events[date]?.add(Event(EventType.weight, '${numberFormat.format(entry.weight)} kg'));
}
// Process measurements
final measurementProvider = context.read<MeasurementProvider>();
for (final category in measurementProvider.categories) {
for (final entry in category.entries) {
final date = DateFormatLists.format(entry.date);
if (!_events.containsKey(date)) {
_events[date] = [];
}
_events[date]?.add(
Event(
EventType.measurement,
'${category.name}: ${numberFormat.format(entry.value)} ${category.unit}',
),
);
}
}
// Process workout sessions
final routinesProvider = context.read<RoutinesProvider>();
final sessions = await routinesProvider.fetchSessionData();
if (!mounted) {
return;
}
for (final session in sessions) {
final date = DateFormatLists.format(session.date);
_events.putIfAbsent(date, () => []);
final time = (session.timeStart != null && session.timeEnd != null)
? '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'
: '';
_events[date]?.add(
Event(
EventType.session,
'${i18n.impression}: ${session.impressionAsString(context)}${time.isNotEmpty ? ' $time' : ''}',
),
);
}
// Process nutritional plans
final nutritionProvider = context.read<NutritionPlansProvider>();
for (final plan in nutritionProvider.items) {
for (final entry in plan.logEntriesValues.entries) {
final date = DateFormatLists.format(entry.key);
if (!_events.containsKey(date)) {
_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();
}
List<Event> _getEventsForDay(DateTime day) {
return _events[DateFormatLists.format(day)] ?? [];
}
List<Event> _getEventsForRange(DateTime start, DateTime end) {
final days = daysInRange(start, end);
return [for (final d in days) ..._getEventsForDay(d)];
}
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);
}
}
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
ListTile(
title: Text(
AppLocalizations.of(context).calendar,
style: Theme.of(context).textTheme.headlineMedium,
),
leading: Icon(
Icons.calendar_today,
color: Theme.of(context).textTheme.headlineMedium!.color,
),
),
TableCalendar<Event>(
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<List<Event>>(
valueListenable: _selectedEvents,
builder: (context, value, _) => Column(
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!'),
),
),
],
),
),
],
),
);
}
}