diff --git a/lib/models/workouts/session.dart b/lib/models/workouts/session.dart index 6d2b6353..4dd1829f 100644 --- a/lib/models/workouts/session.dart +++ b/lib/models/workouts/session.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:wger/helpers/json.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/log.dart'; part 'session.g.dart'; @@ -78,6 +79,28 @@ class WorkoutSession { return endDate.difference(startDate); } + String durationTxt(BuildContext context) { + final duration = this.duration; + if (duration == null) { + return '-/-'; + } + return AppLocalizations.of( + context, + ).durationHoursMinutes(duration.inHours, duration.inMinutes.remainder(60)); + } + + String durationTxtWithStartEnd(BuildContext context) { + final duration = this.duration; + if (duration == null) { + return '-/-'; + } + + final startTime = MaterialLocalizations.of(context).formatTimeOfDay(timeStart!); + final endTime = MaterialLocalizations.of(context).formatTimeOfDay(timeEnd!); + + return '${durationTxt(context)} ($startTime - $endTime)'; + } + // Boilerplate factory WorkoutSession.fromJson(Map json) => _$WorkoutSessionFromJson(json); diff --git a/lib/screens/routine_logs_screen.dart b/lib/screens/routine_logs_screen.dart index ab97ebe4..e99255a3 100644 --- a/lib/screens/routine_logs_screen.dart +++ b/lib/screens/routine_logs_screen.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 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 @@ -20,7 +20,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/widgets/core/app_bar.dart'; -import 'package:wger/widgets/routines/workout_logs.dart'; +import 'package:wger/widgets/routines/logs/log_overview_routine.dart'; class WorkoutLogsScreen extends StatelessWidget { const WorkoutLogsScreen(); diff --git a/lib/widgets/routines/gym_mode/result.dart b/lib/widgets/routines/gym_mode/result.dart index 30541b4e..21377faa 100644 --- a/lib/widgets/routines/gym_mode/result.dart +++ b/lib/widgets/routines/gym_mode/result.dart @@ -1,14 +1,28 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + import 'package:collection/collection.dart'; -import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:wger/helpers/date.dart'; -import 'package:wger/helpers/i18n.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/exercises/exercise.dart'; -import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/routine.dart'; import 'package:wger/models/workouts/session_api.dart'; import 'package:wger/providers/gym_state.dart'; @@ -16,6 +30,9 @@ import 'package:wger/providers/routines.dart'; import 'package:wger/widgets/core/progress_indicator.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; +import '../logs/exercises_expansion_card.dart'; +import '../logs/muscle_groups.dart'; + class ResultsWidget extends ConsumerStatefulWidget { final _logger = Logger('ResultsWidget'); @@ -64,7 +81,7 @@ class _ResultsWidgetState extends ConsumerState { } else if (snapshot.hasError) { return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}')); } else if (snapshot.connectionState == ConnectionState.done) { - return WorkoutStats( + return WorkoutSessionStats( _routine.sessions.firstWhereOrNull( (s) => s.session.date.isSameDayAs(DateTime.now()), ), @@ -81,11 +98,11 @@ class _ResultsWidgetState extends ConsumerState { } } -class WorkoutStats extends ConsumerWidget { +class WorkoutSessionStats extends ConsumerWidget { final _logger = Logger('WorkoutStats'); final WorkoutSessionApi? _sessionApi; - WorkoutStats(this._sessionApi, {super.key}); + WorkoutSessionStats(this._sessionApi, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -151,105 +168,6 @@ class WorkoutStats extends ConsumerWidget { } } -class ExercisesCard extends StatelessWidget { - final WorkoutSessionApi session; - - const ExercisesCard(this.session, {super.key}); - - @override - Widget build(BuildContext context) { - final exercises = session.exercises; - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).exercises, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - ...exercises.map((exercise) { - final logs = session.logs.where((log) => log.exerciseId == exercise.id).toList(); - return _ExerciseExpansionTile(exercise: exercise, logs: logs); - }), - ], - ), - ), - ); - } -} - -class _ExerciseExpansionTile extends StatelessWidget { - const _ExerciseExpansionTile({ - required this.exercise, - required this.logs, - }); - - final Exercise exercise; - final List logs; - - @override - Widget build(BuildContext context) { - final languageCode = Localizations.localeOf(context).languageCode; - final theme = Theme.of(context); - - final topSet = logs.isEmpty - ? null - : logs.reduce((a, b) => (a.weight ?? 0) > (b.weight ?? 0) ? a : b); - final topSetWeight = topSet?.weight?.toStringAsFixed(0) ?? 'N/A'; - final topSetWeightUnit = topSet?.weightUnitObj != null - ? getServerStringTranslation(topSet!.weightUnitObj!.name, context) - : ''; - return ExpansionTile( - // leading: const Icon(Icons.fitness_center), - title: Text(exercise.getTranslation(languageCode).name, style: theme.textTheme.titleMedium), - subtitle: Text('Top set: $topSetWeight $topSetWeightUnit'), - children: logs.map((log) => _SetDataRow(log: log)).toList(), - ); - } -} - -class MuscleGroup { - final String name; - final double percentage; - final Color color; - - MuscleGroup(this.name, this.percentage, this.color); -} - -class _SetDataRow extends StatelessWidget { - const _SetDataRow({required this.log}); - - final Log log; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final i18n = AppLocalizations.of(context); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: 5, - children: [ - Text( - log.repTextNoNl(context), - style: theme.textTheme.bodyMedium, - ), - // if (log.volume() > 0) - // Text( - // '${log.volume().toStringAsFixed(0)} ${getServerStringTranslation(log.weightUnitObj!.name, context)}', - // style: theme.textTheme.bodyMedium, - // ), - ], - ), - ); - } -} - class InfoCard extends StatelessWidget { final String title; final String value; @@ -276,110 +194,3 @@ class InfoCard extends StatelessWidget { ); } } - -class MuscleGroupsCard extends StatelessWidget { - final List logs; - - const MuscleGroupsCard(this.logs, {super.key}); - - List _getMuscleGroups(BuildContext context) { - final allMuscles = logs - .expand((log) => [...log.exercise.muscles, ...log.exercise.musclesSecondary]) - .toList(); - if (allMuscles.isEmpty) { - return []; - } - final muscleCounts = allMuscles.groupListsBy((muscle) => muscle.nameTranslated(context)); - final total = allMuscles.length; - - int colorIndex = 0; - final colors = [ - Colors.blue, - Colors.green, - Colors.orange, - Colors.purple, - Colors.red, - Colors.teal, - Colors.deepOrange, - Colors.indigo, - Colors.pink, - Colors.brown, - Colors.cyan, - Colors.lime, - Colors.amber, - Colors.lightGreen, - Colors.deepPurple, - ]; - - return muscleCounts.entries.map((entry) { - final percentage = (entry.value.length / total) * 100; - final color = colors[colorIndex % colors.length]; - colorIndex++; - return MuscleGroup(entry.key, percentage, color); - }).toList(); - } - - @override - Widget build(BuildContext context) { - final muscles = _getMuscleGroups(context); - final theme = Theme.of(context); - final i18n = AppLocalizations.of(context); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.muscles, - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 16), - SizedBox( - height: 200, - child: PieChart( - PieChartData( - sections: muscles.map((muscle) { - return PieChartSectionData( - color: muscle.color, - value: muscle.percentage, - title: i18n.percentValue(muscle.percentage.toStringAsFixed(0)), - radius: 50, - titleStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: theme.colorScheme.onPrimary, - ), - ); - }).toList(), - sectionsSpace: 2, - centerSpaceRadius: 40, - ), - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 8, - children: muscles.map((muscle) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 16, - height: 16, - color: muscle.color, - ), - const SizedBox(width: 8), - Text(muscle.name), - ], - ); - }).toList(), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/routines/log.dart b/lib/widgets/routines/log.dart deleted file mode 100644 index 7037482f..00000000 --- a/lib/widgets/routines/log.dart +++ /dev/null @@ -1,225 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 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. - * - * wger Workout Manager 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 . - */ - -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:wger/helpers/colors.dart'; -import 'package:wger/helpers/date.dart'; -import 'package:wger/helpers/errors.dart'; -import 'package:wger/helpers/misc.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/workouts/log.dart'; -import 'package:wger/models/workouts/routine.dart'; -import 'package:wger/models/workouts/session.dart'; -import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/routines/charts.dart'; -import 'package:wger/widgets/routines/forms/session.dart'; - -class SessionInfo extends StatefulWidget { - final WorkoutSession _session; - - const SessionInfo(this._session); - - @override - State createState() => _SessionInfoState(); -} - -class _SessionInfoState extends State { - bool editMode = false; - - @override - Widget build(BuildContext context) { - final i18n = AppLocalizations.of(context); - - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - ListTile( - title: Text( - i18n.workoutSession, - style: Theme.of(context).textTheme.headlineSmall, - ), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(widget._session.date), - ), - onTap: () => setState(() => editMode = !editMode), - trailing: Icon(editMode ? Icons.edit_off : Icons.edit), - contentPadding: EdgeInsets.zero, - ), - if (editMode) - SessionForm( - widget._session.routineId!, - onSaved: () => setState(() => editMode = false), - session: widget._session, - ) - else - Column( - children: [ - _buildInfoRow( - context, - i18n.timeStart, - widget._session.timeStart != null - ? MaterialLocalizations.of( - context, - ).formatTimeOfDay(widget._session.timeStart!) - : '-/-', - ), - _buildInfoRow( - context, - i18n.timeEnd, - widget._session.timeEnd != null - ? MaterialLocalizations.of(context).formatTimeOfDay(widget._session.timeEnd!) - : '-/-', - ), - _buildInfoRow( - context, - i18n.impression, - widget._session.impressionAsString, - ), - _buildInfoRow( - context, - i18n.notes, - widget._session.notes.isNotEmpty ? widget._session.notes : '-/-', - ), - ], - ), - ], - ), - ); - } - - Widget _buildInfoRow(BuildContext context, String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$label: ', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - Expanded(child: Text(value)), - ], - ), - ); - } -} - -class ExerciseLogChart extends StatelessWidget { - final Map> _logs; - final DateTime _selectedDate; - - const ExerciseLogChart(this._logs, this._selectedDate); - - @override - Widget build(BuildContext context) { - final colors = generateChartColors(_logs.keys.length).iterator; - - return Column( - mainAxisSize: MainAxisSize.max, - children: [ - LogChartWidgetFl(_logs, _selectedDate), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ..._logs.keys.map((reps) { - colors.moveNext(); - - return Indicator( - color: colors.current, - text: formatNum(reps).toString(), - isSquare: false, - ); - }), - ], - ), - ), - const SizedBox(height: 15), - ], - ); - } -} - -class DayLogWidget extends StatelessWidget { - final DateTime _date; - final Routine _routine; - - const DayLogWidget(this._date, this._routine); - - @override - Widget build(BuildContext context) { - final sessionApi = _routine.sessions.firstWhere( - (sessionApi) => sessionApi.session.date.isSameDayAs(_date), - ); - final exercises = sessionApi.exercises; - - return Card( - child: Column( - children: [ - SessionInfo(sessionApi.session), - ...exercises.map((exercise) { - final translation = exercise.getTranslation( - Localizations.localeOf(context).languageCode, - ); - return Column( - children: [ - Text( - translation.name, - style: Theme.of(context).textTheme.titleMedium, - ), - ...sessionApi.logs - .where((l) => l.exerciseId == exercise.id) - .map( - (log) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(log.repTextNoNl(context)), - IconButton( - icon: const Icon(Icons.delete), - key: ValueKey('delete-log-${log.id}'), - onPressed: () { - showDeleteDialog(context, translation.name, log); - }, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: ExerciseLogChart( - _routine.groupLogsByRepetition( - logs: _routine.filterLogsByExercise(exercise.id!), - filterNullReps: true, - filterNullWeights: true, - ), - _date, - ), - ), - ], - ); - }), - ], - ), - ); - } -} diff --git a/lib/widgets/routines/logs/day_logs_container.dart b/lib/widgets/routines/logs/day_logs_container.dart new file mode 100644 index 00000000..9236425e --- /dev/null +++ b/lib/widgets/routines/logs/day_logs_container.dart @@ -0,0 +1,99 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/helpers/date.dart'; +import 'package:wger/helpers/errors.dart'; +import 'package:wger/models/workouts/routine.dart'; + +import 'exercise_log_chart.dart'; +import 'muscle_groups.dart'; +import 'session_info.dart'; + +class DayLogWidget extends StatelessWidget { + final DateTime _date; + final Routine _routine; + + const DayLogWidget(this._date, this._routine); + + @override + Widget build(BuildContext context) { + final sessionApi = _routine.sessions.firstWhere( + (sessionApi) => sessionApi.session.date.isSameDayAs(_date), + ); + final exercises = sessionApi.exercises; + + return Column( + children: [ + Card(child: SessionInfo(sessionApi.session)), + MuscleGroupsCard(sessionApi.logs), + + Column( + children: [ + ...exercises.map((exercise) { + final translation = exercise.getTranslation( + Localizations.localeOf(context).languageCode, + ); + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text( + translation.name, + style: Theme.of(context).textTheme.titleLarge, + ), + ...sessionApi.logs + .where((l) => l.exerciseId == exercise.id) + .map( + (log) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(log.repTextNoNl(context)), + IconButton( + icon: const Icon(Icons.delete), + key: ValueKey('delete-log-${log.id}'), + onPressed: () { + showDeleteDialog(context, translation.name, log); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: ExerciseLogChart( + _routine.groupLogsByRepetition( + logs: _routine.filterLogsByExercise(exercise.id!), + filterNullReps: true, + filterNullWeights: true, + ), + _date, + ), + ), + ], + ), + ), + ); + }), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/routines/logs/exercise_log_chart.dart b/lib/widgets/routines/logs/exercise_log_chart.dart new file mode 100644 index 00000000..c40af116 --- /dev/null +++ b/lib/widgets/routines/logs/exercise_log_chart.dart @@ -0,0 +1,61 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + +import 'package:flutter/widgets.dart'; +import 'package:wger/helpers/colors.dart'; +import 'package:wger/helpers/misc.dart'; +import 'package:wger/models/workouts/log.dart'; +import 'package:wger/widgets/measurements/charts.dart'; +import 'package:wger/widgets/routines/charts.dart'; + +class ExerciseLogChart extends StatelessWidget { + final Map> _logs; + final DateTime _selectedDate; + + const ExerciseLogChart(this._logs, this._selectedDate); + + @override + Widget build(BuildContext context) { + final colors = generateChartColors(_logs.keys.length).iterator; + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + LogChartWidgetFl(_logs, _selectedDate), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ..._logs.keys.map((reps) { + colors.moveNext(); + + return Indicator( + color: colors.current, + text: formatNum(reps).toString(), + isSquare: false, + ); + }), + ], + ), + ), + const SizedBox(height: 15), + ], + ); + } +} diff --git a/lib/widgets/routines/logs/exercises_expansion_card.dart b/lib/widgets/routines/logs/exercises_expansion_card.dart new file mode 100644 index 00000000..0286418f --- /dev/null +++ b/lib/widgets/routines/logs/exercises_expansion_card.dart @@ -0,0 +1,115 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/helpers/i18n.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/exercises/exercise.dart'; +import 'package:wger/models/workouts/log.dart'; +import 'package:wger/models/workouts/session_api.dart'; + +class ExercisesCard extends StatelessWidget { + final WorkoutSessionApi session; + + const ExercisesCard(this.session, {super.key}); + + @override + Widget build(BuildContext context) { + final exercises = session.exercises; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).exercises, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ...exercises.map((exercise) { + final logs = session.logs.where((log) => log.exerciseId == exercise.id).toList(); + return _ExerciseExpansionTile(exercise: exercise, logs: logs); + }), + ], + ), + ), + ); + } +} + +class _ExerciseExpansionTile extends StatelessWidget { + const _ExerciseExpansionTile({ + required this.exercise, + required this.logs, + }); + + final Exercise exercise; + final List logs; + + @override + Widget build(BuildContext context) { + final languageCode = Localizations.localeOf(context).languageCode; + final theme = Theme.of(context); + + final topSet = logs.isEmpty + ? null + : logs.reduce((a, b) => (a.weight ?? 0) > (b.weight ?? 0) ? a : b); + final topSetWeight = topSet?.weight?.toStringAsFixed(0) ?? 'N/A'; + final topSetWeightUnit = topSet?.weightUnitObj != null + ? getServerStringTranslation(topSet!.weightUnitObj!.name, context) + : ''; + return ExpansionTile( + // leading: const Icon(Icons.fitness_center), + title: Text(exercise.getTranslation(languageCode).name, style: theme.textTheme.titleMedium), + subtitle: Text('Top set: $topSetWeight $topSetWeightUnit'), + children: logs.map((log) => _SetDataRow(log: log)).toList(), + ); + } +} + +class _SetDataRow extends StatelessWidget { + const _SetDataRow({required this.log}); + + final Log log; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final i18n = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 5, + children: [ + Text( + log.repTextNoNl(context), + style: theme.textTheme.bodyMedium, + ), + // if (log.volume() > 0) + // Text( + // '${log.volume().toStringAsFixed(0)} ${getServerStringTranslation(log.weightUnitObj!.name, context)}', + // style: theme.textTheme.bodyMedium, + // ), + ], + ), + ); + } +} diff --git a/lib/widgets/routines/workout_logs.dart b/lib/widgets/routines/logs/log_overview_routine.dart similarity index 81% rename from lib/widgets/routines/workout_logs.dart rename to lib/widgets/routines/logs/log_overview_routine.dart index 66ab75aa..dfcfc1aa 100644 --- a/lib/widgets/routines/workout_logs.dart +++ b/lib/widgets/routines/logs/log_overview_routine.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 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 @@ -23,7 +23,7 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/routine.dart'; import 'package:wger/theme/theme.dart'; -import 'package:wger/widgets/routines/log.dart'; +import 'package:wger/widgets/routines/logs/day_logs_container.dart'; class WorkoutLogs extends StatelessWidget { final Routine _routine; @@ -39,14 +39,6 @@ class WorkoutLogs extends StatelessWidget { AppLocalizations.of(context).labelWorkoutLogs, style: Theme.of(context).textTheme.headlineSmall, ), - Text( - AppLocalizations.of(context).logHelpEntries, - textAlign: TextAlign.justify, - ), - Text( - AppLocalizations.of(context).logHelpEntriesUnits, - textAlign: TextAlign.justify, - ), SizedBox( width: double.infinity, child: WorkoutLogCalendar(_routine), @@ -141,16 +133,29 @@ class _WorkoutLogCalendarState extends State { }, ), const SizedBox(height: 8.0), - SizedBox( - child: ValueListenableBuilder>( - valueListenable: _selectedEvents, - builder: (context, logEvents, _) { - // At the moment there is only one "event" per day - return logEvents.isNotEmpty - ? DayLogWidget(logEvents.first, widget._routine) - : Container(); - }, - ), + ExpansionTile( + showTrailingIcon: false, + dense: true, + title: const Align(alignment: Alignment.centerLeft, child: Icon(Icons.info_outline)), + children: [ + Text( + AppLocalizations.of(context).logHelpEntries, + textAlign: TextAlign.justify, + ), + Text( + AppLocalizations.of(context).logHelpEntriesUnits, + textAlign: TextAlign.justify, + ), + ], + ), + ValueListenableBuilder>( + valueListenable: _selectedEvents, + builder: (context, logEvents, _) { + // At the moment there is only one "event" per day + return logEvents.isNotEmpty + ? DayLogWidget(logEvents.first, widget._routine) + : Container(); + }, ), ], ); diff --git a/lib/widgets/routines/logs/muscle_groups.dart b/lib/widgets/routines/logs/muscle_groups.dart new file mode 100644 index 00000000..1ef2b0e9 --- /dev/null +++ b/lib/widgets/routines/logs/muscle_groups.dart @@ -0,0 +1,138 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + +import 'package:collection/collection.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/workouts/log.dart'; + +class MuscleGroup { + final String name; + final double percentage; + final Color color; + + MuscleGroup(this.name, this.percentage, this.color); +} + +class MuscleGroupsCard extends StatelessWidget { + final List logs; + + const MuscleGroupsCard(this.logs, {super.key}); + + List _getMuscleGroups(BuildContext context) { + final allMuscles = logs + .expand((log) => [...log.exercise.muscles, ...log.exercise.musclesSecondary]) + .toList(); + if (allMuscles.isEmpty) { + return []; + } + final muscleCounts = allMuscles.groupListsBy((muscle) => muscle.nameTranslated(context)); + final total = allMuscles.length; + + int colorIndex = 0; + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.deepOrange, + Colors.indigo, + Colors.pink, + Colors.brown, + Colors.cyan, + Colors.lime, + Colors.amber, + Colors.lightGreen, + Colors.deepPurple, + ]; + + return muscleCounts.entries.map((entry) { + final percentage = (entry.value.length / total) * 100; + final color = colors[colorIndex % colors.length]; + colorIndex++; + return MuscleGroup(entry.key, percentage, color); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final muscles = _getMuscleGroups(context); + final theme = Theme.of(context); + final i18n = AppLocalizations.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.muscles, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: muscles.map((muscle) { + return PieChartSectionData( + color: muscle.color, + value: muscle.percentage, + title: i18n.percentValue(muscle.percentage.toStringAsFixed(0)), + radius: 50, + titleStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimary, + ), + ); + }).toList(), + sectionsSpace: 2, + centerSpaceRadius: 40, + ), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 8, + children: muscles.map((muscle) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + color: muscle.color, + ), + const SizedBox(width: 8), + Text(muscle.name), + ], + ); + }).toList(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/routines/logs/session_info.dart b/lib/widgets/routines/logs/session_info.dart new file mode 100644 index 00000000..3b3846cf --- /dev/null +++ b/lib/widgets/routines/logs/session_info.dart @@ -0,0 +1,113 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 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 . + */ + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/workouts/session.dart'; +import 'package:wger/widgets/routines/forms/session.dart'; + +class SessionInfo extends StatefulWidget { + final WorkoutSession _session; + + const SessionInfo(this._session); + + @override + State createState() => _SessionInfoState(); +} + +class _SessionInfoState extends State { + bool editMode = false; + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + ListTile( + title: Text( + i18n.workoutSession, + style: Theme.of(context).textTheme.headlineSmall, + ), + subtitle: Text( + DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(widget._session.date), + ), + onTap: () => setState(() => editMode = !editMode), + trailing: Icon(editMode ? Icons.edit_off : Icons.edit), + contentPadding: EdgeInsets.zero, + ), + if (editMode) + SessionForm( + widget._session.routineId!, + onSaved: () => setState(() => editMode = false), + session: widget._session, + ) + else + Column( + children: [ + SessionRow( + label: i18n.impression, + value: widget._session.impressionAsString, + ), + SessionRow( + label: i18n.duration, + value: widget._session.durationTxtWithStartEnd(context), + ), + SessionRow( + label: i18n.notes, + value: widget._session.notes.isNotEmpty ? widget._session.notes : '-/-', + ), + ], + ), + ], + ), + ); + } +} + +class SessionRow extends StatelessWidget { + const SessionRow({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label: ', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/test/routine/routine_logs_screen_test.dart b/test/routine/routine_logs_screen_test.dart index 8d57b942..c61bdf3a 100644 --- a/test/routine/routine_logs_screen_test.dart +++ b/test/routine/routine_logs_screen_test.dart @@ -29,7 +29,7 @@ import 'package:wger/models/workouts/routine.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/screens/routine_logs_screen.dart'; import 'package:wger/screens/routine_screen.dart'; -import 'package:wger/widgets/routines/workout_logs.dart'; +import 'package:wger/widgets/routines/logs/log_overview_routine.dart'; import '../../test_data/routines.dart'; import 'routine_logs_screen_test.mocks.dart';