Refactor widgets used in logs

We should try to reuse as many as possible in the end-of-gym-mode logs and in
the routine log page
This commit is contained in:
Roland Geider
2025-11-27 15:08:06 +01:00
parent 142799d870
commit aeb01a517b
11 changed files with 601 additions and 461 deletions

View File

@@ -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<String, dynamic> json) => _$WorkoutSessionFromJson(json);

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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();

View File

@@ -1,14 +1,28 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<ResultsWidget> {
} 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<ResultsWidget> {
}
}
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<Log> 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<Log> logs;
const MuscleGroupsCard(this.logs, {super.key});
List<MuscleGroup> _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(),
),
],
),
),
);
}
}

View File

@@ -1,225 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<SessionInfo> createState() => _SessionInfoState();
}
class _SessionInfoState extends State<SessionInfo> {
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<num, List<Log>> _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,
),
),
],
);
}),
],
),
);
}
}

View File

@@ -0,0 +1,99 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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,
),
),
],
),
),
);
}),
],
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<num, List<Log>> _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),
],
);
}
}

View File

@@ -0,0 +1,115 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<Log> 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,
// ),
],
),
);
}
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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<WorkoutLogCalendar> {
},
),
const SizedBox(height: 8.0),
SizedBox(
child: ValueListenableBuilder<List<DateTime>>(
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<List<DateTime>>(
valueListenable: _selectedEvents,
builder: (context, logEvents, _) {
// At the moment there is only one "event" per day
return logEvents.isNotEmpty
? DayLogWidget(logEvents.first, widget._routine)
: Container();
},
),
],
);

View File

@@ -0,0 +1,138 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<Log> logs;
const MuscleGroupsCard(this.logs, {super.key});
List<MuscleGroup> _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(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<SessionInfo> createState() => _SessionInfoState();
}
class _SessionInfoState extends State<SessionInfo> {
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)),
],
),
);
}
}

View File

@@ -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';