mirror of
https://github.com/wger-project/flutter.git
synced 2026-02-18 00:17:48 +01:00
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.
299 lines
10 KiB
Dart
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!'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|