Merge branch 'master' into fork/dhituval/issue852/hide-diet-plan

This commit is contained in:
Roland Geider
2026-01-16 23:06:11 +01:00
88 changed files with 4421 additions and 753 deletions

View File

@@ -9,7 +9,7 @@ runs:
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.38.3
flutter-version: 3.38.6
cache: true
- name: Install Flutter dependencies

View File

@@ -36,7 +36,7 @@ jobs:
- name: Build APK
run: flutter build apk --release
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-apk
path: build/app/outputs/flutter-apk/app-release.apk
@@ -67,7 +67,7 @@ jobs:
- name: Build AAB
run: flutter build appbundle --release
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-aab
path: build/app/outputs/bundle/release/app-release.aab

View File

@@ -31,7 +31,7 @@ jobs:
cd build/ios/iphoneos
zip -r Runner.app.zip Runner.app
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-ios
path: build/ios/iphoneos/Runner.app.zip
@@ -61,7 +61,7 @@ jobs:
cd build/ios/archive
zip -r Runner.xcarchive.zip Runner.xcarchive
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-ipa
path: build/ios/archive/Runner.xcarchive.zip
@@ -84,7 +84,7 @@ jobs:
cd build/macos/Build/Products/Release
zip -r wger.app.zip wger.app
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-macos
path: build/macos/Build/Products/Release/wger.app.zip

View File

@@ -39,7 +39,7 @@ jobs:
sudo apt install -y pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev --no-install-recommends
flutter build linux --release
tar -zcvf linux-${{ matrix.platform }}.tar.gz build/linux/${{ matrix.platform }}/release/bundle
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-linux
path: |
@@ -62,7 +62,7 @@ jobs:
- name: Bump version and update manifest
run: |
git clone https://github.com/TheAppgineer/flatpak-flutter.git --branch 0.7.5 ../flatpak-flutter
git clone https://github.com/TheAppgineer/flatpak-flutter.git --branch 0.11.0 ../flatpak-flutter
pip install -r ../flatpak-flutter/requirements.txt
python bump-wger-version.py ${{ inputs.ref }}
../flatpak-flutter/flatpak-flutter.py --app-module wger flatpak-flutter.json

View File

@@ -25,7 +25,7 @@ jobs:
- name: Build .exe
run: flutter build windows --release
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: builds-windows
path: build\windows\x64\runner\Release\wger.exe

View File

@@ -67,7 +67,7 @@ jobs:
ref: ${{ github.event.inputs.version }}
- name: Download builds
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: /tmp/
@@ -115,7 +115,7 @@ jobs:
# uses: ./.github/actions/flutter-common
#
# - name: Download builds
# uses: actions/download-artifact@v6
# uses: actions/download-artifact@v7
# with:
# path: /tmp/
#
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Download builds
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
- name: Make Github release
uses: softprops/action-gh-release@v2

View File

@@ -32,7 +32,7 @@ jobs:
flutter drive --driver=test_driver/screenshot_driver.dart --target=integration_test/make_screenshots_test.dart --dart-define=DEVICE_TYPE=iOSPhoneBig -d "$SIMULATOR"
- name: Upload screenshots
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: screenshots-ios
path: fastlane/metadata/ios/**/images/iPhone 6.9/*.png
@@ -113,7 +113,7 @@ jobs:
flutter drive --driver=test_driver/screenshot_driver.dart --target=integration_test/make_screenshots_test.dart --dart-define=DEVICE_TYPE=${{ matrix.device.device_type }}
- name: Upload screenshots
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: screenshots-android-${{ matrix.device.folder }}
path: fastlane/metadata/android/**/images/${{ matrix.device.folder }}/*.png
@@ -129,7 +129,7 @@ jobs:
- uses: actions/checkout@v6
- name: Download all screenshot artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: screenshots

View File

@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1190.0)
aws-sdk-core (3.239.2)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,18 +17,18 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.206.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (3.3.1)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -61,7 +61,7 @@ GEM
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@@ -71,7 +71,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.229.1)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -100,6 +100,7 @@ GEM
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
@@ -164,22 +165,22 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.17.1)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.18.0)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (7.0.0)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)

View File

@@ -14,15 +14,16 @@ analyzer:
# Allow self-reference to deprecated members (we do this because otherwise we have
# to annotate every member in every test, assert, etc, when we deprecate something)
deprecated_member_use_from_same_package: ignore
# Ignore analyzer hints for updating pubspecs when using Future or
# Stream and not importing dart:async
# Please see https://github.com/flutter/flutter/pull/24528 for details.
sdk_version_async_exported_from_core: ignore
plugins:
- riverpod_lint
formatter:
page_width: 100
trailing_commas: preserve
plugins:
riverpod_lint: 3.1.0
linter:
rules:
# These rules are documented on and in the same order as

View File

@@ -84,6 +84,24 @@
</screenshot>
</screenshots>
<releases>
<release version="1.9.5" date="2026-01-14">
<description>
<p>Bug fixes and improvements.</p>
</description>
<url>https://github.com/wger-project/flutter/releases/tag/1.9.5</url>
</release>
<release version="1.9.4" date="2025-12-23">
<description>
<p>Bug fixes and improvements.</p>
</description>
<url>https://github.com/wger-project/flutter/releases/tag/1.9.4</url>
</release>
<release version="1.9.3" date="2025-12-16">
<description>
<p>Bug fixes and improvements.</p>
</description>
<url>https://github.com/wger-project/flutter/releases/tag/1.9.3</url>
</release>
<release version="1.9.2" date="2025-12-04">
<description>
<p>Bug fixes and improvements.</p>

View File

@@ -9,24 +9,28 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1018.0)
aws-sdk-core (3.214.0)
aws-eventstream (1.4.0)
aws-partitions (1.1196.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.176.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -157,6 +161,7 @@ GEM
json (2.9.0)
jwt (2.9.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)

View File

@@ -0,0 +1,86 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020 - 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 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
enum ErrorType {
json,
html,
text,
}
const HTML_ERROR_KEY = 'html_error';
class WgerHttpException implements Exception {
Map<String, dynamic> errors = {};
/// The exception type. While the majority will be json, it is possible that
/// the server will return HTML, e.g. if there has been an internal server error
/// or similar.
late ErrorType type;
/// Custom http exception
WgerHttpException(Response response) {
type = ErrorType.json;
final dynamic responseBody = response.body;
final contentType = response.headers[HttpHeaders.contentTypeHeader];
if ((contentType != null && contentType.contains('text/html')) ||
responseBody.toString().contains('<html')) {
type = ErrorType.html;
}
if (responseBody == null) {
errors = {'unknown_error': 'An unknown error occurred, no further information available'};
} else {
try {
if (type == ErrorType.json) {
final response = json.decode(responseBody);
errors = (response is Map ? response : {'unknown_error': response})
.cast<String, dynamic>();
} else if (type == ErrorType.html) {
errors = {HTML_ERROR_KEY: responseBody.toString()};
} else {
errors = {'text_error': responseBody.toString()};
}
} catch (e) {
errors = {'unknown_error': responseBody};
}
}
}
WgerHttpException.fromMap(Map<String, dynamic> map) : type = ErrorType.json {
errors = map;
}
String get htmlError {
if (type != ErrorType.html) {
return '';
}
return errors[HTML_ERROR_KEY] ?? '';
}
@override
String toString() {
return 'WgerHttpException ($type): $errors';
}
}

View File

@@ -429,7 +429,10 @@ typedef $$IngredientsTableProcessedTableManager =
$$IngredientsTableAnnotationComposer,
$$IngredientsTableCreateCompanionBuilder,
$$IngredientsTableUpdateCompanionBuilder,
(IngredientTable, BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>),
(
IngredientTable,
BaseReferences<_$IngredientDatabase, $IngredientsTable, IngredientTable>,
),
IngredientTable,
PrefetchHooks Function()
>;

View File

@@ -1,48 +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.
*
* 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 'dart:convert';
class WgerHttpException implements Exception {
Map<String, dynamic> errors = {};
/// Custom http exception.
/// Expects the response body of the REST call and will try to parse it to
/// JSON. Will use the response as-is if it fails.
WgerHttpException(dynamic responseBody) {
if (responseBody == null) {
errors = {'unknown_error': 'An unknown error occurred, no further information available'};
} else {
try {
final response = json.decode(responseBody);
errors = (response is Map ? response : {'unknown_error': response}).cast<String, dynamic>();
} catch (e) {
errors = {'unknown_error': responseBody};
}
}
}
WgerHttpException.fromMap(Map<String, dynamic> map) {
errors = map;
}
@override
String toString() {
return errors.values.toList().join(', ');
}
}

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 - 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
@@ -22,11 +22,12 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/main.dart';
import 'package:wger/models/workouts/log.dart';
@@ -50,16 +51,21 @@ void showHttpExceptionErrorDialog(WgerHttpException exception, {BuildContext? co
return;
}
final errorList = formatApiErrors(extractErrors(exception.errors));
showDialog(
context: dialogContext,
builder: (ctx) => AlertDialog(
title: Text(AppLocalizations.of(ctx).anErrorOccurred),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [...errorList],
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (exception.type == ErrorType.html)
ServerHtmlError(data: exception.htmlError)
else
...formatApiErrors(extractErrors(exception.errors)),
],
),
),
actions: [
TextButton(
@@ -145,7 +151,10 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
tilePadding: EdgeInsets.zero,
title: Text(i18n.errorViewDetails),
children: [
Text(issueErrorMessage, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(
issueErrorMessage,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -237,6 +246,31 @@ void showGeneralErrorDialog(dynamic error, StackTrace? stackTrace, {BuildContext
);
}
/// A widget to render HTML errors returned by the server
///
/// This is a simple wrapper around the `Html` Widget, with some light changes
/// to the style.
class ServerHtmlError extends StatelessWidget {
final logger = Logger('ServerHtml');
final String data;
ServerHtmlError({required this.data, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Html(
data: data,
style: {
'h1': Style(fontSize: FontSize(theme.textTheme.bodyLarge?.fontSize ?? 15)),
'h2': Style(fontSize: FontSize(theme.textTheme.bodyMedium?.fontSize ?? 15)),
},
doNotRenderTheseTags: const {'a'},
);
}
}
class CopyToClipboardButton extends StatelessWidget {
final logger = Logger('CopyToClipboardButton');
final String text;
@@ -423,38 +457,26 @@ class FormHttpErrorsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
constraints: const BoxConstraints(maxHeight: 250),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.error, width: 1),
borderRadius: BorderRadius.circular(6),
),
padding: const EdgeInsets.all(10),
child: SingleChildScrollView(
child: Column(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
...formatApiErrors(
extractErrors(exception.errors),
color: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
}
class GeneralErrorsWidget extends StatelessWidget {
final String? title;
final List<String> widgets;
const GeneralErrorsWidget(this.widgets, {this.title, super.key});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 250),
child: SingleChildScrollView(
child: Column(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
...formatTextErrors(widgets, title: title, color: Theme.of(context).colorScheme.error),
Icon(Icons.error_outline, color: theme.colorScheme.error),
if (exception.type == ErrorType.html)
ServerHtmlError(data: exception.htmlError)
else
...formatApiErrors(
extractErrors(exception.errors),
color: theme.colorScheme.error,
),
],
),
),

View File

@@ -705,7 +705,7 @@
"@whatVariationsExist": {},
"previous": "Vorherige",
"@previous": {},
"next": "Nächste",
"next": "Weiter",
"@next": {},
"swiss_ball": "Gymnastikball",
"@swiss_ball": {},
@@ -1127,5 +1127,65 @@
"endWorkout": "Training beenden",
"@endWorkout": {},
"dayTypeCustom": "personalisierte",
"@dayTypeCustom": {}
"@dayTypeCustom": {},
"dayTypeTabata": "Tabata",
"@dayTypeTabata": {},
"impressionGood": "Gut",
"@impressionGood": {},
"impressionNeutral": "Neutral",
"@impressionNeutral": {},
"impressionBad": "Schlecht",
"@impressionBad": {},
"gymModeShowExercises": "Übersichtsseiten der Übungen anzeigen",
"@gymModeShowExercises": {},
"gymModeShowTimer": "Timer zwischen Sätzen anzeigen",
"@gymModeShowTimer": {},
"gymModeTimerType": "Timer-Typ",
"@gymModeTimerType": {},
"gymModeTimerTypeHelText": "Wenn ein Satz eine Pausenzeit hat, wird immer ein Countdown genutzt.",
"@gymModeTimerTypeHelText": {},
"countdown": "Countdown",
"@countdown": {},
"stopwatch": "Stoppuhr",
"@stopwatch": {},
"gymModeDefaultCountdownTime": "Standard-Countdown-Zeit in Sekunden",
"@gymModeDefaultCountdownTime": {},
"gymModeNotifyOnCountdownFinish": "Benachrichtigung bei Ende des Countdowns",
"@gymModeNotifyOnCountdownFinish": {},
"duration": "Dauer",
"@duration": {},
"durationHoursMinutes": "{hours}h {minutes}m",
"@durationHoursMinutes": {
"description": "A duration, in hours and minutes",
"type": "text",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"volume": "Volumen",
"@volume": {
"description": "The volume of a workout or set, i.e. weight x reps"
},
"workoutCompleted": "Training abgeschlossen",
"@workoutCompleted": {},
"formMinMaxValues": "Bitte geben Sie einen Wert zwischen {min} und {max}",
"@formMinMaxValues": {
"description": "Error message when the user needs to enter a value between min and max",
"type": "text",
"placeholders": {
"min": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"superset": "Superset",
"@superset": {}
}

View File

@@ -7,7 +7,7 @@
"@weight": {
"description": "The weight of a workout log or body weight entry"
},
"confirmDelete": "Êtes-vous sûre de vouloir supprimer « {toDelete} »?",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer '{toDelete}'?",
"@confirmDelete": {
"description": "Confirmation text before the user deletes an object",
"type": "text",
@@ -199,7 +199,7 @@
"@reset": {
"description": "Button text allowing the user to reset the entered values to the default"
},
"useCustomServer": "Utiliser le serveur personnalisé",
"useCustomServer": "Utiliser un serveur personnalisé",
"@useCustomServer": {
"description": "Toggle button allowing users to switch between the default and a custom wger server"
},
@@ -364,7 +364,7 @@
}
}
},
"imageFormatNotSupportedDetail": "{imageFormat} non pris en charge",
"imageFormatNotSupportedDetail": "{imageFormat} pas encore pris en charge.",
"@imageFormatNotSupportedDetail": {
"description": "Label shown on the image preview container when image format is not supported",
"type": "text",
@@ -1126,5 +1126,63 @@
"enterTextInLanguage": "Veuillez saisir le texte dans la bonne langue!",
"@enterTextInLanguage": {},
"endWorkout": "Terminer l'entraînement",
"@endWorkout": {}
"@endWorkout": {},
"impressionGood": "Bonne",
"@impressionGood": {},
"impressionNeutral": "Neutre",
"@impressionNeutral": {},
"impressionBad": "Mauvaise",
"@impressionBad": {},
"gymModeShowExercises": "Afficher les pages d'aperçu des exercices",
"@gymModeShowExercises": {},
"gymModeShowTimer": "Afficher le chronomètre entre les séries",
"@gymModeShowTimer": {},
"gymModeTimerType": "Type de chronomètre",
"@gymModeTimerType": {},
"gymModeTimerTypeHelText": "Si une série a un temps de pause, un compte à rebours est toujours utilisé.",
"@gymModeTimerTypeHelText": {},
"countdown": "Compte à rebours",
"@countdown": {},
"stopwatch": "Chronomètre",
"@stopwatch": {},
"gymModeDefaultCountdownTime": "Temps de compte à rebours par défaut, en secondes",
"@gymModeDefaultCountdownTime": {},
"gymModeNotifyOnCountdownFinish": "Notifier à la fin du compte à rebours",
"@gymModeNotifyOnCountdownFinish": {},
"duration": "Durée",
"@duration": {},
"durationHoursMinutes": "{hours}h {minutes}m",
"@durationHoursMinutes": {
"description": "A duration, in hours and minutes",
"type": "text",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"volume": "Volume",
"@volume": {
"description": "The volume of a workout or set, i.e. weight x reps"
},
"workoutCompleted": "Entraînement terminé",
"@workoutCompleted": {},
"formMinMaxValues": "Veuillez entrer une valeur entre {min} et {max}",
"@formMinMaxValues": {
"description": "Error message when the user needs to enter a value between min and max",
"type": "text",
"placeholders": {
"min": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"superset": "Superset",
"@superset": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -115,7 +115,7 @@
"@close": {
"description": "Translation for close"
},
"successfullyDeleted": "Excluído",
"successfullyDeleted": "Removido com êxito",
"@successfullyDeleted": {
"description": "Message when an item was successfully deleted"
},
@@ -125,13 +125,13 @@
"@goToToday": {
"description": "Label on button to jump back to 'today' in the calendar widget"
},
"set": "Definir",
"set": "Série",
"@set": {
"description": "A set in a workout plan"
},
"noMeasurementEntries": "Você não tem entradas de medição",
"@noMeasurementEntries": {},
"newSet": "Novo conjunto",
"newSet": "Nova séries",
"@newSet": {
"description": "Header when adding a new set to a workout day"
},
@@ -189,7 +189,7 @@
"@pause": {
"description": "Noun, not an imperative! Label used for the pause when using the gym mode"
},
"success": "Sucesso",
"success": "Bem-sucedido",
"@success": {
"description": "Message when an action completed successfully, usually used as a heading"
},
@@ -213,7 +213,7 @@
"@newEntry": {
"description": "Title when adding a new entry such as a weight or log entry"
},
"addSet": "Adicionar set",
"addSet": "Adicionar séries",
"@addSet": {
"description": "Label for the button that adds a set (to a workout day)"
},
@@ -271,7 +271,7 @@
"@loadingText": {
"description": "Text to show when entries are being loaded in the background: Loading..."
},
"selectExercises": "Se quiser fazer um superset você pode procurar vários exercícios, eles estarão agrupados",
"selectExercises": "Se quiser fazer um superséries você pode procurar vários exercícios, eles estarão agrupados",
"@selectExercises": {},
"nutritionalDiary": "Diário nutricional",
"@nutritionalDiary": {},
@@ -953,7 +953,7 @@
"@noRoutines": {},
"restTime": "Tempo de descanso",
"@restTime": {},
"sets": "Conjuntos",
"sets": "Séries",
"@sets": {
"description": "The number of sets to be done for one exercise"
},
@@ -967,7 +967,7 @@
}
}
},
"supersetNr": "Superset {nr}",
"supersetNr": "Supersérie {nr}",
"@supersetNr": {
"description": "Header in form indicating the number of the current exercise. Can also be translated as something like 'Superset Nr. xy'.",
"type": "text",
@@ -981,7 +981,7 @@
"@restDay": {},
"isRestDay": "É dia de descanso",
"@isRestDay": {},
"isRestDayHelp": "Por favor, note que todos os conjuntos e exercícios serão removidos quando marcar um dia como um dia de descanso.",
"isRestDayHelp": "Por favor, note que todos as séries e exercícios serão removidos quando marcar um dia como um dia de descanso.",
"@isRestDayHelp": {},
"needsLogsToAdvance": "Precisa de logs para avançar",
"@needsLogsToAdvance": {},
@@ -1005,7 +1005,7 @@
"@toAddMealsToThePlanGoToNutritionalPlanDetails": {
"description": "Message shown to guide users to the nutritional plan details page to add meals"
},
"errorInfoDescription": "Algo de errado aconteceu. Você pode nos ajudar a concertar esse problema reportando o problema no Github",
"errorInfoDescription": "Algo de errado aconteceu. Você pode nos ajudar a concertar esse problema reportando o problema no Github.",
"@errorInfoDescription": {},
"errorInfoDescription2": "Você pode continuar usando o applicativo, mas algumas funcionalidades não estarão disponíveis.",
"@errorInfoDescription2": {},
@@ -1021,7 +1021,7 @@
"@min": {},
"max": "Max",
"@max": {},
"aboutWhySupportTitle": "Open Source & free to use ❤️",
"aboutWhySupportTitle": "Código aberto e de uso gratuito ❤️",
"@aboutWhySupportTitle": {},
"aboutContributeTitle": "Contribua",
"@aboutContributeTitle": {},
@@ -1043,13 +1043,13 @@
"@fitInWeek": {},
"fitInWeekHelp": "Se ligado, os dias vão se repetir semanalmente, caso contrário os dias seguirão sequencialmente se considerar o começo de uma nova semana.",
"@fitInWeekHelp": {},
"addSuperset": "Adicionar superset",
"addSuperset": "Adicionar superséries",
"@addSuperset": {},
"setHasProgression": "Treino tem prograssão",
"@setHasProgression": {},
"setHasProgressionWarning": "Observe que, no momento, não é possível editar todas as configurações de um conjunto no aplicativo móvel nem configurar a progressão automática. Por enquanto, use o aplicativo web.",
"setHasProgressionWarning": "Observe que, no momento, não é possível editar todas as configurações de um séries no aplicativo móvel nem configurar a progressão automática. Por enquanto, use o aplicativo web.",
"@setHasProgressionWarning": {},
"setHasNoExercises": "Este treino ainda não tem exercícios!",
"setHasNoExercises": "Este séries ainda não tem exercícios!",
"@setHasNoExercises": {},
"simpleMode": "Modo simples",
"@simpleMode": {},
@@ -1057,7 +1057,7 @@
"@simpleModeHelp": {},
"progressionRules": "Este exercício tem regras de progressão e não pode ser editado no aplicativo móvel. Use o aplicativo web para editá-lo.",
"@progressionRules": {},
"resistance_band": "Resistance band",
"resistance_band": "Banda de resistência",
"@resistance_band": {
"description": "Generated entry for translation for server strings"
},
@@ -1077,8 +1077,176 @@
"@startDate": {},
"dayTypeCustom": "Personalizado",
"@dayTypeCustom": {},
"dayTypeHiit": "Treino de alta intensidade",
"dayTypeHiit": "Treinamento intervalado de alta intensidade",
"@dayTypeHiit": {},
"dayTypeTabata": "Tabata",
"@dayTypeTabata": {}
"dayTypeTabata": "Método Tabata",
"@dayTypeTabata": {},
"impressionGood": "Boa",
"@impressionGood": {},
"impressionNeutral": "Neutra",
"@impressionNeutral": {},
"impressionBad": "Ruim",
"@impressionBad": {},
"gymModeShowExercises": "Mostrar páginas de visão geral dos exercícios",
"@gymModeShowExercises": {},
"gymModeShowTimer": "Mostrar cronômetro entre séries",
"@gymModeShowTimer": {},
"gymModeTimerType": "Tipo de temporizador",
"@gymModeTimerType": {},
"gymModeTimerTypeHelText": "Se uma série tiver tempo de pausa, sempre será usada uma contagem regressiva.",
"@gymModeTimerTypeHelText": {},
"countdown": "Contagem regressiva",
"@countdown": {},
"stopwatch": "cronômetro",
"@stopwatch": {},
"gymModeDefaultCountdownTime": "Tempo de contagem regressiva padrão, em segundos",
"@gymModeDefaultCountdownTime": {},
"gymModeNotifyOnCountdownFinish": "Notificar no final da contagem regressiva",
"@gymModeNotifyOnCountdownFinish": {},
"duration": "Duração",
"@duration": {},
"durationHoursMinutes": "{hours}h {minutes}m",
"@durationHoursMinutes": {
"description": "A duration, in hours and minutes",
"type": "text",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"volume": "Volume",
"@volume": {
"description": "The volume of a workout or set, i.e. weight x reps"
},
"workoutCompleted": "Treino concluído",
"@workoutCompleted": {},
"dayTypeEnom": "Cada minuto a minuto",
"@dayTypeEnom": {},
"dayTypeAmrap": "Tantas rodadas quanto possível",
"@dayTypeAmrap": {},
"dayTypeEdt": "Treinamento de densidade crescente",
"@dayTypeEdt": {},
"dayTypeRft": "Rodadas para ganhar tempo",
"@dayTypeRft": {},
"dayTypeAfap": "O mais rápido possível",
"@dayTypeAfap": {},
"slotEntryTypeNormal": "Normal",
"@slotEntryTypeNormal": {},
"slotEntryTypePartial": "Parcial",
"@slotEntryTypePartial": {},
"slotEntryTypeForced": "Forçado",
"@slotEntryTypeForced": {},
"slotEntryTypeTut": "Tempo Sob Tensão",
"@slotEntryTypeTut": {},
"slotEntryTypeIso": "Fixação isométrica",
"@slotEntryTypeIso": {},
"slotEntryTypeJump": "Pular",
"@slotEntryTypeJump": {},
"applicationLogs": "Registros de aplicativos",
"@applicationLogs": {},
"openEnded": "Aberto",
"@openEnded": {
"description": "When a nutrition plan has no pre-defined end date"
},
"overview": "visão global",
"@overview": {},
"formMinMaxValues": "Insira um valor entre {min} e {max}",
"@formMinMaxValues": {
"description": "Error message when the user needs to enter a value between min and max",
"type": "text",
"placeholders": {
"min": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"identicalExercisePleaseDiscard": "Se você notar um exercício idêntico ao que você está adicionando, descarte o rascunho e edite o exercício.",
"@identicalExercisePleaseDiscard": {},
"checkInformationBeforeSubmitting": "Verifique se as informações inseridas estão corretas antes de enviar o exercício",
"@checkInformationBeforeSubmitting": {},
"imageDetailsTitle": "Detalhes da imagem",
"@imageDetailsTitle": {
"description": "Title for image details form"
},
"imageDetailsLicenseTitle": "Titulo",
"@imageDetailsLicenseTitle": {
"description": "Label for image title field"
},
"imageDetailsLicenseTitleHint": "Insira o título da imagem",
"@imageDetailsLicenseTitleHint": {
"description": "Hint text for image title field"
},
"imageDetailsSourceLink": "Link para o site de origem",
"@imageDetailsSourceLink": {
"description": "Label for source link field"
},
"author": "Autor(s)",
"@author": {},
"authorHint": "Digite o nome do autor",
"@authorHint": {
"description": "Hint text for author field"
},
"imageDetailsAuthorLink": "Link para o site ou perfil do autor",
"@imageDetailsAuthorLink": {
"description": "Label for author link field"
},
"imageDetailsDerivativeSource": "Link para a fonte original, se este for um trabalho derivado",
"@imageDetailsDerivativeSource": {
"description": "Label for derivative source field"
},
"imageDetailsDerivativeHelp": "Um trabalho derivado é baseado em um trabalho anterior, mas contém conteúdo novo e criativo suficiente para ter direito aos seus próprios direitos autorais.",
"@imageDetailsDerivativeHelp": {
"description": "Helper text explaining derivative works"
},
"imageDetailsImageType": "Tipo de imagem",
"@imageDetailsImageType": {
"description": "Label for image type selector"
},
"imageDetailsLicenseNotice": "Ao enviar esta imagem, você concorda em liberá-la sob CC-BY-SA-4. A imagem deve ser de sua autoria ou o autor deve tê-la divulgado sob uma licença compatível com ela.",
"@imageDetailsLicenseNotice": {},
"imageDetailsLicenseNoticeLinkToLicense": "Consulte o texto da licença.",
"@imageDetailsLicenseNoticeLinkToLicense": {},
"imageFormatNotSupported": "{imageFormat} não compatível",
"@imageFormatNotSupported": {
"description": "Label shown on the error container when image format is not supported",
"type": "text",
"placeholders": {
"imageFormat": {
"type": "String"
}
}
},
"imageFormatNotSupportedDetail": "Imagens {imageFormat} ainda não são suportadas.",
"@imageFormatNotSupportedDetail": {
"description": "Label shown on the image preview container when image format is not supported",
"type": "text",
"placeholders": {
"imageFormat": {
"type": "String"
}
}
},
"add": "adicionar",
"@add": {
"description": "Add button text"
},
"superset": "Supersérie",
"@superset": {},
"enterTextInLanguage": "Por favor, insira o texto no idioma correto!",
"@enterTextInLanguage": {},
"endWorkout": "Terminar treino",
"@endWorkout": {
"description": "Use the imperative, label on button to finish the current workout in gym mode"
},
"slotEntryTypeMyo": "Myo",
"@slotEntryTypeMyo": {},
"slotEntryTypeDropset": "Drop set",
"@slotEntryTypeDropset": {}
}

View File

@@ -1162,5 +1162,63 @@
"slotEntryTypeJump": "Стрибок",
"@slotEntryTypeJump": {},
"endWorkout": "Закінчити тренування",
"@endWorkout": {}
"@endWorkout": {},
"impressionGood": "Добре",
"@impressionGood": {},
"impressionNeutral": "Нейтральний",
"@impressionNeutral": {},
"impressionBad": "Погано",
"@impressionBad": {},
"gymModeShowExercises": "Показати сторінки огляду вправ",
"@gymModeShowExercises": {},
"gymModeShowTimer": "Показувати таймер між сетами",
"@gymModeShowTimer": {},
"gymModeTimerType": "Тип таймера",
"@gymModeTimerType": {},
"gymModeTimerTypeHelText": "Якщо сет має час паузи, завжди використовується зворотний відлік.",
"@gymModeTimerTypeHelText": {},
"countdown": "Зворотний відлік",
"@countdown": {},
"stopwatch": "Секундомір",
"@stopwatch": {},
"gymModeDefaultCountdownTime": "Час зворотного відліку за замовчуванням, у секундах",
"@gymModeDefaultCountdownTime": {},
"gymModeNotifyOnCountdownFinish": "Повідомити про закінчення зворотного відліку",
"@gymModeNotifyOnCountdownFinish": {},
"duration": "Тривалість",
"@duration": {},
"durationHoursMinutes": "{hours}г {minutes}хв",
"@durationHoursMinutes": {
"description": "A duration, in hours and minutes",
"type": "text",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"volume": "Обсяг",
"@volume": {
"description": "The volume of a workout or set, i.e. weight x reps"
},
"workoutCompleted": "Тренування завершено",
"@workoutCompleted": {},
"formMinMaxValues": "Будь ласка, введіть значення від {min} до {max}",
"@formMinMaxValues": {
"description": "Error message when the user needs to enter a value between min and max",
"type": "text",
"placeholders": {
"min": {
"type": "int"
},
"max": {
"type": "int"
}
}
},
"superset": "Суперсет",
"@superset": {}
}

View File

@@ -21,8 +21,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod;
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/core/locator.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/models/measurements/measurement_entry.dart';
part 'measurement_category.g.dart';

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 - 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
@@ -51,7 +51,7 @@ class Log {
late int routineId;
@JsonKey(required: true, name: 'session')
late int? sessionId;
int? sessionId;
@JsonKey(required: true)
int? iteration;
@@ -72,22 +72,22 @@ class Log {
num? repetitionsTarget;
@JsonKey(required: true, name: 'repetitions_unit')
late int? repetitionsUnitId;
int? repetitionsUnitId;
@JsonKey(includeFromJson: false, includeToJson: false)
late RepetitionUnit? repetitionsUnitObj;
RepetitionUnit? repetitionsUnitObj;
@JsonKey(required: true, fromJson: stringToNumNull, toJson: numToString)
late num? weight;
num? weight;
@JsonKey(required: true, fromJson: stringToNumNull, toJson: numToString, name: 'weight_target')
num? weightTarget;
@JsonKey(required: true, name: 'weight_unit')
late int? weightUnitId;
int? weightUnitId;
@JsonKey(includeFromJson: false, includeToJson: false)
late WeightUnit? weightUnitObj;
WeightUnit? weightUnitObj;
@JsonKey(required: true, fromJson: utcIso8601ToLocalDate, toJson: dateToUtcIso8601)
late DateTime date;
@@ -101,41 +101,78 @@ class Log {
this.repetitions,
this.repetitionsTarget,
this.repetitionsUnitId = REP_UNIT_REPETITIONS_ID,
required this.rir,
this.rir,
this.rirTarget,
this.weight,
this.weightTarget,
this.weightUnitId = WEIGHT_UNIT_KG,
required this.date,
});
DateTime? date,
}) : date = date ?? DateTime.now();
Log.empty();
Log.fromSetConfigData(SetConfigData data) {
Log.fromSetConfigData(SetConfigData setConfig) {
date = DateTime.now();
sessionId = null;
slotEntryId = data.slotEntryId;
exerciseBase = data.exercise;
slotEntryId = setConfig.slotEntryId;
exerciseBase = setConfig.exercise;
if (data.weight != null) {
weight = data.weight;
weightTarget = data.weight;
}
if (data.weightUnit != null) {
weightUnit = data.weightUnit;
weight = setConfig.weight;
weightTarget = setConfig.weight;
weightUnit = setConfig.weightUnit;
repetitions = setConfig.repetitions;
repetitionsTarget = setConfig.repetitions;
repetitionUnit = setConfig.repetitionsUnit;
rir = setConfig.rir;
rirTarget = setConfig.rir;
}
Log copyWith({
int? id,
int? exerciseId,
int? routineId,
int? sessionId,
int? iteration,
int? slotEntryId,
num? rir,
num? rirTarget,
num? repetitions,
num? repetitionsTarget,
int? repetitionsUnitId,
num? weight,
num? weightTarget,
int? weightUnitId,
DateTime? date,
}) {
final out = Log(
id: id ?? this.id,
exerciseId: exerciseId ?? this.exerciseId,
iteration: iteration ?? this.iteration,
slotEntryId: slotEntryId ?? this.slotEntryId,
routineId: routineId ?? this.routineId,
repetitions: repetitions ?? this.repetitions,
repetitionsTarget: repetitionsTarget ?? this.repetitionsTarget,
repetitionsUnitId: repetitionsUnitId ?? this.repetitionsUnitId,
rir: rir ?? this.rir,
rirTarget: rirTarget ?? this.rirTarget,
weight: weight ?? this.weight,
weightTarget: weightTarget ?? this.weightTarget,
weightUnitId: weightUnitId ?? this.weightUnitId,
date: date ?? this.date,
);
if (sessionId != null) {
out.sessionId = sessionId;
}
if (data.repetitions != null) {
repetitions = data.repetitions;
repetitionsTarget = data.repetitions;
}
if (data.repetitionsUnit != null) {
repetitionUnit = data.repetitionsUnit;
}
out.exerciseBase = exercise;
out.repetitionUnit = repetitionsUnitObj;
out.weightUnitObj = weightUnitObj;
rir = data.rir;
rirTarget = data.rir;
return out;
}
// Boilerplate

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) 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
@@ -16,9 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/log.dart';
@@ -62,14 +64,14 @@ class WorkoutSession {
this.id,
this.dayId,
required this.routineId,
this.impression = 2,
this.impression = DEFAULT_IMPRESSION,
this.notes = '',
this.timeStart,
this.timeEnd,
this.logs = const <Log>[],
DateTime? date,
}) {
this.date = date ?? DateTime.now();
this.date = date ?? clock.now();
}
Duration? get duration {

View File

@@ -23,7 +23,9 @@ WorkoutSession _$WorkoutSessionFromJson(Map<String, dynamic> json) {
id: (json['id'] as num?)?.toInt(),
dayId: (json['day'] as num?)?.toInt(),
routineId: (json['routine'] as num?)?.toInt(),
impression: json['impression'] == null ? 2 : int.parse(json['impression'] as String),
impression: json['impression'] == null
? DEFAULT_IMPRESSION
: int.parse(json['impression'] as String),
notes: json['notes'] as String? ?? '',
timeStart: stringToTimeNull(json['time_start'] as String?),
timeEnd: stringToTimeNull(json['time_end'] as String?),

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) 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
@@ -36,8 +36,8 @@ class SlotData {
late List<SetConfigData> setConfigs;
SlotData({
required this.comment,
required this.isSuperset,
this.comment = '',
this.isSuperset = false,
this.exerciseIds = const [],
this.setConfigs = const [],
});

View File

@@ -12,8 +12,8 @@ SlotData _$SlotDataFromJson(Map<String, dynamic> json) {
requiredKeys: const ['comment', 'is_superset', 'exercises', 'sets'],
);
return SlotData(
comment: json['comment'] as String,
isSuperset: json['is_superset'] as bool,
comment: json['comment'] as String? ?? '',
isSuperset: json['is_superset'] as bool? ?? false,
exerciseIds:
(json['exercises'] as List<dynamic>?)?.map((e) => (e as num).toInt()).toList() ?? const [],
setConfigs:

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 - 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
@@ -27,7 +27,7 @@ import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:version/version.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/shared_preferences.dart';
@@ -58,7 +58,7 @@ class AuthProvider with ChangeNotifier {
static const SERVER_VERSION_URL = 'version';
static const REGISTRATION_URL = 'register';
static const LOGIN_URL = 'login';
static const TEST_URL = 'userprofile';
static const USERPROFILE_URL = 'userprofile';
late http.Client client;
@@ -132,7 +132,7 @@ class AuthProvider with ChangeNotifier {
);
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
return login(username, password, serverUrl, null);
@@ -150,7 +150,7 @@ class AuthProvider with ChangeNotifier {
// Login using the API token
if (apiToken != null && apiToken.isNotEmpty) {
final response = await client.get(
makeUri(serverUrl, TEST_URL),
makeUri(serverUrl, USERPROFILE_URL),
headers: {
HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8',
HttpHeaders.userAgentHeader: getAppNameHeader(),
@@ -158,8 +158,8 @@ class AuthProvider with ChangeNotifier {
},
);
if (response.statusCode != 200) {
throw WgerHttpException(response.body);
if (response.statusCode >= 400) {
throw WgerHttpException(response);
}
token = apiToken;
@@ -169,17 +169,18 @@ class AuthProvider with ChangeNotifier {
final response = await client.post(
makeUri(serverUrl, LOGIN_URL),
headers: {
HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8',
HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8',
HttpHeaders.userAgentHeader: getAppNameHeader(),
},
body: json.encode({'username': username, 'password': password}),
);
final responseData = json.decode(response.body);
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
final responseData = json.decode(response.body);
token = responseData['token'];
}

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) 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
@@ -16,19 +16,27 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:logging/logging.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/providers/auth.dart';
import 'package:wger/providers/helpers.dart';
/// initial delay for fetch retries, in milliseconds
const FETCH_INITIAL_DELAY = 250;
/// Base provider class.
///
/// Provides a couple of comfort functions so we avoid a bit of boilerplate.
class WgerBaseProvider {
final _logger = Logger('WgerBaseProvider');
AuthProvider auth;
late http.Client client;
@@ -56,21 +64,53 @@ class WgerBaseProvider {
}
/// Fetch and retrieve the overview list of objects, returns the JSON parsed response
Future<dynamic> fetch(Uri uri) async {
// Future<Map<String, dynamic> | List<dynamic>> fetch(Uri uri) async {
// Send the request
final response = await client.get(
uri,
headers: getDefaultHeaders(includeAuth: true),
);
/// with a simple retry mechanism for transient errors.
Future<dynamic> fetch(
Uri uri, {
int maxRetries = 3,
Duration initialDelay = const Duration(milliseconds: 250),
}) async {
int attempt = 0;
final random = math.Random();
// Something wrong with our request
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
Future<void> wait(String reason) async {
final backoff = (initialDelay.inMilliseconds * math.pow(2, attempt - 1)).toInt();
final jitter = random.nextInt((backoff * 0.25).toInt() + 1); // up to 25% jitter
final delay = backoff + jitter;
_logger.info('Retrying fetch for $uri, attempt $attempt (${delay}ms), reason: $reason');
await Future.delayed(Duration(milliseconds: delay));
}
// Process the response
return json.decode(utf8.decode(response.bodyBytes)) as dynamic;
while (true) {
try {
final response = await client
.get(uri, headers: getDefaultHeaders(includeAuth: true))
.timeout(const Duration(seconds: 5));
if (response.statusCode >= 400) {
// Retry on server errors (5xx); e.g. 502 might be transient
if (response.statusCode >= 500 && attempt < maxRetries) {
attempt++;
await wait('status code ${response.statusCode}');
continue;
}
throw WgerHttpException(response);
}
return json.decode(utf8.decode(response.bodyBytes)) as dynamic;
} catch (e) {
final isRetryable =
e is SocketException || e is http.ClientException || e is TimeoutException;
if (isRetryable && attempt < maxRetries) {
attempt++;
await wait(e.toString());
continue;
}
rethrow;
}
}
}
/// Fetch and retrieve the overview list of objects, returns the JSON parsed response
@@ -104,7 +144,7 @@ class WgerBaseProvider {
// Something wrong with our request
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
return json.decode(response.body);
@@ -120,7 +160,7 @@ class WgerBaseProvider {
// Something wrong with our request
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
return json.decode(response.body);
@@ -137,7 +177,7 @@ class WgerBaseProvider {
// Something wrong with our request
if (response.statusCode >= 400) {
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
return response;
}

View File

@@ -18,7 +18,7 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/body_weight/weight_entry.dart';
import 'package:wger/providers/base_provider.dart';
@@ -115,7 +115,7 @@ class BodyWeightProvider with ChangeNotifier {
if (response.statusCode >= 400) {
_entries.insert(existingEntryIndex, existingWeightEntry);
notifyListeners();
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
}
}

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 - 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
@@ -22,9 +22,9 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/locator.dart';
import 'package:wger/database/exercises/exercise_database.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/models/exercises/category.dart';
@@ -234,7 +234,12 @@ class ExercisesProvider with ChangeNotifier {
Future<void> fetchAndSetCategoriesFromApi() async {
_logger.info('Loading exercise categories from API');
final categories = await baseProvider.fetchPaginated(baseProvider.makeUrl(categoriesUrlPath));
final categories = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
categoriesUrlPath,
query: {'limit': API_MAX_PAGE_SIZE},
),
);
for (final category in categories) {
_categories.add(ExerciseCategory.fromJson(category));
}
@@ -242,7 +247,12 @@ class ExercisesProvider with ChangeNotifier {
Future<void> fetchAndSetMusclesFromApi() async {
_logger.info('Loading muscles from API');
final muscles = await baseProvider.fetchPaginated(baseProvider.makeUrl(musclesUrlPath));
final muscles = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
musclesUrlPath,
query: {'limit': API_MAX_PAGE_SIZE},
),
);
for (final muscle in muscles) {
_muscles.add(Muscle.fromJson(muscle));
@@ -251,7 +261,12 @@ class ExercisesProvider with ChangeNotifier {
Future<void> fetchAndSetEquipmentsFromApi() async {
_logger.info('Loading equipment from API');
final equipments = await baseProvider.fetchPaginated(baseProvider.makeUrl(equipmentUrlPath));
final equipments = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
equipmentUrlPath,
query: {'limit': API_MAX_PAGE_SIZE},
),
);
for (final equipment in equipments) {
_equipment.add(Equipment.fromJson(equipment));
@@ -261,7 +276,12 @@ class ExercisesProvider with ChangeNotifier {
Future<void> fetchAndSetLanguagesFromApi() async {
_logger.info('Loading languages from API');
final languageData = await baseProvider.fetchPaginated(baseProvider.makeUrl(languageUrlPath));
final languageData = await baseProvider.fetchPaginated(
baseProvider.makeUrl(
languageUrlPath,
query: {'limit': API_MAX_PAGE_SIZE},
),
);
for (final language in languageData) {
_languages.add(Language.fromJson(language));

View File

@@ -0,0 +1,46 @@
/*
* 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:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wger/models/workouts/log.dart';
part 'gym_log_state.g.dart';
@Riverpod(keepAlive: true)
class GymLogNotifier extends _$GymLogNotifier {
final _logger = Logger('GymLogNotifier');
@override
Log? build() {
_logger.finer('Initializing GymLogNotifier');
return null;
}
void setLog(Log newLog) {
state = newLog;
}
void setWeight(num weight) {
state = state?.copyWith(weight: weight);
}
void setRepetitions(num repetitions) {
state = state?.copyWith(repetitions: repetitions);
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'gym_log_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(GymLogNotifier)
final gymLogProvider = GymLogNotifierProvider._();
final class GymLogNotifierProvider extends $NotifierProvider<GymLogNotifier, Log?> {
GymLogNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'gymLogProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$gymLogNotifierHash();
@$internal
@override
GymLogNotifier create() => GymLogNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Log? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Log?>(value),
);
}
}
String _$gymLogNotifierHash() => r'4523975eeeaacceca4e86fb2e4ddd9a42c263d8e';
abstract class _$GymLogNotifier extends $Notifier<Log?> {
Log? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<Log?, Log?>;
final element =
ref.element as $ClassProviderElement<AnyNotifier<Log?, Log?>, Log?, Object?, Object?>;
element.handleCreate(ref, build);
}
}

View File

@@ -1,3 +1,21 @@
/*
* 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:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@@ -7,8 +25,10 @@ import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/helpers/uuid.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/set_config_data.dart';
import 'package:wger/providers/gym_log_state.dart';
part 'gym_state.g.dart';
@@ -113,7 +133,11 @@ class SlotPageEntry {
this.setConfigData,
this.logDone = false,
String? uuid,
}) : uuid = uuid ?? uuidV4();
}) : assert(
type != SlotPageType.log || setConfigData != null,
'You need to set setConfigData for SlotPageType.log',
),
uuid = uuid ?? uuidV4();
SlotPageEntry copyWith({
String? uuid,
@@ -463,7 +487,7 @@ class GymStateNotifier extends _$GymStateNotifier {
pages.add(PageEntry(type: PageType.workoutSummary, pageIndex: pageIndex + 1));
state = state.copyWith(pages: pages);
// _logger.finer(readPageStructure());
// print(readPageStructure());
_logger.finer('Initialized ${state.pages.length} pages');
}
@@ -555,6 +579,17 @@ class GymStateNotifier extends _$GymStateNotifier {
void setCurrentPage(int page) {
state = state.copyWith(currentPage: page);
// Ensure that there is a log entry for the current slot entry
final slotEntryPage = state.getSlotEntryPageByIndex();
if (slotEntryPage == null || slotEntryPage.setConfigData == null) {
return;
}
final log = Log.fromSetConfigData(slotEntryPage.setConfigData!);
log.routineId = state.routine.id!;
log.iteration = state.iteration;
ref.read(gymLogProvider.notifier).setLog(log);
}
void setShowExercisePages(bool value) {

View File

@@ -10,10 +10,10 @@ part of 'gym_state.dart';
// ignore_for_file: type=lint, type=warning
@ProviderFor(GymStateNotifier)
const gymStateProvider = GymStateNotifierProvider._();
final gymStateProvider = GymStateNotifierProvider._();
final class GymStateNotifierProvider extends $NotifierProvider<GymStateNotifier, GymModeState> {
const GymStateNotifierProvider._()
GymStateNotifierProvider._()
: super(
from: null,
argument: null,
@@ -40,14 +40,13 @@ final class GymStateNotifierProvider extends $NotifierProvider<GymStateNotifier,
}
}
String _$gymStateNotifierHash() => r'449bd80d3b534f68af4f0dbb8556c7f093f3b918';
String _$gymStateNotifierHash() => r'3a0bb78e9f7e682ba93a40a73b170126b5eb5ca9';
abstract class _$GymStateNotifier extends $Notifier<GymModeState> {
GymModeState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<GymModeState, GymModeState>;
final element =
ref.element
@@ -57,6 +56,6 @@ abstract class _$GymStateNotifier extends $Notifier<GymModeState> {
Object?,
Object?
>;
element.handleValue(ref, created);
element.handleCreate(ref, build);
}
}

View File

@@ -18,8 +18,8 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/models/measurements/measurement_entry.dart';

View File

@@ -21,10 +21,10 @@ import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/locator.dart';
import 'package:wger/database/ingredients/ingredients_database.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/models/nutrition/ingredient_image.dart';
@@ -220,7 +220,7 @@ class NutritionPlansProvider with ChangeNotifier {
if (response.statusCode >= 400) {
_plans.insert(existingPlanIndex, existingPlan);
notifyListeners();
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
//existingPlan = null;
}
@@ -263,7 +263,7 @@ class NutritionPlansProvider with ChangeNotifier {
if (response.statusCode >= 400) {
plan.meals.insert(mealIndex, existingMeal);
notifyListeners();
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
}
@@ -293,7 +293,7 @@ class NutritionPlansProvider with ChangeNotifier {
if (response.statusCode >= 400) {
meal.mealItems.insert(mealItemIndex, existingMealItem);
notifyListeners();
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
}

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) 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
@@ -20,7 +20,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/models/exercises/exercise.dart';
@@ -374,7 +374,7 @@ class RoutinesProvider with ChangeNotifier {
if (response.statusCode >= 400) {
_routines.insert(routineIndex, routine);
notifyListeners();
throw WgerHttpException(response.body);
throw WgerHttpException(response);
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -19,7 +19,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (c) 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.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* 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.
@@ -18,8 +18,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/wide_screen_wrapper.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/providers/measurement.dart';
import 'package:wger/screens/form_screen.dart';
import 'package:wger/widgets/measurements/entries.dart';
@@ -38,7 +40,19 @@ class MeasurementEntriesScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final categoryId = ModalRoute.of(context)!.settings.arguments as int;
final category = Provider.of<MeasurementProvider>(context).findCategoryById(categoryId);
final provider = Provider.of<MeasurementProvider>(context);
MeasurementCategory? category;
try {
category = provider.findCategoryById(categoryId);
} on NoSuchEntryException {
Future.microtask(() {
if (context.mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const SizedBox(); // Return empty widget until pop happens
}
return Scaffold(
appBar: AppBar(
@@ -65,7 +79,7 @@ class MeasurementEntriesScreen extends StatelessWidget {
builder: (BuildContext contextDialog) {
return AlertDialog(
content: Text(
AppLocalizations.of(context).confirmDelete(category.name),
AppLocalizations.of(context).confirmDelete(category!.name),
),
actions: [
TextButton(
@@ -84,11 +98,12 @@ class MeasurementEntriesScreen extends StatelessWidget {
Provider.of<MeasurementProvider>(
context,
listen: false,
).deleteCategory(category.id!);
).deleteCategory(category!.id!);
// Close the popup
Navigator.of(contextDialog).pop();
Navigator.of(context).pop(); // Exit detail screen
// and inform the user
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -138,7 +153,7 @@ class MeasurementEntriesScreen extends StatelessWidget {
body: WidescreenWrapper(
child: SingleChildScrollView(
child: Consumer<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category),
builder: (context, provider, child) => EntriesList(category!),
),
),
),

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* 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.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* 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.
@@ -134,33 +134,29 @@ class _DashboardCalendarWidgetState extends State<DashboardCalendarWidget>
// Process workout sessions
final routinesProvider = context.read<RoutinesProvider>();
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);
_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 NutritionPlansProvider nutritionProvider = Provider.of<NutritionPlansProvider>(
context,
listen: false,
);
final nutritionProvider = context.read<NutritionPlansProvider>();
for (final plan in nutritionProvider.items) {
for (final entry in plan.logEntriesValues.entries) {
final date = DateFormatLists.format(entry.key);

View File

@@ -1,13 +1,13 @@
/*
* 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
* 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,
* 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.
@@ -20,6 +20,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:video_player/video_player.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/models/exercises/video.dart';
@@ -33,9 +34,10 @@ class ExerciseVideoWidget extends StatefulWidget {
}
class _ExerciseVideoWidgetState extends State<ExerciseVideoWidget> {
final logger = Logger('ExerciseVideoWidgetState');
late VideoPlayerController _controller;
bool hasError = false;
final logger = Logger('ExerciseVideoWidgetState');
@override
void initState() {
@@ -66,10 +68,14 @@ class _ExerciseVideoWidgetState extends State<ExerciseVideoWidget> {
@override
Widget build(BuildContext context) {
return hasError
? const GeneralErrorsWidget(
[
'An error happened while loading the video. If you can, please check the application logs.',
],
? FormHttpErrorsWidget(
WgerHttpException.fromMap(
const {
'error':
'An error happened while loading the video. If you can, '
'please check the application logs.',
},
),
)
: _controller.value.isInitialized
? AspectRatio(

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -1,13 +1,13 @@
/*
* 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
* 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,
* 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.
@@ -29,7 +29,7 @@ class RepetitionUnitInputWidget extends StatefulWidget {
late int? selectedRepetitionUnit;
final ValueChanged<int?> onChanged;
RepetitionUnitInputWidget(initialValue, {required this.onChanged}) {
RepetitionUnitInputWidget(int? initialValue, {super.key, required this.onChanged}) {
selectedRepetitionUnit = initialValue;
}
@@ -47,7 +47,7 @@ class _RepetitionUnitInputWidgetState extends State<RepetitionUnitInputWidget> {
: null;
return DropdownButtonFormField(
value: selectedWeightUnit,
initialValue: selectedWeightUnit,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).repetitionUnit,
),

View File

@@ -1,13 +1,13 @@
/*
* 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
* 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,
* 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.
@@ -30,7 +30,7 @@ class RiRInputWidget extends StatefulWidget {
static const SLIDER_START = -0.5;
RiRInputWidget(this._initialValue, {required this.onChanged}) {
RiRInputWidget(this._initialValue, {super.key, required this.onChanged}) {
_logger.finer('Initializing with initial value: $_initialValue');
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -1,13 +1,13 @@
/*
* 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
* 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,
* 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.
@@ -20,7 +20,7 @@ import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/helpers/json.dart';
@@ -100,6 +100,7 @@ class _SessionFormState extends State<SessionForm> {
children: [
errorMessage,
ToggleButtons(
key: const ValueKey('impression-toggle-buttons'),
renderBorder: false,
onPressed: (int index) {
setState(() {

View File

@@ -18,7 +18,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/day.dart';

View File

@@ -19,7 +19,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/errors.dart';
import 'package:wger/l10n/generated/app_localizations.dart';

View File

@@ -1,13 +1,13 @@
/*
* 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
* 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,
* 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.
@@ -29,7 +29,7 @@ class WeightUnitInputWidget extends StatefulWidget {
late int? selectedWeightUnit;
final ValueChanged<int?> onChanged;
WeightUnitInputWidget(int? initialValue, {required this.onChanged}) {
WeightUnitInputWidget(int? initialValue, {super.key, required this.onChanged}) {
selectedWeightUnit = initialValue;
}
@@ -47,7 +47,7 @@ class _WeightUnitInputWidgetState extends State<WeightUnitInputWidget> {
: null;
return DropdownButtonFormField(
value: selectedWeightUnit,
initialValue: selectedWeightUnit,
decoration: InputDecoration(labelText: AppLocalizations.of(context).weightUnit),
onChanged: (WeightUnit? newValue) {
setState(() {

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (c) 2020 - 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.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* 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.
@@ -15,17 +15,19 @@
* 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart' as provider;
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/set_config_data.dart';
import 'package:wger/models/workouts/slot_entry.dart';
import 'package:wger/providers/gym_log_state.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/plate_weights.dart';
import 'package:wger/providers/routines.dart';
@@ -38,60 +40,37 @@ import 'package:wger/widgets/routines/forms/weight_unit.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
import 'package:wger/widgets/routines/plate_calculator.dart';
class LogPage extends ConsumerStatefulWidget {
class LogPage extends ConsumerWidget {
final _logger = Logger('LogPage');
final PageController _controller;
LogPage(this._controller);
@override
_LogPageState createState() => _LogPageState();
}
class _LogPageState extends ConsumerState<LogPage> {
final GlobalKey<_LogFormWidgetState> _logFormKey = GlobalKey<_LogFormWidgetState>();
late FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode();
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final state = ref.watch(gymStateProvider);
final gymState = ref.watch(gymStateProvider);
final languageCode = Localizations.localeOf(context).languageCode;
final page = state.getPageByIndex();
final page = gymState.getPageByIndex();
if (page == null) {
widget._logger.info(
'getPageByIndex for ${state.currentPage} returned null, showing empty container.',
_logger.info(
'getPageByIndex for ${gymState.currentPage} returned null, showing empty container.',
);
return Container();
}
final slotEntryPage = state.getSlotEntryPageByIndex();
final slotEntryPage = gymState.getSlotEntryPageByIndex();
if (slotEntryPage == null) {
widget._logger.info(
'getSlotPageByIndex for ${state.currentPage} returned null, showing empty container',
_logger.info(
'getSlotPageByIndex for ${gymState.currentPage} returned null, showing empty container',
);
return Container();
}
final setConfigData = slotEntryPage.setConfigData!;
final log = Log.fromSetConfigData(setConfigData)
..routineId = state.routine.id!
..iteration = state.iteration;
final log = ref.watch(gymLogProvider);
// Mark done sets
final decorationStyle = slotEntryPage.logDone
@@ -101,8 +80,9 @@ class _LogPageState extends ConsumerState<LogPage> {
return Column(
children: [
NavigationHeader(
log.exercise.getTranslation(Localizations.localeOf(context).languageCode).name,
widget._controller,
log!.exercise.getTranslation(languageCode).name,
_controller,
key: const ValueKey('log-page-navigation-header'),
),
Container(
@@ -148,16 +128,9 @@ class _LogPageState extends ConsumerState<LogPage> {
Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center),
const SizedBox(height: 10),
Expanded(
child: (state.routine.filterLogsByExercise(log.exercise.id!).isNotEmpty)
child: (gymState.routine.filterLogsByExercise(log.exerciseId).isNotEmpty)
? LogsPastLogsWidget(
log: log,
pastLogs: state.routine.filterLogsByExercise(log.exercise.id!),
onCopy: (pastLog) {
_logFormKey.currentState?.copyFromPastLog(pastLog);
},
setStateCallback: (fn) {
setState(fn);
},
pastLogs: gymState.routine.filterLogsByExercise(log.exerciseId),
)
: Container(),
),
@@ -170,16 +143,15 @@ class _LogPageState extends ConsumerState<LogPage> {
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: LogFormWidget(
key: _logFormKey,
controller: widget._controller,
controller: _controller,
configData: setConfigData,
log: log,
focusNode: focusNode,
// log: log!,
key: _logFormKey,
),
),
),
),
NavigationFooter(widget._controller),
NavigationFooter(_controller),
],
);
}
@@ -239,68 +211,62 @@ class LogsPlatesWidget extends ConsumerWidget {
}
}
class LogsRepsWidget extends StatelessWidget {
final TextEditingController controller;
final SetConfigData configData;
final FocusNode focusNode;
final Log log;
final void Function(VoidCallback fn) setStateCallback;
class LogsRepsWidget extends ConsumerWidget {
final _logger = Logger('LogsRepsWidget');
final num valueChange;
LogsRepsWidget({
super.key,
required this.controller,
required this.configData,
required this.focusNode,
required this.log,
required this.setStateCallback,
});
num? valueChange,
}) : valueChange = valueChange ?? 1;
@override
Widget build(BuildContext context) {
final repsValueChange = configData.repetitionsRounding ?? 1;
Widget build(BuildContext context, WidgetRef ref) {
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final i18n = AppLocalizations.of(context);
final logNotifier = ref.read(gymLogProvider.notifier);
final log = ref.watch(gymLogProvider);
final currentReps = log?.repetitions;
final repText = currentReps != null ? numberFormat.format(currentReps) : '';
return Row(
children: [
// "Quick-remove" button
IconButton(
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
final currentValue = numberFormat.tryParse(controller.text) ?? 0;
final newValue = currentValue - repsValueChange;
if (newValue >= 0) {
setStateCallback(() {
log.repetitions = newValue;
controller.text = numberFormat.format(newValue);
});
final base = currentReps ?? 0;
final newValue = base - valueChange;
if (newValue >= 0 && log != null) {
logNotifier.setRepetitions(newValue);
}
},
),
// Text field
Expanded(
child: TextFormField(
decoration: InputDecoration(labelText: i18n.repetitions),
enabled: true,
controller: controller,
key: ValueKey('reps-field-$repText'),
initialValue: repText,
keyboardType: textInputTypeDecimal,
focusNode: focusNode,
onChanged: (value) {
try {
final newValue = numberFormat.parse(value);
setStateCallback(() {
log.repetitions = newValue;
});
logNotifier.setRepetitions(newValue);
} on FormatException catch (error) {
_logger.fine('Error parsing repetitions: $error');
_logger.finer('Error parsing repetitions: $error');
}
},
onSaved: (newValue) {
_logger.info('Saving new reps value: $newValue');
setStateCallback(() {
log.repetitions = numberFormat.parse(newValue!);
});
if (newValue == null || log == null) {
return;
}
logNotifier.setRepetitions(numberFormat.parse(newValue));
},
validator: (value) {
if (numberFormat.tryParse(value ?? '') == null) {
@@ -310,19 +276,15 @@ class LogsRepsWidget extends StatelessWidget {
},
),
),
// "Quick-add" button
IconButton(
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final value = controller.text.isNotEmpty ? controller.text : '0';
try {
final newValue = numberFormat.parse(value) + repsValueChange;
setStateCallback(() {
log.repetitions = newValue;
controller.text = numberFormat.format(newValue);
});
} on FormatException catch (error) {
_logger.fine('Error parsing reps during quick-add: $error');
final base = currentReps ?? 0;
final newValue = base + valueChange;
if (newValue >= 0 && log != null) {
logNotifier.setRepetitions(newValue);
}
},
),
@@ -332,76 +294,62 @@ class LogsRepsWidget extends StatelessWidget {
}
class LogsWeightWidget extends ConsumerWidget {
final TextEditingController controller;
final SetConfigData configData;
final FocusNode focusNode;
final Log log;
final void Function(VoidCallback fn) setStateCallback;
final _logger = Logger('LogsWeightWidget');
final num valueChange;
LogsWeightWidget({
super.key,
required this.controller,
required this.configData,
required this.focusNode,
required this.log,
required this.setStateCallback,
});
num? valueChange,
}) : valueChange = valueChange ?? 1.25;
@override
Widget build(BuildContext context, WidgetRef ref) {
final weightValueChange = configData.weightRounding ?? 1.25;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final i18n = AppLocalizations.of(context);
final plateProvider = ref.read(plateCalculatorProvider.notifier);
final logProvider = ref.read(gymLogProvider.notifier);
final log = ref.watch(gymLogProvider);
final currentWeight = log?.weight;
final weightText = currentWeight != null ? numberFormat.format(currentWeight) : '';
return Row(
children: [
IconButton(
// "Quick-remove" button
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
try {
final newValue = numberFormat.parse(controller.text) - weightValueChange;
if (newValue > 0) {
setStateCallback(() {
log.weight = newValue;
controller.text = numberFormat.format(newValue);
ref
.read(plateCalculatorProvider.notifier)
.setWeight(
controller.text == '' ? 0 : newValue,
);
});
}
} on FormatException catch (error) {
_logger.fine('Error parsing weight during quick-remove: $error');
final base = currentWeight ?? 0;
final newValue = base - valueChange;
if (newValue >= 0 && log != null) {
logProvider.setWeight(newValue);
}
},
),
// Text field
Expanded(
child: TextFormField(
key: ValueKey('weight-field-$weightText'),
decoration: InputDecoration(labelText: i18n.weight),
controller: controller,
initialValue: weightText,
keyboardType: textInputTypeDecimal,
onChanged: (value) {
try {
final newValue = numberFormat.parse(value);
setStateCallback(() {
log.weight = newValue;
ref
.read(plateCalculatorProvider.notifier)
.setWeight(
controller.text == '' ? 0 : numberFormat.parse(controller.text),
);
});
plateProvider.setWeight(newValue);
logProvider.setWeight(newValue);
} on FormatException catch (error) {
_logger.fine('Error parsing weight: $error');
_logger.finer('Error parsing weight: $error');
}
},
onSaved: (newValue) {
setStateCallback(() {
log.weight = numberFormat.parse(newValue!);
});
if (newValue == null || log == null) {
return;
}
logProvider.setWeight(numberFormat.parse(newValue));
},
validator: (value) {
if (numberFormat.tryParse(value ?? '') == null) {
@@ -411,24 +359,15 @@ class LogsWeightWidget extends ConsumerWidget {
},
),
),
// "Quick-add" button
IconButton(
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
final value = controller.text.isNotEmpty ? controller.text : '0';
try {
final newValue = numberFormat.parse(value) + weightValueChange;
setStateCallback(() {
log.weight = newValue;
controller.text = numberFormat.format(newValue);
ref
.read(plateCalculatorProvider.notifier)
.setWeight(
controller.text == '' ? 0 : newValue,
);
});
} on FormatException catch (error) {
_logger.fine('Error parsing weight during quick-add: $error');
final base = currentWeight ?? 0;
final newValue = base + valueChange;
if (log != null) {
logProvider.setWeight(newValue);
}
},
),
@@ -437,22 +376,19 @@ class LogsWeightWidget extends ConsumerWidget {
}
}
class LogsPastLogsWidget extends StatelessWidget {
final Log log;
class LogsPastLogsWidget extends ConsumerWidget {
final List<Log> pastLogs;
final void Function(Log pastLog) onCopy;
final void Function(VoidCallback fn) setStateCallback;
const LogsPastLogsWidget({
super.key,
required this.log,
required this.pastLogs,
required this.onCopy,
required this.setStateCallback,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final logProvider = ref.read(gymLogProvider.notifier);
final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode);
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView(
@@ -466,25 +402,16 @@ class LogsPastLogsWidget extends StatelessWidget {
return ListTile(
key: ValueKey('past-log-${pastLog.id}'),
title: Text(pastLog.repTextNoNl(context)),
subtitle: Text(
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(pastLog.date),
),
subtitle: Text(dateFormat.format(pastLog.date)),
trailing: const Icon(Icons.copy),
onTap: () {
setStateCallback(() {
log.rir = pastLog.rir;
log.repetitionUnit = pastLog.repetitionsUnitObj;
log.weightUnit = pastLog.weightUnitObj;
onCopy(pastLog);
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).dataCopied),
),
);
});
logProvider.setLog(pastLog);
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).dataCopied),
),
);
},
contentPadding: const EdgeInsets.symmetric(horizontal: 40),
);
@@ -500,15 +427,11 @@ class LogFormWidget extends ConsumerStatefulWidget {
final PageController controller;
final SetConfigData configData;
final Log log;
final FocusNode focusNode;
LogFormWidget({
super.key,
required this.controller,
required this.configData,
required this.log,
required this.focusNode,
});
@override
@@ -519,61 +442,11 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
final _form = GlobalKey<FormState>();
var _detailed = false;
bool _isSaving = false;
late Log _log;
late final TextEditingController _repetitionsController;
late final TextEditingController _weightController;
@override
void initState() {
super.initState();
_log = widget.log;
_repetitionsController = TextEditingController();
_weightController = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
final locale = Localizations.localeOf(context).toString();
final numberFormat = NumberFormat.decimalPattern(locale);
if (widget.configData.repetitions != null) {
_repetitionsController.text = numberFormat.format(widget.configData.repetitions);
}
if (widget.configData.weight != null) {
_weightController.text = numberFormat.format(widget.configData.weight);
}
});
}
@override
void dispose() {
_repetitionsController.dispose();
_weightController.dispose();
super.dispose();
}
void copyFromPastLog(Log pastLog) {
final locale = Localizations.localeOf(context).toString();
final numberFormat = NumberFormat.decimalPattern(locale);
setState(() {
_repetitionsController.text = pastLog.repetitions != null
? numberFormat.format(pastLog.repetitions)
: '';
widget._logger.finer('Setting log repetitions to ${_repetitionsController.text}');
_weightController.text = pastLog.weight != null ? numberFormat.format(pastLog.weight) : '';
widget._logger.finer('Setting log weight to ${_weightController.text}');
_log.rir = pastLog.rir;
widget._logger.finer('Setting log rir to ${_log.rir}');
});
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final log = ref.watch(gymLogProvider);
return Form(
key: _form,
@@ -590,25 +463,15 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
children: [
Flexible(
child: LogsRepsWidget(
controller: _repetitionsController,
configData: widget.configData,
focusNode: widget.focusNode,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
key: const ValueKey('logs-reps-widget'),
valueChange: widget.configData.repetitionsRounding,
),
),
const SizedBox(width: 8),
Flexible(
child: LogsWeightWidget(
controller: _weightController,
configData: widget.configData,
focusNode: widget.focusNode,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
key: const ValueKey('logs-weight-widget'),
valueChange: widget.configData.weightRounding,
),
),
],
@@ -619,19 +482,15 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
children: [
Flexible(
child: LogsRepsWidget(
controller: _repetitionsController,
configData: widget.configData,
focusNode: widget.focusNode,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
key: const ValueKey('logs-reps-widget'),
valueChange: widget.configData.repetitionsRounding,
),
),
const SizedBox(width: 8),
Flexible(
child: RepetitionUnitInputWidget(
_log.repetitionsUnitId,
key: const ValueKey('repetition-unit-input-widget'),
log!.repetitionsUnitId,
onChanged: (v) => {},
),
),
@@ -644,34 +503,31 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
children: [
Flexible(
child: LogsWeightWidget(
controller: _weightController,
configData: widget.configData,
focusNode: widget.focusNode,
log: _log,
setStateCallback: (fn) {
setState(fn);
},
key: const ValueKey('logs-weight-widget'),
valueChange: widget.configData.weightRounding,
),
),
const SizedBox(width: 8),
Flexible(
child: WeightUnitInputWidget(_log.weightUnitId, onChanged: (v) => {}),
child: WeightUnitInputWidget(
log!.weightUnitId,
onChanged: (v) => {},
key: const ValueKey('weight-unit-input-widget'),
),
),
const SizedBox(width: 8),
],
),
if (_detailed)
RiRInputWidget(
_log.rir,
key: const ValueKey('rir-input-widget'),
log!.rir,
onChanged: (value) {
if (value == '') {
_log.rir = null;
} else {
_log.rir = num.parse(value);
}
log.rir = value == '' ? null : num.parse(value);
},
),
SwitchListTile(
key: const ValueKey('units-switch'),
dense: true,
title: Text(i18n.setUnitsAndRir),
value: _detailed,
@@ -682,6 +538,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
},
),
FilledButton(
key: const ValueKey('save-log-button'),
onPressed: _isSaving
? null
: () async {
@@ -699,7 +556,7 @@ class _LogFormWidgetState extends ConsumerState<LogFormWidget> {
await provider.Provider.of<RoutinesProvider>(
context,
listen: false,
).addLog(_log);
).addLog(log!);
final page = gymState.getSlotEntryPageByIndex()!;
gymProvider.markSlotPageAsDone(page.uuid, isDone: true);

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* 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.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* 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.
@@ -28,7 +28,12 @@ class NavigationHeader extends StatelessWidget {
final String _title;
final bool showEndWorkoutButton;
const NavigationHeader(this._title, this._controller, {this.showEndWorkoutButton = true});
const NavigationHeader(
this._title,
this._controller, {
this.showEndWorkoutButton = true,
super.key,
});
@override
Widget build(BuildContext context) {

View File

@@ -1,13 +1,13 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2025 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
* 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,
* 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.
@@ -15,59 +15,96 @@
* 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:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/forms/session.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
class SessionPage extends ConsumerWidget {
class SessionPage extends ConsumerStatefulWidget {
final PageController _controller;
const SessionPage(this._controller);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gymStateProvider);
ConsumerState<SessionPage> createState() => _SessionPageState();
}
final session = state.routine.sessions
.map((sessionApi) => sessionApi.session)
.firstWhere(
(session) => session.date.isSameDayAs(clock.now()),
orElse: () => WorkoutSession(
dayId: state.dayId,
routineId: state.routine.id,
impression: DEFAULT_IMPRESSION,
date: clock.now(),
timeStart: state.startTime,
timeEnd: TimeOfDay.fromDateTime(clock.now()),
),
);
class _SessionPageState extends ConsumerState<SessionPage> {
late Future<void> _initData;
late Routine _routine;
@override
void initState() {
super.initState();
_initData = _reloadRoutineData();
}
Future<void> _reloadRoutineData() async {
final gymState = ref.read(gymStateProvider);
_routine = await context.read<RoutinesProvider>().fetchAndSetRoutineFull(gymState.routine.id!);
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final gymState = ref.read(gymStateProvider);
return Column(
children: [
NavigationHeader(
AppLocalizations.of(context).workoutSession,
_controller,
),
Expanded(child: Container()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: SessionForm(
state.routine.id,
onSaved: () => _controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
),
session: session,
NavigationHeader(i18n.workoutSession, widget._controller),
Expanded(
child: FutureBuilder<void>(
future: _initData,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else {
final session = _routine.sessions
.map((sessionApi) => sessionApi.session)
.firstWhere(
(s) => s.date.isSameDayAs(clock.now()),
orElse: () => WorkoutSession(
dayId: gymState.dayId,
date: clock.now(),
routineId: gymState.routine.id,
timeStart: gymState.startTime,
timeEnd: TimeOfDay.fromDateTime(clock.now()),
),
);
return Column(
children: [
Expanded(child: Container()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: SessionForm(
gymState.routine.id,
onSaved: () => widget._controller.nextPage(
duration: DEFAULT_ANIMATION_DURATION,
curve: DEFAULT_ANIMATION_CURVE,
),
session: session,
),
),
],
);
}
},
),
),
NavigationFooter(_controller),
NavigationFooter(widget._controller),
],
);
}

View File

@@ -13,10 +13,10 @@ packages:
dependency: transitive
description:
name: analyzer
sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "8.4.0"
version: "8.4.1"
analyzer_buffer:
dependency: transitive
description:
@@ -25,14 +25,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.11"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53"
url: "https://pub.dev"
source: hosted
version: "0.13.10"
archive:
dependency: transitive
description:
@@ -69,10 +61,10 @@ packages:
dependency: transitive
description:
name: build
sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
url: "https://pub.dev"
source: hosted
version: "4.0.3"
version: "4.0.4"
build_config:
dependency: transitive
description:
@@ -93,10 +85,10 @@ packages:
dependency: "direct dev"
description:
name: build_runner
sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057"
sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072
url: "https://pub.dev"
source: hosted
version: "2.10.4"
version: "2.10.5"
built_collection:
dependency: transitive
description:
@@ -109,10 +101,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
sha256: "120df83d4a4ce6bed06ad653c0a3e85737e0f66664f31e17a55136ff5a738cde"
url: "https://pub.dev"
source: hosted
version: "8.12.1"
version: "8.12.2"
camera:
dependency: transitive
description:
@@ -125,18 +117,18 @@ packages:
dependency: transitive
description:
name: camera_android_camerax
sha256: "1f1d1ff65223c59018d58bdac5211417c2af60bcb469c9d26f928dd412eb91cf"
sha256: bc7a96998258adddd0b653dd693b0874537707d58b0489708f2a646e4f124246
url: "https://pub.dev"
source: hosted
version: "0.6.24+3"
version: "0.6.27"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "035b90c1e33c2efad7548f402572078f6e514d4f82be0a315cd6c6af7e855aa8"
sha256: "087a9fadef20325cb246b4c13344a3ce8e408acfc3e0c665ebff0ec3144d7163"
url: "https://pub.dev"
source: hosted
version: "0.9.22+6"
version: "0.9.22+8"
camera_platform_interface:
dependency: transitive
description:
@@ -149,10 +141,10 @@ packages:
dependency: transitive
description:
name: camera_web
sha256: "77e53acb64d9de8917424eeb32b5c7c73572d1e00954bbf54a1e609d79a751a2"
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
version: "0.3.5+3"
carousel_slider:
dependency: "direct main"
description:
@@ -197,10 +189,10 @@ packages:
dependency: "direct dev"
description:
name: cider
sha256: dfff70e9324f99e315857c596c31f54cb7380cfa20dfdfdca11a3631e05b7d3e
sha256: "455e3549bd1d21708326985702703345245acd3d7a2ac485de4183affb414a2c"
url: "https://pub.dev"
source: hosted
version: "0.2.8"
version: "0.2.9"
cli_config:
dependency: transitive
description:
@@ -229,10 +221,10 @@ packages:
dependency: transitive
description:
name: code_builder
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.0"
version: "4.11.1"
collection:
dependency: "direct main"
description:
@@ -289,22 +281,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
custom_lint_visitor:
dependency: transitive
description:
name: custom_lint_visitor
sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+8.4.0"
dart_style:
dependency: transitive
description:
@@ -317,26 +293,26 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "83290a32ae006a7535c5ecf300722cb77177250d9df4ee2becc5fa8a36095114"
sha256: "5ea2f718558c0b31d4b8c36a3d8e5b7016f1265f46ceb5a5920e16117f0c0d6a"
url: "https://pub.dev"
source: hosted
version: "2.29.0"
version: "2.30.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "6019f827544e77524ffd5134ae0cb75dfd92ef5ef3e269872af92840c929cd43"
sha256: "892dfb5d69d9e604bdcd102a9376de8b41768cf7be93fd26b63cfc4d8f91ad5f"
url: "https://pub.dev"
source: hosted
version: "2.29.0"
version: "2.30.1"
equatable:
dependency: "direct main"
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
version: "2.0.8"
fake_async:
dependency: transitive
description:
@@ -349,10 +325,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
file:
dependency: transitive
description:
@@ -516,10 +492,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde"
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.1.0"
flutter_staggered_grid_view:
dependency: "direct main"
description:
@@ -667,10 +643,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
version: "4.7.2"
image_picker:
dependency: "direct main"
description:
@@ -699,10 +675,10 @@ packages:
dependency: transitive
description:
name: image_picker_ios
sha256: "997d100ce1dda5b1ba4085194c5e36c9f8a1fb7987f6a36ab677a344cd2dc986"
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
url: "https://pub.dev"
source: hosted
version: "0.8.13+2"
version: "0.8.13+3"
image_picker_linux:
dependency: transitive
description:
@@ -880,10 +856,10 @@ packages:
dependency: "direct dev"
description:
name: mockito
sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e
sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566
url: "https://pub.dev"
source: hosted
version: "5.6.1"
version: "5.6.3"
multi_select_flutter:
dependency: "direct main"
description:
@@ -976,10 +952,10 @@ packages:
dependency: transitive
description:
name: path_provider_foundation
sha256: "97390a0719146c7c3e71b6866c34f1cde92685933165c1c671984390d2aca776"
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
@@ -1144,50 +1120,50 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.1.0"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: a0f68adb078b790faa3c655110a017f9a7b7b079a57bbd40f540e80dce5fcd29
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.7"
version: "1.0.0-dev.8"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: "7230014155777fc31ba3351bc2cb5a3b5717b11bfafe52b1553cb47d385f8897"
sha256: cc1474bc2df55ec3c1da1989d139dcef22cd5e2bd78da382e867a69a8eca2e46
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "4.0.0"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "49894543a42cf7a9954fc4e7366b6d3cb2e6ec0fa07775f660afcdd92d097702"
sha256: e43b1537229cc8f487f09b0c20d15dba840acbadcf5fc6dad7ad5e8ab75950dc
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "4.0.0+1"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.3"
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
url: "https://pub.dev"
source: hosted
version: "2.4.17"
version: "2.4.18"
shared_preferences_foundation:
dependency: transitive
description:
@@ -1333,10 +1309,10 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: "54eea43e36dd3769274c3108625f9ea1a382f8d2ac8b16f3e4589d9bd9b0e16c"
sha256: f52f5d5649dcc13ed198c4176ddef74bf6851c30f4f31603f1b37788695b93e2
url: "https://pub.dev"
source: hosted
version: "0.42.0"
version: "0.43.0"
stack_trace:
dependency: transitive
description:
@@ -1497,14 +1473,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_graphics:
dependency: transitive
description:
@@ -1565,18 +1533,18 @@ packages:
dependency: transitive
description:
name: video_player_android
sha256: "3f7ef3fb7b29f510e58f4d56b6ffbc3463b1071f2cf56e10f8d25f5b991ed85b"
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
url: "https://pub.dev"
source: hosted
version: "2.8.21"
version: "2.9.1"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "6bced1739cf1f96f03058118adb8ac0dd6f96aa1a1a6e526424ab92fd2a6a77d"
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
url: "https://pub.dev"
source: hosted
version: "2.8.7"
version: "2.8.9"
video_player_platform_interface:
dependency: transitive
description:
@@ -1605,10 +1573,10 @@ packages:
dependency: transitive
description:
name: watcher
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.1.4"
version: "1.2.1"
web:
dependency: transitive
description:

View File

@@ -21,7 +21,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# - the version number is taken from the git tag vX.Y.Z
# - the build number is computed by reading the last one from the play store
# and increasing by one
version: 1.9.2+120
version: 1.9.5+150
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -36,8 +36,8 @@ dependencies:
clock: ^1.1.2
collection: ^1.18.0
cupertino_icons: ^1.0.8
drift: ^2.29.0
equatable: ^2.0.7
drift: ^2.30.0
equatable: ^2.0.8
fl_chart: ^1.1.1
flex_color_scheme: ^8.3.1
flutter_html: ^3.0.0
@@ -66,8 +66,8 @@ dependencies:
version: ^3.0.2
video_player: ^2.10.1
logging: ^1.3.0
flutter_riverpod: ^3.0.3
riverpod_annotation: ^3.0.3
flutter_riverpod: ^3.1.0
riverpod_annotation: ^4.0.0
dev_dependencies:
flutter_test:
@@ -75,7 +75,7 @@ dev_dependencies:
integration_test:
sdk: flutter
build_runner: ^2.10.4
cider: ^0.2.7
cider: ^0.2.9
drift_dev: ^2.29.0
flutter_lints: ^6.0.0
freezed: ^3.2.0
@@ -83,7 +83,7 @@ dev_dependencies:
mockito: ^5.4.4
network_image_mock: ^2.1.1
shared_preferences_platform_interface: ^2.0.0
riverpod_generator: ^3.0.3
riverpod_generator: ^4.0.0+1
# Script to read out unused translations
#translations_cleaner: ^0.0.5

View File

@@ -0,0 +1,107 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020 - 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 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:wger/core/exceptions/http_exception.dart';
void main() {
group('WgerHttpException', () {
test('parses valid JSON response', () {
// Arrange
final resp = http.Response(
'{"foo":"bar"}',
400,
headers: {HttpHeaders.contentTypeHeader: 'application/json'},
);
// Act
final ex = WgerHttpException(resp);
// Assert
expect(ex.type, ErrorType.json);
expect(ex.errors['foo'], 'bar');
expect(ex.toString(), contains('WgerHttpException'));
});
test('falls back on malformed JSON', () {
// Arrange
const body = '{"foo":';
final resp = http.Response(
body,
500,
headers: {HttpHeaders.contentTypeHeader: 'application/json'},
);
// Act
final ex = WgerHttpException(resp);
// Assert
expect(ex.type, ErrorType.json);
expect(ex.errors['unknown_error'], body);
});
test('detects HTML response from headers', () {
// Arrange
const body = '<html lang="en"><body>Error</body></html>';
final resp = http.Response(
body,
500,
headers: {HttpHeaders.contentTypeHeader: 'text/html; charset=utf-8'},
);
// Act
final ex = WgerHttpException(resp);
// Assert
expect(ex.type, ErrorType.html);
expect(ex.htmlError, body);
});
test('detects HTML response from content', () {
// Arrange
const body = '<html lang="en"><body>Error</body></html>';
final resp = http.Response(
body,
500,
headers: {HttpHeaders.contentTypeHeader: 'text/foo; charset=utf-8'},
);
// Act
final ex = WgerHttpException(resp);
// Assert
expect(ex.type, ErrorType.html);
expect(ex.htmlError, body);
});
test('fromMap sets errors and type', () {
// Arrange
final map = <String, dynamic>{'field': 'value'};
// Act
final ex = WgerHttpException.fromMap(map);
// Assert
expect(ex.type, ErrorType.json);
expect(ex.errors, map);
});
});
}

View File

@@ -1100,9 +1100,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider {
as Uri);
@override
_i18.Future<dynamic> fetch(Uri? uri) =>
_i18.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i18.Future<dynamic>.value(),
)
as _i18.Future<dynamic>);

View File

@@ -20,6 +20,7 @@ import 'package:wger/l10n/generated/app_localizations.dart' as _i2;
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
/// A class which mocks [AppLocalizations].
///
@@ -919,6 +920,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String get impressionGood =>
(super.noSuchMethod(
Invocation.getter(#impressionGood),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#impressionGood),
),
)
as String);
@override
String get impressionNeutral =>
(super.noSuchMethod(
Invocation.getter(#impressionNeutral),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#impressionNeutral),
),
)
as String);
@override
String get impressionBad =>
(super.noSuchMethod(
Invocation.getter(#impressionBad),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#impressionBad),
),
)
as String);
@override
String get impression =>
(super.noSuchMethod(
@@ -1095,6 +1129,105 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String get gymModeTimerType =>
(super.noSuchMethod(
Invocation.getter(#gymModeTimerType),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#gymModeTimerType),
),
)
as String);
@override
String get gymModeTimerTypeHelText =>
(super.noSuchMethod(
Invocation.getter(#gymModeTimerTypeHelText),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#gymModeTimerTypeHelText),
),
)
as String);
@override
String get countdown =>
(super.noSuchMethod(
Invocation.getter(#countdown),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#countdown),
),
)
as String);
@override
String get stopwatch =>
(super.noSuchMethod(
Invocation.getter(#stopwatch),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#stopwatch),
),
)
as String);
@override
String get gymModeDefaultCountdownTime =>
(super.noSuchMethod(
Invocation.getter(#gymModeDefaultCountdownTime),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#gymModeDefaultCountdownTime),
),
)
as String);
@override
String get gymModeNotifyOnCountdownFinish =>
(super.noSuchMethod(
Invocation.getter(#gymModeNotifyOnCountdownFinish),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#gymModeNotifyOnCountdownFinish),
),
)
as String);
@override
String get duration =>
(super.noSuchMethod(
Invocation.getter(#duration),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#duration),
),
)
as String);
@override
String get volume =>
(super.noSuchMethod(
Invocation.getter(#volume),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#volume),
),
)
as String);
@override
String get workoutCompleted =>
(super.noSuchMethod(
Invocation.getter(#workoutCompleted),
returnValue: _i3.dummyValue<String>(
this,
Invocation.getter(#workoutCompleted),
),
)
as String);
@override
String get plateCalculator =>
(super.noSuchMethod(
@@ -3677,6 +3810,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String durationHoursMinutes(int? hours, int? minutes) =>
(super.noSuchMethod(
Invocation.method(#durationHoursMinutes, [hours, minutes]),
returnValue: _i3.dummyValue<String>(
this,
Invocation.method(#durationHoursMinutes, [hours, minutes]),
),
)
as String);
@override
String chartAllTimeTitle(String? name) =>
(super.noSuchMethod(
@@ -3765,6 +3909,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations {
)
as String);
@override
String formMinMaxValues(int? min, int? max) =>
(super.noSuchMethod(
Invocation.method(#formMinMaxValues, [min, max]),
returnValue: _i3.dummyValue<String>(
this,
Invocation.method(#formMinMaxValues, [min, max]),
),
)
as String);
@override
String enterMinCharacters(String? min) =>
(super.noSuchMethod(

View File

@@ -402,9 +402,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider {
as Uri);
@override
_i14.Future<dynamic> fetch(Uri? uri) =>
_i14.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i14.Future<dynamic>.value(),
)
as _i14.Future<dynamic>);

View File

@@ -21,7 +21,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/add_exercise.dart';
import 'package:wger/providers/exercises.dart';
@@ -422,7 +422,7 @@ void main() {
testWidgets('Failed submission displays error message', (WidgetTester tester) async {
// Setup: Create verified user and mock failed submission
setupFullVerifiedUserContext();
final httpException = WgerHttpException({
final httpException = WgerHttpException.fromMap({
'name': ['This field is required'],
});
when(mockAddExerciseProvider.postExerciseToServer()).thenThrow(httpException);

View File

@@ -1,3 +1,21 @@
/*
* 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 'dart:convert';
import 'package:drift/native.dart';
@@ -100,19 +118,25 @@ void main() {
SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty();
// Mock categories
when(mockBaseProvider.makeUrl(categoryUrl)).thenReturn(tCategoryEntriesUri);
when(
mockBaseProvider.makeUrl(categoryUrl, query: anyNamed('query')),
).thenReturn(tCategoryEntriesUri);
when(
mockBaseProvider.fetchPaginated(tCategoryEntriesUri),
).thenAnswer((_) => Future.value(tCategoryMap['results']));
// Mock muscles
when(mockBaseProvider.makeUrl(muscleUrl)).thenReturn(tMuscleEntriesUri);
when(
mockBaseProvider.makeUrl(muscleUrl, query: anyNamed('query')),
).thenReturn(tMuscleEntriesUri);
when(
mockBaseProvider.fetchPaginated(tMuscleEntriesUri),
).thenAnswer((_) => Future.value(tMuscleMap['results']));
// Mock equipment
when(mockBaseProvider.makeUrl(equipmentUrl)).thenReturn(tEquipmentEntriesUri);
when(
mockBaseProvider.makeUrl(equipmentUrl, query: anyNamed('query')),
).thenReturn(tEquipmentEntriesUri);
when(
mockBaseProvider.fetchPaginated(tEquipmentEntriesUri),
).thenAnswer((_) => Future.value(tEquipmentMap['results']));

View File

@@ -1,3 +1,21 @@
/*
* 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 'dart:convert';
import 'package:drift/drift.dart';
@@ -7,8 +25,8 @@ import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/database/exercises/exercise_database.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/shared_preferences.dart';
import 'package:wger/models/exercises/category.dart';
@@ -105,19 +123,25 @@ void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
// Mock categories
when(mockBaseProvider.makeUrl(categoryUrl)).thenReturn(tCategoryEntriesUri);
when(
mockBaseProvider.makeUrl(categoryUrl, query: anyNamed('query')),
).thenReturn(tCategoryEntriesUri);
when(
mockBaseProvider.fetchPaginated(tCategoryEntriesUri),
).thenAnswer((_) => Future.value(tCategoryMap['results']));
// Mock muscles
when(mockBaseProvider.makeUrl(muscleUrl)).thenReturn(tMuscleEntriesUri);
when(
mockBaseProvider.makeUrl(muscleUrl, query: anyNamed('query')),
).thenReturn(tMuscleEntriesUri);
when(
mockBaseProvider.fetchPaginated(tMuscleEntriesUri),
).thenAnswer((_) => Future.value(tMuscleMap['results']));
// Mock equipment
when(mockBaseProvider.makeUrl(equipmentUrl)).thenReturn(tEquipmentEntriesUri);
when(
mockBaseProvider.makeUrl(equipmentUrl, query: anyNamed('query')),
).thenReturn(tEquipmentEntriesUri);
when(
mockBaseProvider.fetchPaginated(tEquipmentEntriesUri),
).thenAnswer((_) => Future.value(tEquipmentMap['results']));

View File

@@ -175,9 +175,17 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider {
as Uri);
@override
_i6.Future<dynamic> fetch(Uri? uri) =>
_i6.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i6.Future<dynamic>.value(),
)
as _i6.Future<dynamic>);

View File

@@ -175,9 +175,17 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider {
as Uri);
@override
_i6.Future<dynamic> fetch(Uri? uri) =>
_i6.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i6.Future<dynamic>.value(),
)
as _i6.Future<dynamic>);

View File

@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/models/measurements/measurement_entry.dart';

View File

@@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/exceptions/no_such_entry_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/no_such_entry_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/models/measurements/measurement_entry.dart';
@@ -271,7 +271,7 @@ void main() {
'should re-add the "removed" MeasurementCategory and relay the exception on WgerHttpException',
() {
// arrange
when(mockWgerBaseProvider.deleteRequest(any, any)).thenThrow(WgerHttpException('{}'));
when(mockWgerBaseProvider.deleteRequest(any, any)).thenThrow(WgerHttpException.fromMap({}));
// act & assert
expect(
@@ -330,7 +330,7 @@ void main() {
test('should keep categories list as is on WgerHttpException', () {
// arrange
when(mockWgerBaseProvider.patch(any, any)).thenThrow(WgerHttpException('{}'));
when(mockWgerBaseProvider.patch(any, any)).thenThrow(WgerHttpException.fromMap({}));
// act & assert
expect(
@@ -550,7 +550,7 @@ void main() {
),
const MeasurementCategory(id: 2, name: 'Biceps', unit: 'cm'),
];
when(mockWgerBaseProvider.deleteRequest(any, any)).thenThrow(WgerHttpException('{}'));
when(mockWgerBaseProvider.deleteRequest(any, any)).thenThrow(WgerHttpException.fromMap({}));
// act & assert
expect(

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -122,9 +122,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -357,9 +357,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i8.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -0,0 +1,135 @@
/*
* 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 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/providers/base_provider.dart';
import '../utils.dart';
import 'base_provider.mocks.dart';
@GenerateMocks([Client])
void main() {
final Uri testUri = Uri(scheme: 'https', host: 'localhost', path: 'api/v2/test/');
test('Retry on SocketException then succeeds', () async {
// Arrange
final mockClient = MockClient();
var callCount = 0;
when(mockClient.get(testUri, headers: anyNamed('headers'))).thenAnswer((_) {
if (callCount == 0) {
callCount++;
return Future.error(const SocketException('conn fail'));
}
return Future.value(Response('{"ok": true}', 200));
});
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(2);
});
test('Retry on 5xx then succeeds', () async {
// Arrange
final mockClient = MockClient();
var callCount = 0;
when(mockClient.get(testUri, headers: anyNamed('headers'))).thenAnswer((_) {
if (callCount == 0) {
callCount++;
return Future.value(Response('{"msg":"error"}', 502));
}
return Future.value(Response('{"ok": true}', 200));
});
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(2);
});
test('Do not retry on 4xx client error', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.value(Response('{"error":"bad"}', 400)));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
// Assert
await expectLater(
provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1)),
throwsA(isA<WgerHttpException>()),
);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(1);
});
test('Exceed max retries and rethrow after retries', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.error(ClientException('conn fail')));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
dynamic caught;
try {
await provider.fetch(testUri, initialDelay: const Duration(milliseconds: 1));
} catch (e) {
caught = e;
}
// Assert
expect(caught, isA<ClientException>());
// initial try + 3 retries = 4 calls
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(4);
});
test('Request succeeds without retries', () async {
// Arrange
final mockClient = MockClient();
when(
mockClient.get(testUri, headers: anyNamed('headers')),
).thenAnswer((_) => Future.value(Response('{"ok": true}', 200)));
// Act
final provider = WgerBaseProvider(testAuthProvider, mockClient);
final result = await provider.fetch(testUri);
// Assert
expect(result, isA<Map>());
expect(result['ok'], isTrue);
verify(mockClient.get(testUri, headers: anyNamed('headers'))).called(1);
});
}

View File

@@ -0,0 +1,218 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in wger/test/providers/base_provider.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:convert' as _i4;
import 'dart:typed_data' as _i6;
import 'package:http/http.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response {
_FakeResponse_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
}
class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse {
_FakeStreamedResponse_1(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
/// A class which mocks [Client].
///
/// See the documentation for Mockito's code generation for more information.
class MockClient extends _i1.Mock implements _i2.Client {
MockClient() {
_i1.throwOnMissingStub(this);
}
@override
_i3.Future<_i2.Response> head(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#head, [url], {#headers: headers}),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(#head, [url], {#headers: headers}),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> get(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#get, [url], {#headers: headers}),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(#get, [url], {#headers: headers}),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> post(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#post,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#post,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> put(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#put,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#put,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> patch(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#patch,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#patch,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<_i2.Response> delete(
Uri? url, {
Map<String, String>? headers,
Object? body,
_i4.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
#delete,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
returnValue: _i3.Future<_i2.Response>.value(
_FakeResponse_0(
this,
Invocation.method(
#delete,
[url],
{#headers: headers, #body: body, #encoding: encoding},
),
),
),
)
as _i3.Future<_i2.Response>);
@override
_i3.Future<String> read(Uri? url, {Map<String, String>? headers}) =>
(super.noSuchMethod(
Invocation.method(#read, [url], {#headers: headers}),
returnValue: _i3.Future<String>.value(
_i5.dummyValue<String>(
this,
Invocation.method(#read, [url], {#headers: headers}),
),
),
)
as _i3.Future<String>);
@override
_i3.Future<_i6.Uint8List> readBytes(
Uri? url, {
Map<String, String>? headers,
}) =>
(super.noSuchMethod(
Invocation.method(#readBytes, [url], {#headers: headers}),
returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)),
)
as _i3.Future<_i6.Uint8List>);
@override
_i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) =>
(super.noSuchMethod(
Invocation.method(#send, [request]),
returnValue: _i3.Future<_i2.StreamedResponse>.value(
_FakeStreamedResponse_1(
this,
Invocation.method(#send, [request]),
),
),
)
as _i3.Future<_i2.StreamedResponse>);
@override
void close() => super.noSuchMethod(
Invocation.method(#close, []),
returnValueForMissingStub: null,
);
}

View File

@@ -21,6 +21,7 @@ import 'package:shared_preferences/src/shared_preferences_async.dart' as _i2;
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
/// A class which mocks [SharedPreferencesAsync].
///

View File

@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/session.dart';
import 'package:wger/providers/routines.dart';

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 2025 wger Team
* Copyright (c) 2020 - 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
@@ -109,12 +109,10 @@ void main() {
await withClock(Clock.fixed(DateTime(2025, 3, 29, 14, 33)), () async {
await tester.pumpWidget(renderGymMode());
await tester.pumpAndSettle();
await tester.tap(find.byType(TextButton));
await tester.pumpAndSettle();
//await tester.ensureVisible(find.byKey(Key(key as String)));
//
// Start page
//
@@ -306,6 +304,7 @@ void main() {
expect(find.byIcon(Icons.chevron_right), findsNothing);
});
},
tags: ['golden'],
semanticsEnabled: false,
);
}

View File

@@ -201,9 +201,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i20.Future<dynamic> fetch(Uri? uri) =>
_i20.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i20.Future<dynamic>.value(),
)
as _i20.Future<dynamic>);

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020, 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
@@ -56,6 +56,9 @@ void main() {
when(mockRoutinesProvider.editSession(any)).thenAnswer(
(_) => Future.value(testRoutine.sessions[0].session),
);
when(mockRoutinesProvider.fetchAndSetRoutineFull(any)).thenAnswer(
(_) => Future.value(testRoutine),
);
});
Widget renderSessionPage({locale = 'en'}) {
@@ -85,6 +88,9 @@ void main() {
testWidgets('Test that data from session is loaded', (WidgetTester tester) async {
withClock(Clock.fixed(DateTime(2021, 5, 1)), () async {
await tester.pumpWidget(renderSessionPage());
await tester.pumpAndSettle();
debugDumpApp();
expect(find.text('10:00'), findsOneWidget);
expect(find.text('12:34'), findsOneWidget);
expect(find.text('This is a note'), findsOneWidget);
@@ -102,6 +108,7 @@ void main() {
withClock(Clock.fixed(DateTime(2021, 5, 1)), () async {
await tester.pumpWidget(renderSessionPage());
await tester.pumpAndSettle();
final startTimeField = find.byKey(const ValueKey('time-start'));
expect(startTimeField, findsOneWidget);
@@ -123,6 +130,7 @@ void main() {
// Act
await tester.pumpWidget(renderSessionPage());
await tester.pumpAndSettle();
// Assert
expect(find.text('13:35'), findsOneWidget);
@@ -134,11 +142,11 @@ void main() {
testWidgets('Test that correct data is send to server', (WidgetTester tester) async {
withClock(Clock.fixed(DateTime(2021, 5, 1)), () async {
await tester.pumpWidget(renderSessionPage());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('save-button')));
final captured =
verify(mockRoutinesProvider.editSession(captureAny)).captured.single as WorkoutSession;
print(captured);
expect(captured.id, 1);
expect(captured.impression, 3);
expect(captured.notes, equals('This is a note'));

View File

@@ -21,7 +21,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/core/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/routine.dart';

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -151,9 +151,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i11.Future<dynamic> fetch(Uri? uri) =>
_i11.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i11.Future<dynamic>.value(),
)
as _i11.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -23,6 +23,7 @@ import 'package:wger/providers/base_provider.dart' as _i4;
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeAuthProvider_0 extends _i1.SmartFake implements _i2.AuthProvider {
_FakeAuthProvider_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation);
@@ -65,14 +66,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as _i3.Client);
@override
set auth(_i2.AuthProvider? _auth) => super.noSuchMethod(
Invocation.setter(#auth, _auth),
set auth(_i2.AuthProvider? value) => super.noSuchMethod(
Invocation.setter(#auth, value),
returnValueForMissingStub: null,
);
@override
set client(_i3.Client? _client) => super.noSuchMethod(
Invocation.setter(#client, _client),
set client(_i3.Client? value) => super.noSuchMethod(
Invocation.setter(#client, value),
returnValueForMissingStub: null,
);
@@ -111,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -112,9 +112,17 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider {
as Uri);
@override
_i5.Future<dynamic> fetch(Uri? uri) =>
_i5.Future<dynamic> fetch(
Uri? uri, {
int? maxRetries = 3,
Duration? initialDelay = const Duration(milliseconds: 250),
}) =>
(super.noSuchMethod(
Invocation.method(#fetch, [uri]),
Invocation.method(
#fetch,
[uri],
{#maxRetries: maxRetries, #initialDelay: initialDelay},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);

View File

@@ -0,0 +1,241 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2025 - 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart' as provider;
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/exercises/exercise.dart';
import 'package:wger/models/workouts/day_data.dart';
import 'package:wger/models/workouts/log.dart';
import 'package:wger/models/workouts/set_config_data.dart';
import 'package:wger/models/workouts/slot_data.dart';
import 'package:wger/providers/exercises.dart';
import 'package:wger/providers/gym_state.dart';
import 'package:wger/providers/routines.dart';
import 'package:wger/widgets/routines/gym_mode/log_page.dart';
import '../../../../test_data/exercises.dart';
import '../../../../test_data/routines.dart' as testdata;
import 'log_page_test.mocks.dart';
@GenerateMocks([ExercisesProvider, RoutinesProvider])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('LogPage tests', () {
late List<Exercise> testExercises;
late ProviderContainer container;
setUp(() {
SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty();
testExercises = getTestExercises();
container = ProviderContainer.test();
});
Future<void> pumpLogPage(WidgetTester tester, {RoutinesProvider? routinesProvider}) async {
final providerValue = routinesProvider ?? MockRoutinesProvider();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: provider.ChangeNotifierProvider<RoutinesProvider>.value(
value: providerValue,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
// Provide a PageView so the PageController used by LogPage is attached
body: Builder(
builder: (context) {
final controller = PageController();
return PageView(
controller: controller,
children: [LogPage(controller)],
);
},
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('handles null values', (tester) async {
// Arrange
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
routine.dayDataGym = [
DayData(
iteration: 1,
date: DateTime(2024, 11, 01),
label: '',
day: routine.dayDataGym.first.day,
slots: [
SlotData(
isSuperset: false,
exerciseIds: [testExercises[0].id!],
setConfigs: [
SetConfigData(
exerciseId: testExercises[0].id!,
exercise: testExercises[0],
slotEntryId: 1,
nrOfSets: 1,
repetitions: null,
repetitionsUnit: null,
weight: null,
weightUnit: null,
restTime: 120,
rir: 1.5,
rpe: 8,
textRepr: '3x100kg',
),
],
),
],
),
];
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
currentPage: 2,
);
// Act
notifier.calculatePages();
notifier.setCurrentPage(2);
// Assert
expect(notifier.state.getSlotEntryPageByIndex()!.type, SlotPageType.log);
await pumpLogPage(tester);
expect(find.byType(LogPage), findsOneWidget);
});
testWidgets('renders without crashing for default slotEntryPage', (tester) async {
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
);
notifier.calculatePages();
await pumpLogPage(tester);
expect(find.byType(LogPage), findsOneWidget);
});
testWidgets('copy from past log updates form fields and shows SnackBar', (tester) async {
// Arrange
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
);
notifier.calculatePages();
notifier.setCurrentPage(2);
// Act
// Log page is at index 2
notifier.state = notifier.state.copyWith(currentPage: 2);
expect(notifier.state.getSlotEntryPageByIndex()!.type, SlotPageType.log);
await pumpLogPage(tester);
// Assert
final pastLogTile = find.byKey(const ValueKey('past-log-1'));
expect(pastLogTile, findsOneWidget);
await tester.tap(pastLogTile);
await tester.pumpAndSettle();
final editableFields = find.byType(EditableText);
expect(editableFields, findsWidgets);
// Get controller texts
final repControllerText = tester.widget<EditableText>(editableFields.at(0)).controller.text;
final weightControllerText = tester
.widget<EditableText>(editableFields.at(1))
.controller
.text;
expect(repControllerText, contains('10'));
expect(weightControllerText, contains('10'));
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('save button calls addLog on RoutinesProvider', (tester) async {
// Arrange
final notifier = container.read(gymStateProvider.notifier);
final routine = testdata.getTestRoutine();
notifier.state = notifier.state.copyWith(
dayId: routine.days.first.id,
routine: routine,
iteration: 1,
);
notifier.calculatePages();
notifier.setCurrentPage(2);
notifier.state = notifier.state.copyWith(currentPage: 2);
final mockRoutines = MockRoutinesProvider();
// Act
await pumpLogPage(tester, routinesProvider: mockRoutines);
final editableFields = find.byType(EditableText);
expect(editableFields, findsWidgets);
await tester.enterText(editableFields.at(0), '12'); // Reps
await tester.enterText(editableFields.at(1), '34'); // Weight
await tester.pumpAndSettle();
Log? capturedLog;
when(mockRoutines.addLog(any)).thenAnswer((invocation) async {
capturedLog = invocation.positionalArguments[0] as Log;
capturedLog!.id = 42;
return capturedLog!;
});
final saveButton = find.byKey(const ValueKey('save-log-button'));
expect(saveButton, findsOneWidget);
await tester.tap(saveButton);
await tester.pumpAndSettle();
// Assert
verify(mockRoutines.addLog(any)).called(1);
expect(capturedLog, isNotNull);
expect(capturedLog!.repetitions, equals(12));
expect(capturedLog!.weight, equals(34));
final currentSlotPage = notifier.state.getSlotEntryPageByIndex()!;
expect(capturedLog!.slotEntryId, equals(currentSlotPage.setConfigData!.slotEntryId));
expect(capturedLog!.routineId, equals(notifier.state.routine.id));
expect(capturedLog!.iteration, equals(notifier.state.iteration));
});
});
}

File diff suppressed because it is too large Load Diff