diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index d35d7225..61d7662b 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -7,7 +7,7 @@ jobs: name: 'iOS' runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Common flutter setup uses: ./.github/actions/flutter-common @@ -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@v4 + uses: actions/upload-artifact@v5 with: name: screenshots-ios path: fastlane/metadata/ios/**/images/iPhone 6.9/*.png @@ -56,7 +56,7 @@ jobs: device_type: androidTabletBig folder: tenInchScreenshots steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Common flutter setup uses: ./.github/actions/flutter-common @@ -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@v4 + uses: actions/upload-artifact@v5 with: name: screenshots-android-${{ matrix.device.folder }} path: fastlane/metadata/android/**/images/${{ matrix.device.folder }}/*.png @@ -126,10 +126,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download all screenshot artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: path: screenshots diff --git a/android/build.gradle b/android/build.gradle index ddb8ad0b..0069540f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -17,19 +17,19 @@ subprojects { afterEvaluate { project -> if (project.extensions.findByName("android") != null) { Integer pluginCompileSdk = project.android.compileSdk - if (pluginCompileSdk != null && pluginCompileSdk < 31) { + if (pluginCompileSdk != null && pluginCompileSdk < 34) { project.logger.error( "Warning: Overriding compileSdk version in Flutter plugin: " + project.name + " from " + pluginCompileSdk - + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." + + " to 36 (to work around https://issuetracker.google.com/issues/199180389)." + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " + project.name + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." ) project.android { - compileSdk 34 + compileSdk 36 } } } diff --git a/android/gradle.properties b/android/gradle.properties index 399dfe11..f622fd8f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx2048M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index d5ce57cb..472e5d51 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index ed68c2f2..d7f1a044 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.6.0" apply false - id "org.jetbrains.kotlin.android" version "2.1.20" apply false + id "com.android.application" version "8.13.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.21" apply false } include ":app" diff --git a/integration_test/3_gym_mode.dart b/integration_test/3_gym_mode.dart index 4b587ceb..a47e5f1b 100644 --- a/integration_test/3_gym_mode.dart +++ b/integration_test/3_gym_mode.dart @@ -1,13 +1,17 @@ +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/exercise_state.dart'; import 'package:wger/providers/exercise_state_notifier.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/screens/gym_mode.dart'; import 'package:wger/screens/routine_screen.dart'; import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/routines/gym_mode/summary.dart'; +import '../test/routine/gym_mode/gym_mode_test.mocks.dart'; import '../test_data/exercises.dart'; import '../test_data/routines.dart'; @@ -58,3 +62,59 @@ Widget createGymModeScreen({Locale? locale}) { ), ); } + +Widget createGymModeResultsScreen({String locale = 'en'}) { + final controller = PageController(initialPage: 0); + + final key = GlobalKey(); + final routine = getTestRoutine(exercises: getScreenshotExercises()); + routine.sessions.first.session.date = clock.now(); + + final mockRoutinesProvider = MockRoutinesProvider(); + final mockExerciseProvider = MockExercisesProvider(); + + when(mockRoutinesProvider.fetchAndSetRoutineFull(1)).thenAnswer((_) async => routine); + when(mockRoutinesProvider.findById(1)).thenAnswer((_) => routine); + + return riverpod.UncontrolledProviderScope( + container: riverpod.ProviderContainer.test( + overrides: [ + gymStateProvider.overrideWithValue( + GymModeState( + routine: routine, + dayId: routine.days.first.id!, + iteration: 1, + showExercisePages: true, + showTimerPages: true, + ), + ), + ], + ), + child: MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => mockRoutinesProvider, + ), + ChangeNotifierProvider( + create: (context) => mockExerciseProvider, + ), + ], + child: MaterialApp( + locale: Locale(locale), + debugShowCheckedModeBanner: false, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + navigatorKey: key, + theme: wgerLightTheme, + home: Scaffold( + body: PageView( + controller: controller, + children: [ + WorkoutSummary(controller), + ], + ), + ), + ), + ), + ); +} diff --git a/integration_test/make_screenshots_test.dart b/integration_test/make_screenshots_test.dart index 3f307ec1..97a7b999 100644 --- a/integration_test/make_screenshots_test.dart +++ b/integration_test/make_screenshots_test.dart @@ -78,14 +78,16 @@ const languages = [ 'en-US', 'es-ES', + 'fa-IR', 'fr-FR', 'hi-IN', 'hr', 'it-IT', + 'iw-IL', 'ko-KR', 'nb-NO', - 'pl-PL', + 'pl-PL', 'pt-BR', 'pt-PT', 'ru-RU', @@ -128,9 +130,19 @@ void main() { await takeScreenshot(tester, binding, language, '02 - workout detail'); }); - testWidgets('gym mode screen - $language', (WidgetTester tester) async { - await tester.pumpWidget(createGymModeScreen(locale: locale)); - await tester.tap(find.byType(TextButton)); + // testWidgets('gym mode screen - $language', (WidgetTester tester) async { + // await tester.pumpWidget(createGymModeScreen(locale: locale)); + // await tester.tap(find.byType(TextButton)); + // await tester.pumpAndSettle(); + // await tester.tap(find.byKey(const ValueKey('gym-mode-options-tile'))); + // await tester.pumpAndSettle(); + // await tester.tap(find.byKey(const ValueKey('gym-mode-option-show-exercises'))); + // await tester.pumpAndSettle(); + // await takeScreenshot(tester, binding, language, '03 - gym mode'); + // }); + + testWidgets('gym mode stats screen - $language', (WidgetTester tester) async { + await tester.pumpWidget(createGymModeResultsScreen(locale: languageCode)); await tester.pumpAndSettle(); await takeScreenshot(tester, binding, language, '03 - gym mode'); }); diff --git a/lib/helpers/i18n.dart b/lib/helpers/i18n.dart index 8a270686..4da3c04d 100644 --- a/lib/helpers/i18n.dart +++ b/lib/helpers/i18n.dart @@ -9,126 +9,127 @@ import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -String getTranslation(String value, BuildContext context) { - final logger = Logger('getTranslation'); +String getServerStringTranslation(String value, BuildContext context) { + final logger = Logger('getServerStringTranslation'); + final i18n = AppLocalizations.of(context); switch (value) { case 'Abs': - return AppLocalizations.of(context).abs; + return i18n.abs; case 'Arms': - return AppLocalizations.of(context).arms; + return i18n.arms; case 'Back': - return AppLocalizations.of(context).back; + return i18n.back; case 'Barbell': - return AppLocalizations.of(context).barbell; + return i18n.barbell; case 'Bench': - return AppLocalizations.of(context).bench; + return i18n.bench; case 'Biceps': - return AppLocalizations.of(context).biceps; + return i18n.biceps; case 'Body Weight': - return AppLocalizations.of(context).body_weight; + return i18n.body_weight; case 'Calves': - return AppLocalizations.of(context).calves; + return i18n.calves; case 'Cardio': - return AppLocalizations.of(context).cardio; + return i18n.cardio; case 'Chest': - return AppLocalizations.of(context).chest; + return i18n.chest; case 'Dumbbell': - return AppLocalizations.of(context).dumbbell; + return i18n.dumbbell; case 'Glutes': - return AppLocalizations.of(context).glutes; + return i18n.glutes; case 'Gym mat': - return AppLocalizations.of(context).gym_mat; + return i18n.gym_mat; case 'Hamstrings': - return AppLocalizations.of(context).hamstrings; + return i18n.hamstrings; case 'Incline bench': - return AppLocalizations.of(context).incline_bench; + return i18n.incline_bench; case 'Kettlebell': - return AppLocalizations.of(context).kettlebell; + return i18n.kettlebell; case 'Kilometers': - return AppLocalizations.of(context).kilometers; + return i18n.kilometers; case 'Kilometers Per Hour': - return AppLocalizations.of(context).kilometers_per_hour; + return i18n.kilometers_per_hour; case 'Lats': - return AppLocalizations.of(context).lats; + return i18n.lats; case 'Legs': - return AppLocalizations.of(context).legs; + return i18n.legs; case 'Lower back': - return AppLocalizations.of(context).lower_back; + return i18n.lower_back; case 'Max Reps': - return AppLocalizations.of(context).max_reps; + return i18n.max_reps; case 'Miles': - return AppLocalizations.of(context).miles; + return i18n.miles; case 'Miles Per Hour': - return AppLocalizations.of(context).miles_per_hour; + return i18n.miles_per_hour; case 'Minutes': - return AppLocalizations.of(context).minutes; + return i18n.minutes; case 'Plates': - return AppLocalizations.of(context).plates; + return i18n.plates; case 'Pull-up bar': - return AppLocalizations.of(context).pull_up_bar; + return i18n.pull_up_bar; case 'Quads': - return AppLocalizations.of(context).quads; + return i18n.quads; case 'Repetitions': - return AppLocalizations.of(context).repetitions; + return i18n.repetitions; case 'Resistance band': - return AppLocalizations.of(context).resistance_band; + return i18n.resistance_band; case 'SZ-Bar': - return AppLocalizations.of(context).sz_bar; + return i18n.sz_bar; case 'Seconds': - return AppLocalizations.of(context).seconds; + return i18n.seconds; case 'Shoulders': - return AppLocalizations.of(context).shoulders; + return i18n.shoulders; case 'Swiss Ball': - return AppLocalizations.of(context).swiss_ball; + return i18n.swiss_ball; case 'Triceps': - return AppLocalizations.of(context).triceps; + return i18n.triceps; case 'Until Failure': - return AppLocalizations.of(context).until_failure; + return i18n.until_failure; case 'kg': - return AppLocalizations.of(context).kg; + return i18n.kg; case 'lb': - return AppLocalizations.of(context).lb; + return i18n.lb; case 'none (bodyweight exercise)': - return AppLocalizations.of(context).none__bodyweight_exercise_; + return i18n.none__bodyweight_exercise_; default: logger.warning('Could not translate the server string $value'); diff --git a/lib/helpers/json.dart b/lib/helpers/json.dart index 560e6958..91cd0756 100644 --- a/lib/helpers/json.dart +++ b/lib/helpers/json.dart @@ -23,6 +23,10 @@ num stringToNum(String? e) { return e == null ? 0 : num.parse(e); } +num? stringToNumNull(String? e) { + return e == null ? null : num.parse(e); +} + num stringOrIntToNum(dynamic e) { if (e is int) { return e.toDouble(); // Convert int to double (a type of num) @@ -30,10 +34,6 @@ num stringOrIntToNum(dynamic e) { return num.tryParse(e) ?? 0; } -num? stringToNumNull(String? e) { - return e == null ? null : num.parse(e); -} - String? numToString(num? e) { if (e == null) { return null; diff --git a/lib/helpers/misc.dart b/lib/helpers/misc.dart index f99b2364..3078e87d 100644 --- a/lib/helpers/misc.dart +++ b/lib/helpers/misc.dart @@ -18,50 +18,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:wger/helpers/consts.dart'; -import 'package:wger/models/workouts/repetition_unit.dart'; -import 'package:wger/models/workouts/weight_unit.dart'; - -/// Returns the text representation for a single setting, used in the gym mode -String repText( - num? repetitions, - RepetitionUnit? repetitionUnitObj, - num? weight, - WeightUnit? weightUnitObj, - num? rir, -) { - // TODO(x): how to (easily?) translate strings like the units or 'RiR' - - final List out = []; - - if (repetitions != null) { - out.add(formatNum(repetitions).toString()); - - // The default repetition unit is 'reps', which we don't show unless there - // is no weight defined so that we don't just output something like "8" but - // rather "8 repetitions". If there is weight we want to output "8 x 50kg", - // since the repetitions are implied. If other units are used, we always - // print them - if (repetitionUnitObj != null && repetitionUnitObj.id != REP_UNIT_REPETITIONS_ID || - weight == 0 || - weight == null) { - out.add(repetitionUnitObj!.name); - } - } - - if (weight != null && weight != 0) { - out.add('×'); - out.add(formatNum(weight).toString()); - out.add(weightUnitObj!.name); - } - - if (rir != null && rir != '') { - out.add('\n'); - out.add('($rir RiR)'); - } - - return out.join(' '); -} void launchURL(String url, BuildContext context) async { final scaffoldMessenger = ScaffoldMessenger.of(context); diff --git a/lib/helpers/uuid.dart b/lib/helpers/uuid.dart new file mode 100644 index 00000000..33790988 --- /dev/null +++ b/lib/helpers/uuid.dart @@ -0,0 +1,45 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:math'; +import 'dart:typed_data'; + +String uuidV4() { + final rnd = Random.secure(); + final bytes = Uint8List(16); + for (var i = 0; i < 16; i++) { + bytes[i] = rnd.nextInt(256); + } + + // Set version to 4 -> xxxx0100 + bytes[6] = (bytes[6] & 0x0F) | 0x40; + + // Set variant to RFC4122 -> 10xxxxxx + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + return _bytesToUuid(bytes); +} + +String _bytesToUuid(Uint8List bytes) { + final sb = StringBuffer(); + for (var i = 0; i < bytes.length; i++) { + sb.write(bytes[i].toRadixString(16).padLeft(2, '0')); + if (i == 3 || i == 5 || i == 7 || i == 9) sb.write('-'); + } + return sb.toString(); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b954b8eb..1064ca94 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -232,6 +232,9 @@ "@comment": { "description": "Comment, additional information" }, + "impressionGood": "Good", + "impressionNeutral": "Neutral", + "impressionBad": "Bad", "impression": "Impression", "@impression": { "description": "General impression (e.g. for a workout session) such as good, bad, etc." @@ -263,6 +266,33 @@ "@gymMode": { "description": "Label when starting the gym mode" }, + "gymModeShowExercises": "Show exercise overview pages", + "gymModeShowTimer": "Show timer between sets", + "gymModeTimerType": "Timer type", + "gymModeTimerTypeHelText": "If a set has pause time, a countdown is always used.", + "countdown": "Countdown", + "stopwatch": "Stopwatch", + "gymModeDefaultCountdownTime": "Default countdown time, in seconds", + "gymModeNotifyOnCountdownFinish": "Notify on countdown end", + "duration": "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": "Workout completed", "plateCalculator": "Plates", "@plateCalculator": { "description": "Label used for the plate calculator in the gym mode" @@ -643,6 +673,19 @@ } } }, + "formMinMaxValues": "Please enter a value between {min} and {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" + } + } + }, "enterMinCharacters": "Please enter at least {min} characters", "@enterMinCharacters": { "description": "Error message when the user hasn't entered the minimum amount characters in a form", @@ -845,6 +888,7 @@ "fitInWeek": "Fit in week", "fitInWeekHelp": "If enabled, the days will repeat in a weekly cycle, otherwise the days will follow sequentially without regards to the start of a new week.", "addSuperset": "Add superset", + "superset": "Superset", "setHasProgression": "Set has progression", "setHasProgressionWarning": "Please note that at the moment it is not possible to edit all settings for a set on the mobile application or configure automatic progression. For now, please use the web application.", "setHasNoExercises": "This set has no exercises yet!", @@ -1063,7 +1107,10 @@ "@indicatorAvg": { "description": "added for localization of Class Indicator's field text" }, - "endWorkout": "End Workout", + "endWorkout": "End workout", + "@endWorkout": { + "description": "Use the imperative, label on button to finish the current workout in gym mode" + }, "themeMode": "Theme mode", "darkMode": "Always dark mode", "lightMode": "Always light mode", diff --git a/lib/models/exercises/muscle.dart b/lib/models/exercises/muscle.dart index 69ef6c28..6d382ea6 100644 --- a/lib/models/exercises/muscle.dart +++ b/lib/models/exercises/muscle.dart @@ -37,7 +37,7 @@ class Muscle extends Equatable { List get props => [id, name, isFront]; String nameTranslated(BuildContext context) { - return name + (nameEn.isNotEmpty ? ' (${getTranslation(nameEn, context)})' : ''); + return name + (nameEn.isNotEmpty ? ' (${getServerStringTranslation(nameEn, context)})' : ''); } @override diff --git a/lib/models/workouts/log.dart b/lib/models/workouts/log.dart index 52ebd300..33952e56 100644 --- a/lib/models/workouts/log.dart +++ b/lib/models/workouts/log.dart @@ -25,6 +25,13 @@ import 'package:wger/models/workouts/repetition_unit.dart'; import 'package:wger/models/workouts/set_config_data.dart'; import 'package:wger/models/workouts/weight_unit.dart'; +enum LogTargetStatus { + lessThanTarget, + atTarget, + moreThanTarget, + notSet, +} + class Log { String? id; @@ -70,16 +77,25 @@ class Log { Log.fromSetConfigData(SetConfigData data, {int? routineId, this.iteration}) { date = DateTime.now(); sessionId = null; + slotEntryId = data.slotEntryId; exercise = data.exercise; - weight = data.weight; - weightTarget = data.weight; - weightUnit = data.weightUnit; + if (data.weight != null) { + weight = data.weight; + weightTarget = data.weight; + } + if (data.weightUnit != null) { + weightUnit = data.weightUnit; + } - repetitions = data.repetitions; - repetitionsTarget = data.repetitions; - repetitionUnit = data.repetitionsUnit; + if (data.repetitions != null) { + repetitions = data.repetitions; + repetitionsTarget = data.repetitions; + } + if (data.repetitionsUnit != null) { + repetitionUnit = data.repetitionsUnit; + } rir = data.rir; rirTarget = data.rir; @@ -134,15 +150,80 @@ class Log { repetitionsUnitId = repetitionUnit?.id; } - /// Returns the text representation for a single setting, used in the gym mode - String get singleLogRepTextNoNl { - return repText( - repetitions, - repetitionsUnitObj, - weight, - weightUnitObj, - rir, - ).replaceAll('\n', ''); + /// Returns the text representation for a single setting, removes new lines + String repTextNoNl(BuildContext context) { + return repText(context).replaceAll('\n', ''); + } + + /// Returns the text representation for a single setting + String repText(BuildContext context) { + final List out = []; + + if (repetitions != null) { + out.add(formatNum(repetitions!).toString()); + + // The default repetition unit is 'reps', which we don't show unless there + // is no weight defined so that we don't just output something like "8" but + // rather "8 repetitions". If there is weight we want to output "8 x 50kg", + // since the repetitions are implied. If other units are used, we always + // print them + if (repetitionsUnitObj != null && repetitionsUnitObj!.id != REP_UNIT_REPETITIONS_ID || + weight == 0 || + weight == null) { + out.add(getServerStringTranslation(repetitionsUnitObj!.name, context)); + } + } + + if (weight != null && weight != 0) { + out.add('×'); + out.add(formatNum(weight!).toString()); + out.add(weightUnitObj!.name); + } + + if (rir != null) { + out.add('\n($rir RiR)'); + } + + return out.join(' '); + } + + /// Calculates the volume for this log entry + num volume({bool metric = true}) { + final unitId = metric ? WEIGHT_UNIT_KG : WEIGHT_UNIT_LB; + + if (weight != null && + weightUnitId == unitId && + repetitions != null && + repetitionsUnitId == REP_UNIT_REPETITIONS_ID) { + return weight! * repetitions!; + } + return 0; + } + + LogTargetStatus get targetStatus { + if (weightTarget == null && repetitionsTarget == null && rirTarget == null) { + return LogTargetStatus.notSet; + } + + final weightOk = weightTarget == null || (weight != null && weight! >= weightTarget!); + final repsOk = + repetitionsTarget == null || (repetitions != null && repetitions! >= repetitionsTarget!); + final rirOk = rirTarget == null || (rir != null && rir! <= rirTarget!); + + if (weightOk && repsOk && rirOk) { + return LogTargetStatus.atTarget; + } + + final weightMore = weightTarget != null && weight != null && weight! > weightTarget!; + final repsMore = + repetitionsTarget != null && repetitions != null && repetitions! > repetitionsTarget!; + final rirLess = rirTarget != null && rir != null && rir! < rirTarget!; + + if (weightMore || repsMore || rirLess) { + return LogTargetStatus.moreThanTarget; + } + + return LogTargetStatus.lessThanTarget; } /// Override the equals operator diff --git a/lib/models/workouts/routine.dart b/lib/models/workouts/routine.dart index bfca9a83..5e1dc388 100644 --- a/lib/models/workouts/routine.dart +++ b/lib/models/workouts/routine.dart @@ -19,6 +19,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:wger/helpers/date.dart'; import 'package:wger/helpers/json.dart'; +import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/day.dart'; import 'package:wger/models/workouts/day_data.dart'; import 'package:wger/models/workouts/log.dart'; @@ -176,4 +177,37 @@ class Routine { return groupedLogs; } + + void replaceExercise(int oldExerciseId, Exercise newExercise) { + for (final session in sessions) { + for (final log in session.logs) { + if (log.exerciseId == oldExerciseId) { + log.exerciseId = newExercise.id!; + log.exercise = newExercise; + } + } + } + + for (final day in dayData) { + for (final slot in day.slots) { + for (final config in slot.setConfigs) { + if (config.exerciseId == oldExerciseId) { + config.exerciseId = newExercise.id!; + config.exercise = newExercise; + } + } + } + } + + for (final day in dayDataGym) { + for (final slot in day.slots) { + for (final config in slot.setConfigs) { + if (config.exerciseId == oldExerciseId) { + config.exerciseId = newExercise.id!; + config.exercise = newExercise; + } + } + } + } + } } diff --git a/lib/models/workouts/session.dart b/lib/models/workouts/session.dart index 85712a6a..2af41eca 100644 --- a/lib/models/workouts/session.dart +++ b/lib/models/workouts/session.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * 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 @@ -20,11 +20,16 @@ import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:wger/database/powersync/database.dart'; import 'package:wger/models/exercises/exercise.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:wger/helpers/json.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/log.dart'; const IMPRESSION_MAP = {1: 'bad', 2: 'neutral', 3: 'good'}; class WorkoutSession { + final _logger = Logger('WorkoutSession'); + String? id; late int? routineId; int? dayId; @@ -63,6 +68,48 @@ class WorkoutSession { ); } + Duration? get duration { + if (timeStart == null || timeEnd == null) { + return null; + } + final now = DateTime.now(); + final startDate = DateTime(now.year, now.month, now.day, timeStart!.hour, timeStart!.minute); + final endDate = DateTime(now.year, now.month, now.day, timeEnd!.hour, timeEnd!.minute); + return endDate.difference(startDate); + } + + String durationTxt(BuildContext context) { + final duration = this.duration; + if (duration == null) { + return '-/-'; + } + return AppLocalizations.of( + context, + ).durationHoursMinutes(duration.inHours, duration.inMinutes.remainder(60)); + } + + String durationTxtWithStartEnd(BuildContext context) { + final duration = this.duration; + if (duration == null) { + return '-/-'; + } + + final startTime = MaterialLocalizations.of(context).formatTimeOfDay(timeStart!); + final endTime = MaterialLocalizations.of(context).formatTimeOfDay(timeEnd!); + + return '${durationTxt(context)} ($startTime - $endTime)'; + } + + /// Get total volume of the session for metric and imperial units + /// (i.e. sets that have "repetitions" as units and weight in kg or lbs). + /// Other combinations such as "seconds" are ignored. + Map get volume { + final volumeMetric = logs.fold(0, (sum, log) => sum + log.volume(metric: true)); + final volumeImperial = logs.fold(0, (sum, log) => sum + log.volume(metric: false)); + + return {'metric': volumeMetric, 'imperial': volumeImperial}; + } + List get exercises { final Set exerciseSet = {}; for (final log in logs) { @@ -71,7 +118,16 @@ class WorkoutSession { return exerciseSet.toList(); } - String get impressionAsString { - return IMPRESSION_MAP[impression]!; + String impressionAsString(BuildContext context) { + if (impression == 1) { + return AppLocalizations.of(context).impressionBad; + } else if (impression == 2) { + return AppLocalizations.of(context).impressionNeutral; + } else if (impression == 3) { + return AppLocalizations.of(context).impressionGood; + } + + _logger.warning('Unknown impression value: $impression'); + return AppLocalizations.of(context).impressionGood; } } diff --git a/lib/models/workouts/set_config_data.dart b/lib/models/workouts/set_config_data.dart index bdebc47c..38b8e152 100644 --- a/lib/models/workouts/set_config_data.dart +++ b/lib/models/workouts/set_config_data.dart @@ -46,55 +46,55 @@ class SetConfigData { String get textReprWithType => '$textRepr${type.typeLabel}'; @JsonKey(required: true, name: 'sets') - late num? nrOfSets; + num? nrOfSets; @JsonKey(required: true, name: 'max_sets') - late num? maxNrOfSets; + num? maxNrOfSets; @JsonKey(required: true, fromJson: stringToNumNull) - late num? weight; + num? weight; @JsonKey(required: true, name: 'max_weight', fromJson: stringToNumNull) - late num? maxWeight; + num? maxWeight; @JsonKey(required: true, name: 'weight_unit') - late int? weightUnitId; + int? weightUnitId; @JsonKey(includeToJson: false, includeFromJson: false) - late WeightUnit? weightUnit; + WeightUnit? weightUnit; @JsonKey(required: true, name: 'weight_rounding', fromJson: stringToNumNull) - late num? weightRounding; + num? weightRounding; @JsonKey(required: true, name: 'repetitions', fromJson: stringToNumNull) - late num? repetitions; + num? repetitions; @JsonKey(required: true, name: 'max_repetitions', fromJson: stringToNumNull) - late num? maxRepetitions; + num? maxRepetitions; @JsonKey(required: true, name: 'repetitions_unit') - late int? repetitionsUnitId; + int? repetitionsUnitId; @JsonKey(includeToJson: false, includeFromJson: false) - late RepetitionUnit? repetitionsUnit; + RepetitionUnit? repetitionsUnit; @JsonKey(required: true, name: 'repetitions_rounding', fromJson: stringToNumNull) - late num? repetitionsRounding; + num? repetitionsRounding; @JsonKey(required: true, fromJson: stringToNumNull) - late num? rir; + num? rir; @JsonKey(required: true, name: 'max_rir', fromJson: stringToNumNull) - late num? maxRir; + num? maxRir; @JsonKey(required: true, fromJson: stringToNumNull) - late num? rpe; + num? rpe; @JsonKey(required: true, name: 'rest', fromJson: stringToNumNull) - late num? restTime; + num? restTime; @JsonKey(required: true, name: 'max_rest', fromJson: stringToNumNull) - late num? maxRestTime; + num? maxRestTime; @JsonKey(required: true) late String comment; @@ -103,20 +103,20 @@ class SetConfigData { required this.exerciseId, required this.slotEntryId, this.type = SlotEntryType.normal, - required this.nrOfSets, + this.nrOfSets, this.maxNrOfSets, - required this.weight, + this.weight, this.maxWeight, this.weightUnitId = WEIGHT_UNIT_KG, this.weightRounding, - required this.repetitions, + this.repetitions, this.maxRepetitions, this.repetitionsUnitId = REP_UNIT_REPETITIONS_ID, this.repetitionsRounding, - required this.rir, + this.rir, this.maxRir, - required this.rpe, - required this.restTime, + this.rpe, + this.restTime, this.maxRestTime, this.comment = '', this.textRepr = '', @@ -135,6 +135,58 @@ class SetConfigData { } } + SetConfigData copyWith({ + int? exerciseId, + int? slotEntryId, + SlotEntryType? type, + String? textRepr, + num? nrOfSets, + num? maxNrOfSets, + num? weight, + num? maxWeight, + int? weightUnitId, + num? weightRounding, + num? repetitions, + num? maxRepetitions, + int? repetitionsUnitId, + num? repetitionsRounding, + num? rir, + num? maxRir, + num? rpe, + num? restTime, + num? maxRestTime, + String? comment, + Exercise? exercise, + WeightUnit? weightUnit, + RepetitionUnit? repetitionsUnit, + }) { + return SetConfigData( + exerciseId: exerciseId ?? this.exerciseId, + slotEntryId: slotEntryId ?? this.slotEntryId, + type: type ?? this.type, + textRepr: textRepr ?? this.textRepr, + nrOfSets: nrOfSets ?? this.nrOfSets, + maxNrOfSets: maxNrOfSets ?? this.maxNrOfSets, + weight: weight ?? this.weight, + maxWeight: maxWeight ?? this.maxWeight, + weightUnitId: weightUnitId ?? this.weightUnitId, + weightRounding: weightRounding ?? this.weightRounding, + repetitions: repetitions ?? this.repetitions, + maxRepetitions: maxRepetitions ?? this.maxRepetitions, + repetitionsUnitId: repetitionsUnitId ?? this.repetitionsUnitId, + repetitionsRounding: repetitionsRounding ?? this.repetitionsRounding, + rir: rir ?? this.rir, + maxRir: maxRir ?? this.maxRir, + rpe: rpe ?? this.rpe, + restTime: restTime ?? this.restTime, + maxRestTime: maxRestTime ?? this.maxRestTime, + comment: comment ?? this.comment, + exercise: exercise ?? this.exercise, + weightUnit: weightUnit ?? this.weightUnit, + repetitionsUnit: repetitionsUnit ?? this.repetitionsUnit, + ); + } + // Boilerplate factory SetConfigData.fromJson(Map json) => _$SetConfigDataFromJson(json); diff --git a/lib/providers/gym_state.dart b/lib/providers/gym_state.dart index f4bd7ee8..cec04d8f 100644 --- a/lib/providers/gym_state.dart +++ b/lib/providers/gym_state.dart @@ -1,99 +1,704 @@ import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +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/routine.dart'; +import 'package:wger/models/workouts/set_config_data.dart'; + +part 'gym_state.g.dart'; const DEFAULT_DURATION = Duration(hours: 5); -final gymStateProvider = NotifierProvider(GymNotifier.new); +const PREFS_SHOW_EXERCISES = 'showExercisePrefs'; +const PREFS_SHOW_TIMER = 'showTimerPrefs'; +const PREFS_ALERT_COUNTDOWN = 'alertCountdownPrefs'; +const PREFS_USE_COUNTDOWN_BETWEEN_SETS = 'useCountdownBetweenSetsPrefs'; +const PREFS_COUNTDOWN_DURATION = 'countdownDurationSecondsPrefs'; -class GymState { - final Map exercisePages; - final bool showExercisePages; - final int currentPage; - final int? dayId; - late TimeOfDay startTime; - late DateTime validUntil; +/// In seconds +const DEFAULT_COUNTDOWN_DURATION = 180; +const MIN_COUNTDOWN_DURATION = 10; +const MAX_COUNTDOWN_DURATION = 1800; - GymState({ - this.exercisePages = const {}, - this.showExercisePages = true, - this.currentPage = 0, - this.dayId, - DateTime? validUntil, - TimeOfDay? startTime, +enum PageType { + start, + set, + session, + workoutSummary, +} + +enum SlotPageType { + exerciseOverview, + log, + timer, +} + +class PageEntry { + final String uuid; + + final PageType type; + + /// Absolute page index + final int pageIndex; + + final List slotPages; + + PageEntry({ + required this.type, + required this.pageIndex, + this.slotPages = const [], + String? uuid, + }) : uuid = uuid ?? uuidV4(), + assert( + slotPages.isEmpty || type == PageType.set, + 'SlotEntries can only be set for set pages', + ); + + PageEntry copyWith({ + String? uuid, + PageType? type, + int? pageIndex, + List? slotPages, }) { - this.validUntil = validUntil ?? DateTime.now().add(DEFAULT_DURATION); - this.startTime = startTime ?? TimeOfDay.fromDateTime(clock.now()); + return PageEntry( + uuid: uuid ?? this.uuid, + type: type ?? this.type, + pageIndex: pageIndex ?? this.pageIndex, + slotPages: slotPages ?? this.slotPages, + ); } - GymState copyWith({ - Map? exercisePages, - bool? showExercisePages, - int? currentPage, + List get exercises { + final exerciseSet = {}; + for (final entry in slotPages) { + exerciseSet.add(entry.setConfigData!.exercise); + } + return exerciseSet.toList(); + } + + // Whether all sub-pages (e.g. log pages) are marked as done. + bool get allLogsDone => + slotPages.where((entry) => entry.type == SlotPageType.log).every((entry) => entry.logDone); + + @override + String toString() => 'PageEntry(type: $type, pageIndex: $pageIndex)'; +} + +class SlotPageEntry { + final String uuid; + + final SlotPageType type; + + /// index within a set for overview (e.g. "1 of 5 sets") + final int setIndex; + + /// Absolute page index + final int pageIndex; + + /// Whether the log page has been marked as done + final bool logDone; + + /// The associated SetConfigData + final SetConfigData? setConfigData; + + SlotPageEntry({ + required this.type, + required this.pageIndex, + required this.setIndex, + this.setConfigData, + this.logDone = false, + String? uuid, + }) : uuid = uuid ?? uuidV4(); + + SlotPageEntry copyWith({ + String? uuid, + SlotPageType? type, + int? exerciseId, + int? setIndex, + int? pageIndex, + SetConfigData? setConfigData, + bool? logDone, + }) { + return SlotPageEntry( + uuid: uuid ?? this.uuid, + type: type ?? this.type, + setIndex: setIndex ?? this.setIndex, + pageIndex: pageIndex ?? this.pageIndex, + setConfigData: setConfigData ?? this.setConfigData, + logDone: logDone ?? this.logDone, + ); + } + + @override + String toString() => + 'SlotPageEntry(' + 'uuid: $uuid, ' + 'type: $type, ' + 'setIndex: $setIndex, ' + 'pageIndex: $pageIndex, ' + 'logDone: $logDone' + ')'; +} + +class GymModeState { + final _logger = Logger('GymModeState'); + + // Navigation data + final bool isInitialized; + + final List pages; + final int currentPage; + + final TimeOfDay startTime; + final DateTime validUntil; + + // User settings + final bool showExercisePages; + final bool showTimerPages; + final bool alertOnCountdownEnd; + final bool useCountdownBetweenSets; + final Duration countdownDuration; + + // Routine data + late final int dayId; + late final int iteration; + late final Routine routine; + + GymModeState({ + this.isInitialized = false, + this.pages = const [], + this.currentPage = 0, + + this.showExercisePages = true, + this.showTimerPages = true, + this.alertOnCountdownEnd = true, + this.useCountdownBetweenSets = false, + this.countdownDuration = const Duration(seconds: DEFAULT_COUNTDOWN_DURATION), + int? dayId, + int? iteration, + Routine? routine, + DateTime? validUntil, TimeOfDay? startTime, + }) : validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION), + startTime = startTime ?? TimeOfDay.fromDateTime(clock.now()) { + if (dayId != null) { + this.dayId = dayId; + } + + if (iteration != null) { + this.iteration = iteration; + } + + if (routine != null) { + this.routine = routine; + } + } + + GymModeState copyWith({ + // Navigation data + bool? isInitialized, + List? pages, + int? currentPage, + + // Routine data + int? dayId, + int? iteration, + DateTime? validUntil, + TimeOfDay? startTime, + Routine? routine, + + // User settings + bool? showExercisePages, + bool? showTimerPages, + bool? alertOnCountdownEnd, + bool? useCountdownBetweenSets, + int? countdownDuration, }) { - return GymState( - exercisePages: exercisePages ?? this.exercisePages, - showExercisePages: showExercisePages ?? this.showExercisePages, + return GymModeState( + isInitialized: isInitialized ?? this.isInitialized, + pages: pages ?? this.pages, currentPage: currentPage ?? this.currentPage, + dayId: dayId ?? this.dayId, - validUntil: validUntil ?? this.validUntil.add(DEFAULT_DURATION), + iteration: iteration ?? this.iteration, + validUntil: validUntil ?? this.validUntil, startTime: startTime ?? this.startTime, + routine: routine ?? this.routine, + + showExercisePages: showExercisePages ?? this.showExercisePages, + showTimerPages: showTimerPages ?? this.showTimerPages, + alertOnCountdownEnd: alertOnCountdownEnd ?? this.alertOnCountdownEnd, + useCountdownBetweenSets: useCountdownBetweenSets ?? this.useCountdownBetweenSets, + countdownDuration: Duration( + seconds: countdownDuration ?? this.countdownDuration.inSeconds, + ), ); } + int get totalPages { + // Main pages (start, session, etc.) + var count = pages.where((p) => p.type != PageType.set).length; + + // Add all other sub pages (sets, timer, etc.) + count += pages.fold(0, (prev, e) => prev + e.slotPages.length); + + return count; + } + + DayData get dayDataGym => + routine.dayDataGym.where((e) => e.iteration == iteration && e.day?.id == dayId).first; + + DayData get dayDataDisplay => routine.dayData.firstWhere( + (e) => e.iteration == iteration && e.day?.id == dayId, + ); + + PageEntry? getPageByIndex([int? pageIndex]) { + final index = pageIndex ?? currentPage; + + for (final page in pages) { + for (final slotPage in page.slotPages) { + if (slotPage.pageIndex == index) { + return page; + } + } + } + return null; + } + + SlotPageEntry? getSlotEntryPageByIndex([int? pageIndex]) { + final index = pageIndex ?? currentPage; + + for (final slotPage in pages.expand((p) => p.slotPages)) { + if (slotPage.pageIndex == index) { + return slotPage; + } + } + return null; + } + + SlotPageEntry? getSlotPageByUUID(String uuid) { + for (final slotPage in pages.expand((p) => p.slotPages)) { + if (slotPage.uuid == uuid) { + return slotPage; + } + } + return null; + } + + double get ratioCompleted { + if (totalPages == 0) { + return 0.0; + } + + // Note: add 1 to currentPage to make it 1-based + return (currentPage + 1) / totalPages; + } + @override String toString() { return 'GymState(' 'currentPage: $currentPage, ' - 'showExercisePages: $showExercisePages, ' - 'exercisePages: ${exercisePages.length} exercises, ' - 'dayId: $dayId, ' 'validUntil: $validUntil ' 'startTime: $startTime, ' + 'showExercisePages: $showExercisePages, ' + 'showTimerPages: $showTimerPages, ' ')'; } } -class GymNotifier extends Notifier { +@Riverpod(keepAlive: true) +class GymStateNotifier extends _$GymStateNotifier { final _logger = Logger('GymStateNotifier'); @override - GymState build() => GymState(); + GymModeState build() { + _logger.finer('Initializing GymStateNotifier'); + return GymModeState(); + } + + Future loadPrefs() async { + final prefs = PreferenceHelper.asyncPref; + + final showExercise = await prefs.getBool(PREFS_SHOW_EXERCISES); + if (showExercise != null && showExercise != state.showExercisePages) { + state = state.copyWith(showExercisePages: showExercise); + } + + final showTimer = await prefs.getBool(PREFS_SHOW_TIMER); + if (showTimer != null && showTimer != state.showTimerPages) { + state = state.copyWith(showTimerPages: showTimer); + } + + final alertOnCountdownEnd = await prefs.getBool(PREFS_ALERT_COUNTDOWN); + if (alertOnCountdownEnd != null && alertOnCountdownEnd != state.alertOnCountdownEnd) { + state = state.copyWith(alertOnCountdownEnd: alertOnCountdownEnd); + } + + final useCountdownBetweenSets = await prefs.getBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS); + if (useCountdownBetweenSets != null && + useCountdownBetweenSets != state.useCountdownBetweenSets) { + state = state.copyWith(useCountdownBetweenSets: useCountdownBetweenSets); + } + + final defaultCountdownDurationSeconds = await prefs.getInt(PREFS_COUNTDOWN_DURATION); + if (defaultCountdownDurationSeconds != null && + defaultCountdownDurationSeconds != state.countdownDuration.inSeconds) { + state = state.copyWith( + countdownDuration: defaultCountdownDurationSeconds, + ); + } + + _logger.finer( + 'Loaded saved preferences: ' + 'showExercise=$showExercise ' + 'showTimer=$showTimer ' + 'alertOnCountdownEnd=$alertOnCountdownEnd ' + 'useCountdownBetweenSets=$useCountdownBetweenSets ' + 'defaultCountdownDurationSeconds=$defaultCountdownDurationSeconds', + ); + } + + Future _savePrefs() async { + final prefs = PreferenceHelper.asyncPref; + await prefs.setBool(PREFS_SHOW_EXERCISES, state.showExercisePages); + await prefs.setBool(PREFS_SHOW_TIMER, state.showTimerPages); + await prefs.setBool(PREFS_ALERT_COUNTDOWN, state.alertOnCountdownEnd); + await prefs.setBool(PREFS_USE_COUNTDOWN_BETWEEN_SETS, state.useCountdownBetweenSets); + await prefs.setInt( + PREFS_COUNTDOWN_DURATION, + state.countdownDuration.inSeconds, + ); + _logger.finer( + 'Saved preferences: ' + 'showExercise=${state.showExercisePages} ' + 'showTimer=${state.showTimerPages} ' + 'alertOnCountdownEnd=${state.alertOnCountdownEnd} ' + 'useCountdownBetweenSets=${state.useCountdownBetweenSets} ' + 'defaultCountdownDuration=${state.countdownDuration.inSeconds}', + ); + } + + /// Calculates the page entries + void calculatePages() { + var pageIndex = 0; + + final List pages = [ + // Start page + PageEntry(type: PageType.start, pageIndex: pageIndex), + ]; + + pageIndex++; + for (final slotData in state.dayDataGym.slots) { + final slotPageIndex = pageIndex; + final slotEntries = []; + int setIndex = 0; + + // exercise overview page + if (state.showExercisePages) { + // Add one overview page per exercise in the slot (e.g. for supersets) + for (final exerciseId in slotData.exerciseIds) { + final setConfig = slotData.setConfigs.firstWhereOrNull((c) => c.exerciseId == exerciseId); + if (setConfig == null) { + _logger.warning('Exercise with ID $exerciseId not found in slotData!!'); + continue; + } + + slotEntries.add( + SlotPageEntry( + type: SlotPageType.exerciseOverview, + setIndex: setIndex, + pageIndex: pageIndex, + setConfigData: setConfig, + ), + ); + pageIndex++; + } + } + + for (final config in slotData.setConfigs) { + // Log page + slotEntries.add( + SlotPageEntry( + type: SlotPageType.log, + setIndex: setIndex, + pageIndex: pageIndex, + setConfigData: config, + ), + ); + pageIndex++; + setIndex++; + + // Timer page + if (state.showTimerPages) { + slotEntries.add( + SlotPageEntry( + type: SlotPageType.timer, + setIndex: setIndex, + pageIndex: pageIndex, + setConfigData: config, + ), + ); + pageIndex++; + } + } + + pages.add( + PageEntry( + type: PageType.set, + pageIndex: slotPageIndex, + slotPages: slotEntries, + ), + ); + } + + // Session and summary page + pages.add(PageEntry(type: PageType.session, pageIndex: pageIndex)); + pages.add(PageEntry(type: PageType.workoutSummary, pageIndex: pageIndex + 1)); + + state = state.copyWith(pages: pages); + // _logger.finer(readPageStructure()); + _logger.finer('Initialized ${state.pages.length} pages'); + } + + // Recalculates the indices of all pages + void recalculateIndices() { + var pageIndex = 0; + final updatedPages = []; + + for (final page in state.pages) { + final slotPageIndex = pageIndex; + var setIndex = 0; + final updatedSlotPages = []; + + for (final slotPage in page.slotPages) { + updatedSlotPages.add( + slotPage.copyWith( + pageIndex: pageIndex, + setIndex: setIndex, + ), + ); + setIndex++; + pageIndex++; + } + + if (page.type != PageType.set) { + pageIndex++; + } + + updatedPages.add( + page.copyWith( + pageIndex: slotPageIndex, + slotPages: updatedSlotPages, + ), + ); + } + + state = state.copyWith(pages: updatedPages); + // _logger.fine(readPageStructure()); + _logger.fine('Recalculated page indices'); + } + + /// Reads the current page structure for debugging purposes + String readPageStructure() { + final List out = []; + out.add('GymModeState structure:'); + for (final page in state.pages) { + out.add('Page ${page.pageIndex}: ${page.type}'); + for (final slotPage in page.slotPages) { + out.add( + ' SlotPage ${slotPage.pageIndex.toString().padLeft(2, ' ')} (set index ${slotPage.setIndex}): ${slotPage.type}', + ); + } + } + + return out.join('\n'); + } + + int initData(Routine routine, int dayId, int iteration) { + final validUntil = state.validUntil; + final currentPage = state.currentPage; + + final shouldReset = + (!state.isInitialized || state.isInitialized && dayId != state.dayId) || + validUntil.isBefore(DateTime.now()); + if (shouldReset) { + _logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.'); + } + final initialPage = shouldReset ? 0 : currentPage; + + // set dayId and initial page + state = state.copyWith( + isInitialized: true, + dayId: dayId, + routine: routine, + iteration: iteration, + currentPage: initialPage, + ); + + // Calculate the pages. + // Note that this is only done if we need to reset, otherwise we keep the + // existing state like the exercises that have already been done + if (shouldReset) { + calculatePages(); + } + + _logger.fine('Initialized GymModeState, initialPage=$initialPage'); + return initialPage; + } void setCurrentPage(int page) { - // _logger.fine('Setting page from ${state.currentPage} to $page'); state = state.copyWith(currentPage: page); } - void toggleExercisePages() { - state = state.copyWith(showExercisePages: !state.showExercisePages); + void setShowExercisePages(bool value) { + state = state.copyWith(showExercisePages: value); + calculatePages(); + _savePrefs(); } - void setDayId(int dayId) { - // _logger.fine('Setting day id from ${state.dayId} to $dayId'); - state = state.copyWith(dayId: dayId); + void setShowTimerPages(bool value) { + state = state.copyWith(showTimerPages: value); + calculatePages(); + _savePrefs(); } - void setExercisePages(Map exercisePages) { - // _logger.fine('Setting exercise pages - ${exercisePages.length} exercises'); - state = state.copyWith(exercisePages: exercisePages); - // _logger.fine( - // 'Exercise pages set - ${exercisePages.entries.map((e) => '${e.key.id}: ${e.value}').join(', ')}'); + void setAlertOnCountdownEnd(bool value) { + state = state.copyWith(alertOnCountdownEnd: value); + _savePrefs(); + } + + void setUseCountdownBetweenSets(bool value) { + state = state.copyWith(useCountdownBetweenSets: value); + _savePrefs(); + } + + void setCountdownDuration(int duration) { + state = state.copyWith(countdownDuration: duration); + _savePrefs(); + } + + void markSlotPageAsDone(String uuid, {required bool isDone}) { + final slotPage = state.getSlotPageByUUID(uuid); + if (slotPage == null) { + _logger.warning('No slot page found for UUID $uuid'); + return; + } + + final updatedSlotPage = slotPage.copyWith(logDone: isDone); + + final updatedPages = state.pages.map((page) { + if (page.type != PageType.set) { + return page; + } + + final updatedSlotPages = page.slotPages.map((sp) { + if (sp.uuid == uuid) { + return updatedSlotPage; + } + return sp; + }).toList(); + + return page.copyWith(slotPages: updatedSlotPages); + }).toList(); + + state = state.copyWith(pages: updatedPages); + _logger.fine('Set logDone=$isDone for slot page UUID $uuid'); + } + + void replaceExercises( + String pageEntryUUID, { + required int originalExerciseId, + required Exercise newExercise, + }) { + final updatedPages = state.pages.map((page) { + if (page.type != PageType.set) { + return page; + } + + if (page.uuid != pageEntryUUID) { + return page; + } + + final updatedSlotPages = page.slotPages.map((slotPage) { + if (slotPage.setConfigData != null && + slotPage.setConfigData!.exercise.id == originalExerciseId) { + final updatedSetConfigData = slotPage.setConfigData!.copyWith( + exerciseId: newExercise.id, + exercise: newExercise, + ); + return slotPage.copyWith(setConfigData: updatedSetConfigData); + } + return slotPage; + }).toList(); + + return page.copyWith(slotPages: updatedSlotPages); + }).toList(); + + // TODO: this should not be done in-place! + state.routine.replaceExercise(originalExerciseId, newExercise); + state = state.copyWith( + pages: updatedPages, + ); + _logger.fine('Replaced exercise $originalExerciseId with ${newExercise.id}'); + } + + void addExerciseAfterPage( + String pageEntryUUID, { + required Exercise newExercise, + }) { + final List pages = []; + for (final page in state.pages) { + pages.add(page); + + if (page.uuid == pageEntryUUID) { + final setConfigData = page.slotPages.first.setConfigData!; + + final List newSlotPages = []; + for (var i = 1; i <= 4; i++) { + newSlotPages.add( + SlotPageEntry( + type: SlotPageType.log, + pageIndex: 1, + setIndex: 0, + setConfigData: SetConfigData( + textRepr: '-/-', + exerciseId: newExercise.id!, + exercise: newExercise, + slotEntryId: setConfigData.slotEntryId, + ), + ), + ); + } + + final newPage = PageEntry(type: PageType.set, pageIndex: 1, slotPages: newSlotPages); + + pages.add(newPage); + } + } + + state = state.copyWith( + pages: pages, + ); + + recalculateIndices(); } void clear() { _logger.fine('Clearing state'); state = state.copyWith( - exercisePages: {}, + isInitialized: false, + pages: [], currentPage: 0, - dayId: null, - validUntil: DateTime.now().add(DEFAULT_DURATION), - startTime: TimeOfDay.now(), + + validUntil: clock.now().add(DEFAULT_DURATION), + startTime: null, ); } } diff --git a/lib/providers/gym_state.g.dart b/lib/providers/gym_state.g.dart new file mode 100644 index 00000000..3a858e5e --- /dev/null +++ b/lib/providers/gym_state.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gym_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(GymStateNotifier) +const gymStateProvider = GymStateNotifierProvider._(); + +final class GymStateNotifierProvider extends $NotifierProvider { + const GymStateNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gymStateProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gymStateNotifierHash(); + + @$internal + @override + GymStateNotifier create() => GymStateNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GymModeState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gymStateNotifierHash() => r'449bd80d3b534f68af4f0dbb8556c7f093f3b918'; + +abstract class _$GymStateNotifier extends $Notifier { + GymModeState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + GymModeState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 05e8a8f5..a175a074 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -28,7 +28,7 @@ import 'package:wger/widgets/dashboard/widgets/routines.dart'; import 'package:wger/widgets/dashboard/widgets/weight.dart'; class DashboardScreen extends StatelessWidget { - const DashboardScreen(); + const DashboardScreen({super.key}); static const routeName = '/dashboard'; diff --git a/lib/screens/gym_mode.dart b/lib/screens/gym_mode.dart index 9b8ff02d..3d0a9fda 100644 --- a/lib/screens/gym_mode.dart +++ b/lib/screens/gym_mode.dart @@ -39,21 +39,13 @@ class GymModeScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final args = ModalRoute.of(context)!.settings.arguments as GymModeArguments; - final routineProvider = ref.read(routinesRiverpodProvider); - final routine = routineProvider.findById(args.routineId); - final dayDataDisplay = routine.dayData.firstWhere( - (e) => e.iteration == args.iteration && e.day?.id == args.dayId, - ); - final dayDataGym = routine.dayDataGym - .where((e) => e.iteration == args.iteration && e.day?.id == args.dayId) - .first; - return Scaffold( // backgroundColor: Theme.of(context).cardColor, // primary: false, body: SafeArea( child: WidescreenWrapper( - child: GymMode(dayDataGym, dayDataDisplay, args.iteration), + child: GymMode(args), + ), ), ), ); diff --git a/lib/screens/routine_logs_screen.dart b/lib/screens/routine_logs_screen.dart index ec10741f..16ff916e 100644 --- a/lib/screens/routine_logs_screen.dart +++ b/lib/screens/routine_logs_screen.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -21,7 +21,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/widgets/core/app_bar.dart'; -import 'package:wger/widgets/routines/workout_logs.dart'; +import 'package:wger/widgets/routines/logs/log_overview_routine.dart'; class WorkoutLogsScreen extends ConsumerWidget { const WorkoutLogsScreen(); diff --git a/lib/widgets/add_exercise/steps/step_1_basics.dart b/lib/widgets/add_exercise/steps/step_1_basics.dart index ef1d1e74..0e3ebd43 100644 --- a/lib/widgets/add_exercise/steps/step_1_basics.dart +++ b/lib/widgets/add_exercise/steps/step_1_basics.dart @@ -79,7 +79,7 @@ class Step1Basics extends ConsumerWidget { return AppLocalizations.of(context).selectEntry; } }, - displayName: (ExerciseCategory c) => getTranslation(c.name, context), + displayName: (ExerciseCategory c) => getServerStringTranslation(c.name, context), ), AddExerciseMultiselectButton( key: const Key('equipment-multiselect'), @@ -92,7 +92,7 @@ class Step1Basics extends ConsumerWidget { onSaved: (dynamic entries) { addExerciseProvider.equipment = entries.cast(); }, - displayName: (Equipment e) => getTranslation(e.name, context), + displayName: (Equipment e) => getServerStringTranslation(e.name, context), ), AddExerciseMultiselectButton( key: const Key('primary-muscles-multiselect'), @@ -106,7 +106,10 @@ class Step1Basics extends ConsumerWidget { addExerciseProvider.primaryMuscles = muscles.cast(); }, displayName: (Muscle e) => - e.name + (e.nameEn.isNotEmpty ? '\n(${getTranslation(e.nameEn, context)})' : ''), + e.name + + (e.nameEn.isNotEmpty + ? '\n(${getServerStringTranslation(e.nameEn, context)})' + : ''), ), AddExerciseMultiselectButton( key: const Key('secondary-muscles-multiselect'), @@ -120,7 +123,10 @@ class Step1Basics extends ConsumerWidget { addExerciseProvider.secondaryMuscles = muscles.cast(); }, displayName: (Muscle e) => - e.name + (e.nameEn.isNotEmpty ? '\n(${getTranslation(e.nameEn, context)})' : ''), + e.name + + (e.nameEn.isNotEmpty + ? '\n(${getServerStringTranslation(e.nameEn, context)})' + : ''), ), MuscleRowWidget( muscles: provider.primaryMuscles, diff --git a/lib/widgets/dashboard/calendar.dart b/lib/widgets/dashboard/calendar.dart index 2edffe83..762ee221 100644 --- a/lib/widgets/dashboard/calendar.dart +++ b/lib/widgets/dashboard/calendar.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * 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 * 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, + * wger Workout Manager is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -155,11 +155,15 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Chip( - label: Text(getTranslation(e.name, context)), + label: Text(getServerStringTranslation(e.name, context)), padding: EdgeInsets.zero, backgroundColor: theme.splashColor, ), diff --git a/lib/widgets/exercises/filter_modal.dart b/lib/widgets/exercises/filter_modal.dart index dc8de5cd..1bca09f0 100644 --- a/lib/widgets/exercises/filter_modal.dart +++ b/lib/widgets/exercises/filter_modal.dart @@ -66,7 +66,7 @@ class _ExerciseFilterModalBodyState extends ConsumerState getTranslation(e.name, context)).toList().join(', ')}', + '${getServerStringTranslation(exercise.category!.name, context)} / ${exercise.equipment.map((e) => getServerStringTranslation(e.name, context)).toList().join(', ')}', ), onTap: () { Navigator.pushNamed(context, ExerciseDetailScreen.routeName, arguments: exercise); diff --git a/lib/widgets/routines/forms/rir.dart b/lib/widgets/routines/forms/rir.dart index 254a3603..22c95753 100644 --- a/lib/widgets/routines/forms/rir.dart +++ b/lib/widgets/routines/forms/rir.dart @@ -17,27 +17,21 @@ */ import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/slot_entry.dart'; /// Input widget for Reps In Reserve class RiRInputWidget extends StatefulWidget { + final _logger = Logger('RiRInputWidget'); + final num? _initialValue; final ValueChanged onChanged; - late String dropdownValue; - late double _currentSetSliderValue; static const SLIDER_START = -0.5; RiRInputWidget(this._initialValue, {required this.onChanged}) { - dropdownValue = _initialValue != null ? _initialValue.toString() : SlotEntry.DEFAULT_RIR; - - // Read string RiR into a double - if (_initialValue != null) { - _currentSetSliderValue = _initialValue.toDouble(); - } else { - _currentSetSliderValue = SLIDER_START; - } + _logger.finer('Initializing with initial value: $_initialValue'); } @override @@ -45,6 +39,28 @@ class RiRInputWidget extends StatefulWidget { } class _RiRInputWidgetState extends State { + late double _currentSetSliderValue; + + @override + void initState() { + super.initState(); + _currentSetSliderValue = widget._initialValue?.toDouble() ?? RiRInputWidget.SLIDER_START; + widget._logger.finer('initState - starting slider value: ${widget._initialValue}'); + } + + @override + void didUpdateWidget(covariant RiRInputWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + final newValue = widget._initialValue?.toDouble() ?? RiRInputWidget.SLIDER_START; + if (widget._initialValue != oldWidget._initialValue) { + widget._logger.finer('didUpdateWidget - new initial value: ${widget._initialValue}'); + setState(() { + _currentSetSliderValue = newValue; + }); + } + } + /// Returns the string used in the slider String getSliderLabel(double value) { if (value < 0) { @@ -77,15 +93,15 @@ class _RiRInputWidgetState extends State { Text(AppLocalizations.of(context).rir), Expanded( child: Slider( - value: widget._currentSetSliderValue, + value: _currentSetSliderValue, min: RiRInputWidget.SLIDER_START, max: (SlotEntry.POSSIBLE_RIR_VALUES.length - 2) / 2, divisions: SlotEntry.POSSIBLE_RIR_VALUES.length - 1, - label: getSliderLabel(widget._currentSetSliderValue), + label: getSliderLabel(_currentSetSliderValue), onChanged: (double value) { widget.onChanged(mapDoubleToAllowedRir(value)); setState(() { - widget._currentSetSliderValue = value; + _currentSetSliderValue = value; }); }, ), diff --git a/lib/widgets/routines/forms/session.dart b/lib/widgets/routines/forms/session.dart index beadc420..81615d8b 100644 --- a/lib/widgets/routines/forms/session.dart +++ b/lib/widgets/routines/forms/session.dart @@ -140,6 +140,15 @@ class _SessionFormState extends ConsumerState { decoration: InputDecoration( labelText: AppLocalizations.of(context).timeStart, errorMaxLines: 2, + suffix: IconButton( + onPressed: () => { + setState(() { + timeStartController.text = ''; + widget._session.timeStart = null; + }), + }, + icon: const Icon(Icons.clear), + ), ), controller: timeStartController, onFieldSubmitted: (_) {}, @@ -185,6 +194,15 @@ class _SessionFormState extends ConsumerState { key: const ValueKey('time-end'), decoration: InputDecoration( labelText: AppLocalizations.of(context).timeEnd, + suffix: IconButton( + onPressed: () => { + setState(() { + timeEndController.text = ''; + widget._session.timeEnd = null; + }), + }, + icon: const Icon(Icons.clear), + ), ), controller: timeEndController, onFieldSubmitted: (_) {}, diff --git a/lib/widgets/routines/gym_mode/exercise_overview.dart b/lib/widgets/routines/gym_mode/exercise_overview.dart index df34e881..e823816a 100644 --- a/lib/widgets/routines/gym_mode/exercise_overview.dart +++ b/lib/widgets/routines/gym_mode/exercise_overview.dart @@ -16,44 +16,45 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; -import 'package:wger/models/exercises/exercise.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/widgets/exercises/exercises.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; -class ExerciseOverview extends StatelessWidget { +class ExerciseOverview extends ConsumerWidget { + final _logger = Logger('ExerciseOverview'); final PageController _controller; - final Exercise _exercise; - final double _ratioCompleted; - final Map _exercisePages; - final int _totalPages; - const ExerciseOverview( - this._controller, - this._exercise, - this._ratioCompleted, - this._exercisePages, - this._totalPages, - ); + ExerciseOverview(this._controller); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final page = ref.watch(gymStateProvider).getSlotEntryPageByIndex(); + + if (page == null) { + _logger.info( + 'getPageByIndex returned null, showing empty container.', + ); + return Container(); + } + final exercise = page.setConfigData!.exercise; + return Column( children: [ NavigationHeader( - _exercise.getTranslation(Localizations.localeOf(context).languageCode).name, + exercise.getTranslation(Localizations.localeOf(context).languageCode).name, _controller, - totalPages: _totalPages, - exercisePages: _exercisePages, ), Expanded( child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: ExerciseDetail(_exercise), + child: ExerciseDetail(exercise), ), ), ), - NavigationFooter(_controller, _ratioCompleted), + NavigationFooter(_controller), ], ); } diff --git a/lib/widgets/routines/gym_mode/gym_mode.dart b/lib/widgets/routines/gym_mode/gym_mode.dart index 644a21b3..216fb7ba 100644 --- a/lib/widgets/routines/gym_mode/gym_mode.dart +++ b/lib/widgets/routines/gym_mode/gym_mode.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + import 'dart:async'; import 'package:flutter/material.dart'; @@ -25,159 +26,98 @@ import 'package:wger/models/workouts/day_data.dart'; import 'package:wger/providers/exercise_state_notifier.dart'; import 'package:wger/providers/gym_state.dart'; import 'package:wger/providers/routines.dart'; -import 'package:wger/widgets/routines/gym_mode/exercise_overview.dart'; -import 'package:wger/widgets/routines/gym_mode/log_page.dart'; -import 'package:wger/widgets/routines/gym_mode/session_page.dart'; -import 'package:wger/widgets/routines/gym_mode/start_page.dart'; -import 'package:wger/widgets/routines/gym_mode/timer.dart'; +import 'package:wger/screens/gym_mode.dart'; +import 'package:wger/widgets/core/progress_indicator.dart'; + +import 'exercise_overview.dart'; +import 'log_page.dart'; +import 'session_page.dart'; +import 'start_page.dart'; +import 'summary.dart'; +import 'timer.dart'; class GymMode extends ConsumerStatefulWidget { - final DayData _dayDataGym; - final DayData _dayDataDisplay; - final int _iteration; + final GymModeArguments _args; final _logger = Logger('GymMode'); - GymMode(this._dayDataGym, this._dayDataDisplay, this._iteration); + GymMode(this._args); @override ConsumerState createState() => _GymModeState(); } class _GymModeState extends ConsumerState { - var _totalElements = 1; - var _totalPages = 1; late Future _initData; bool _initialPageJumped = false; - - /// Map with the first (navigation) page for each exercise - final Map _exercisePages = {}; late final PageController _controller; + @override + void initState() { + super.initState(); + _controller = PageController(initialPage: 0); + _initData = _loadGymState(); + } + @override void dispose() { _controller.dispose(); super.dispose(); } - @override - void initState() { - super.initState(); - _initData = _loadGymState(); - _controller = PageController(initialPage: 0); - _calculatePages(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(gymStateProvider.notifier).setExercisePages(_exercisePages); - }); - } - Future _loadGymState() async { - final validUntil = ref.read(gymStateProvider).validUntil; - final currentPage = ref.read(gymStateProvider).currentPage; - final savedDayId = ref.read(gymStateProvider).dayId; - final newDayId = widget._dayDataGym.day!.id!; - - final shouldReset = newDayId != savedDayId || validUntil.isBefore(DateTime.now()); - if (shouldReset) { - widget._logger.fine('Day ID mismatch or expired validUntil date. Resetting to page 0.'); - } - final initialPage = shouldReset ? 0 : currentPage; - - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(gymStateProvider.notifier) - ..setDayId(newDayId) - ..setCurrentPage(initialPage); - }); + widget._logger.fine('Loading gym state'); + final routine = await context.read().fetchAndSetRoutineFull( + widget._args.routineId, + ); + final gymViewModel = ref.read(gymStateProvider.notifier); + final initialPage = gymViewModel.initData( + routine, + widget._args.dayId, + widget._args.iteration, + ); + await gymViewModel.loadPrefs(); + gymViewModel.calculatePages(); return initialPage; } - void _calculatePages() { - for (final slot in widget._dayDataGym.slots) { - _totalElements += slot.setConfigs.length; - // add 1 for each exercise - _totalPages += 1; - for (final config in slot.setConfigs) { - // add nrOfSets * 2, 1 for log page and 1 for timer - _totalPages += (config.nrOfSets! * 2).toInt(); - } - } - _exercisePages.clear(); - var currentPage = 1; - - for (final slot in widget._dayDataGym.slots) { - var firstPage = true; - for (final config in slot.setConfigs) { - final exercise = ref.read(exerciseStateProvider.notifier).getById(config.exerciseId); - - if (firstPage) { - _exercisePages[exercise] = currentPage; - currentPage++; - } - currentPage += 2; - firstPage = false; - } - } - } - - List getContent() { - final state = ref.watch(gymStateProvider); - final exercisesProvider = ref.read(exerciseStateProvider.notifier); - final routinesProvider = ref.watch(routinesRiverpodProvider); - var currentElement = 1; + List _getContent(GymModeState state) { + final gymState = ref.watch(gymStateProvider); final List out = []; - for (final slotData in widget._dayDataGym.slots) { - var firstPage = true; - for (final config in slotData.setConfigs) { - final ratioCompleted = currentElement / _totalElements; - final exercise = exercisesProvider.getById(config.exerciseId); - currentElement++; + // Workout overview + out.add(StartPage(_controller)); - if (firstPage && state.showExercisePages) { - out.add( - ExerciseOverview( - _controller, - exercise, - ratioCompleted, - state.exercisePages, - _totalPages, - ), - ); + // Sets + for (final page in state.pages) { + for (final slotPage in page.slotPages) { + if (slotPage.type == SlotPageType.exerciseOverview) { + out.add(ExerciseOverview(_controller)); } - out.add( - LogPage( - _controller, - config, - slotData, - exercise, - routinesProvider.findById(widget._dayDataGym.day!.routineId), - ratioCompleted, - state.exercisePages, - _totalPages, - widget._iteration, - ), - ); - - // If there is a rest time, add a countdown timer - if (config.restTime != null) { - out.add( - TimerCountdownWidget( - _controller, - config.restTime!.toInt(), - ratioCompleted, - state.exercisePages, - _totalPages, - ), - ); - } else { - out.add(TimerWidget(_controller, ratioCompleted, state.exercisePages, _totalPages)); + if (slotPage.type == SlotPageType.log) { + out.add(LogPage(_controller)); } - firstPage = false; + // Timer. Use rest time from config data if available, otherwise use user settings + final rest = slotPage.setConfigData?.restTime; + if (slotPage.type == SlotPageType.timer) { + out.add( + (rest != null || gymState.useCountdownBetweenSets) + ? TimerCountdownWidget( + _controller, + (rest ?? gymState.countdownDuration.inSeconds).toInt(), + ) + : TimerWidget(_controller), + ); + } } } + + // End + out.add(SessionPage(_controller)); + out.add(WorkoutSummary(_controller)); + return out; } @@ -187,43 +127,40 @@ class _GymModeState extends ConsumerState { future: _initData, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); + return const BoxedProgressIndicator(); } else if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); + return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}')); + } else if (snapshot.connectionState == ConnectionState.done) { + final initialPage = snapshot.data!; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_initialPageJumped && _controller.hasClients) { + _controller.jumpToPage(initialPage); + setState(() => _initialPageJumped = true); + } + }); + + final state = ref.watch(gymStateProvider); + final children = [ + ..._getContent(state), + ]; + + return PageView( + controller: _controller, + onPageChanged: (page) { + ref.read(gymStateProvider.notifier).setCurrentPage(page); + + // Check if the last page is reached + if (page == children.length - 1) { + widget._logger.finer('Last page reached, clearing gym state'); + ref.read(gymStateProvider.notifier).clear(); + } + }, + children: children, + ); } - final initialPage = snapshot.data!; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_initialPageJumped && _controller.hasClients) { - _controller.jumpToPage(initialPage); - setState(() => _initialPageJumped = true); - } - }); - - final List children = [ - StartPage(_controller, widget._dayDataDisplay, _exercisePages), - ...getContent(), - SessionPage( - ref.read(routinesRiverpodProvider).findById(widget._dayDataGym.day!.routineId), - _controller, - ref.read(gymStateProvider).startTime, - _exercisePages, - dayId: widget._dayDataGym.day!.id, - ), - ]; - - return PageView( - controller: _controller, - onPageChanged: (page) { - ref.read(gymStateProvider.notifier).setCurrentPage(page); - - // Check if the last page is reached - if (page == children.length - 1) { - ref.read(gymStateProvider.notifier).clear(); - } - }, - children: children, - ); + return const Center(child: Text('Unexpected state')); }, ); } diff --git a/lib/widgets/routines/gym_mode/log_page.dart b/lib/widgets/routines/gym_mode/log_page.dart index 2b31a95e..8edc8ea0 100644 --- a/lib/widgets/routines/gym_mode/log_page.dart +++ b/lib/widgets/routines/gym_mode/log_page.dart @@ -21,12 +21,10 @@ import 'package:intl/intl.dart'; import 'package:logging/logging.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/log.dart'; -import 'package:wger/models/workouts/routine.dart'; import 'package:wger/models/workouts/set_config_data.dart'; -import 'package:wger/models/workouts/slot_data.dart'; import 'package:wger/models/workouts/slot_entry.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/providers/plate_weights.dart'; import 'package:wger/providers/workout_logs.dart'; import 'package:wger/screens/configure_plates_screen.dart'; @@ -39,27 +37,11 @@ import 'package:wger/widgets/routines/gym_mode/navigation.dart'; import 'package:wger/widgets/routines/plate_calculator.dart'; class LogPage extends ConsumerStatefulWidget { - final PageController _controller; - final SetConfigData _configData; - final SlotData _slotData; - final Exercise _exercise; - final Routine _routine; - final double _ratioCompleted; - final Map _exercisePages; - final Log _log; - final int _totalPages; + final _logger = Logger('LogPage'); - LogPage( - this._controller, - this._configData, - this._slotData, - this._exercise, - this._routine, - this._ratioCompleted, - this._exercisePages, - this._totalPages, - int? iteration, - ) : _log = Log.fromSetConfigData(_configData, routineId: _routine.id, iteration: iteration); + final PageController _controller; + + LogPage(this._controller); @override _LogPageState createState() => _LogPageState(); @@ -85,14 +67,40 @@ class _LogPageState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final state = ref.watch(gymStateProvider); + + final page = state.getPageByIndex(); + if (page == null) { + widget._logger.info( + 'getPageByIndex for ${state.currentPage} returned null, showing empty container.', + ); + return Container(); + } + + final slotEntryPage = state.getSlotEntryPageByIndex(); + if (slotEntryPage == null) { + widget._logger.info( + 'getSlotPageByIndex for ${state.currentPage} returned null, showing empty container', + ); + return Container(); + } + + final setConfigData = slotEntryPage.setConfigData!; + + final log = Log.fromSetConfigData(setConfigData) + ..routineId = state.routine.id! + ..iteration = state.iteration; + + // Mark done sets + final decorationStyle = slotEntryPage.logDone + ? TextDecoration.lineThrough + : TextDecoration.none; return Column( children: [ NavigationHeader( - widget._exercise.getTranslation(Localizations.localeOf(context).languageCode).name, + log.exercise.getTranslation(Localizations.localeOf(context).languageCode).name, widget._controller, - totalPages: widget._totalPages, - exercisePages: widget._exercisePages, ), Container( @@ -101,34 +109,47 @@ class _LogPageState extends ConsumerState { child: Center( child: Column( children: [ + Column( + children: [ + Text( + setConfigData.textRepr, + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: decorationStyle, + ), + ), + if (setConfigData.type != SlotEntryType.normal) + Text( + setConfigData.type.name.toUpperCase(), + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: decorationStyle, + ), + ), + ], + ), Text( - widget._configData.textRepr, - style: theme.textTheme.headlineMedium?.copyWith( + '${slotEntryPage.setIndex + 1} / ${page.slotPages.where((e) => e.type == SlotPageType.log).length}', + style: theme.textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.primary, ), textAlign: TextAlign.center, ), - if (widget._configData.type != SlotEntryType.normal) - Text( - widget._configData.type.name.toUpperCase(), - style: theme.textTheme.headlineMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - textAlign: TextAlign.center, - ), ], ), ), ), - if (widget._log.exerciseObj.showPlateCalculator) const LogsPlatesWidget(), - if (widget._slotData.comment.isNotEmpty) - Text(widget._slotData.comment, textAlign: TextAlign.center), + if (log.exercise.showPlateCalculator) const LogsPlatesWidget(), + if (slotEntryPage.setConfigData!.comment.isNotEmpty) + Text(slotEntryPage.setConfigData!.comment, textAlign: TextAlign.center), const SizedBox(height: 10), Expanded( - child: (widget._routine.filterLogsByExercise(widget._exercise.id).isNotEmpty) + child: (state.routine.filterLogsByExercise(log.exercise.id!).isNotEmpty) ? LogsPastLogsWidget( - log: widget._log, - pastLogs: widget._routine.filterLogsByExercise(widget._exercise.id), + log: log, + pastLogs: state.routine.filterLogsByExercise(log.exercise.id!), onCopy: (pastLog) { _logFormKey.currentState?.copyFromPastLog(pastLog); }, @@ -149,14 +170,14 @@ class _LogPageState extends ConsumerState { child: LogFormWidget( key: _logFormKey, controller: widget._controller, - configData: widget._configData, - log: widget._log, + configData: setConfigData, + log: log, focusNode: focusNode, ), ), ), ), - NavigationFooter(widget._controller, widget._ratioCompleted), + NavigationFooter(widget._controller), ], ); } @@ -290,8 +311,10 @@ class LogsRepsWidget extends StatelessWidget { IconButton( icon: const Icon(Icons.add, color: Colors.black), onPressed: () { + final value = controller.text.isNotEmpty ? controller.text : '0'; + try { - final newValue = numberFormat.parse(controller.text) + repsValueChange; + final newValue = numberFormat.parse(value) + repsValueChange; setStateCallback(() { log.repetitions = newValue; controller.text = numberFormat.format(newValue); @@ -389,8 +412,10 @@ class LogsWeightWidget extends ConsumerWidget { IconButton( icon: const Icon(Icons.add, color: Colors.black), onPressed: () { + final value = controller.text.isNotEmpty ? controller.text : '0'; + try { - final newValue = numberFormat.parse(controller.text) + weightValueChange; + final newValue = numberFormat.parse(value) + weightValueChange; setStateCallback(() { log.weight = newValue; controller.text = numberFormat.format(newValue); @@ -437,7 +462,8 @@ class LogsPastLogsWidget extends StatelessWidget { ), ...pastLogs.map((pastLog) { return ListTile( - title: Text(pastLog.singleLogRepTextNoNl), + key: ValueKey('past-log-${pastLog.id}'), + title: Text(pastLog.repTextNoNl(context)), subtitle: Text( DateFormat.yMd(Localizations.localeOf(context).languageCode).format(pastLog.date), ), @@ -468,12 +494,14 @@ class LogsPastLogsWidget extends StatelessWidget { } class LogFormWidget extends ConsumerStatefulWidget { + final _logger = Logger('LogFormWidget'); + final PageController controller; final SetConfigData configData; final Log log; final FocusNode focusNode; - const LogFormWidget({ + LogFormWidget({ super.key, required this.controller, required this.configData, @@ -489,6 +517,7 @@ class _LogFormWidgetState extends ConsumerState { final _form = GlobalKey(); var _detailed = false; bool _isSaving = false; + late Log _log; late final TextEditingController _repetitionsController; late final TextEditingController _weightController; @@ -497,6 +526,7 @@ class _LogFormWidgetState extends ConsumerState { void initState() { super.initState(); + _log = widget.log; _repetitionsController = TextEditingController(); _weightController = TextEditingController(); @@ -529,7 +559,13 @@ class _LogFormWidgetState extends ConsumerState { _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}'); }); } @@ -556,7 +592,7 @@ class _LogFormWidgetState extends ConsumerState { controller: _repetitionsController, configData: widget.configData, focusNode: widget.focusNode, - log: widget.log, + log: _log, setStateCallback: (fn) { setState(fn); }, @@ -568,7 +604,7 @@ class _LogFormWidgetState extends ConsumerState { controller: _weightController, configData: widget.configData, focusNode: widget.focusNode, - log: widget.log, + log: _log, setStateCallback: (fn) { setState(fn); }, @@ -585,7 +621,7 @@ class _LogFormWidgetState extends ConsumerState { controller: _repetitionsController, configData: widget.configData, focusNode: widget.focusNode, - log: widget.log, + log: _log, setStateCallback: (fn) { setState(fn); }, @@ -594,7 +630,7 @@ class _LogFormWidgetState extends ConsumerState { const SizedBox(width: 8), Flexible( child: RepetitionUnitInputWidget( - widget.log.repetitionsUnitId, + _log.repetitionsUnitId, onChanged: (v) => {}, ), ), @@ -610,7 +646,7 @@ class _LogFormWidgetState extends ConsumerState { controller: _weightController, configData: widget.configData, focusNode: widget.focusNode, - log: widget.log, + log: _log, setStateCallback: (fn) { setState(fn); }, @@ -618,19 +654,19 @@ class _LogFormWidgetState extends ConsumerState { ), const SizedBox(width: 8), Flexible( - child: WeightUnitInputWidget(widget.log.weightUnitId, onChanged: (v) => {}), + child: WeightUnitInputWidget(_log.weightUnitId, onChanged: (v) => {}), ), const SizedBox(width: 8), ], ), if (_detailed) RiRInputWidget( - widget.log.rir, + _log.rir, onChanged: (value) { if (value == '') { - widget.log.rir = null; + _log.rir = null; } else { - widget.log.rir = num.parse(value); + _log.rir = num.parse(value); } }, ), @@ -657,8 +693,13 @@ class _LogFormWidgetState extends ConsumerState { }); _form.currentState!.save(); - widget.log.id = null; - logProvider.addEntry(widget.log); + try { + final gymState = ref.read(gymStateProvider); + final gymProvider = ref.read(gymStateProvider.notifier); + + logProvider.addEntry(_log); + final page = gymState.getSlotEntryPageByIndex()!; + gymProvider.markSlotPageAsDone(page.uuid, isDone: true); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/routines/gym_mode/navigation.dart b/lib/widgets/routines/gym_mode/navigation.dart index 3ff2391c..d2572b52 100644 --- a/lib/widgets/routines/gym_mode/navigation.dart +++ b/lib/widgets/routines/gym_mode/navigation.dart @@ -17,130 +17,18 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/helpers/consts.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/exercises/exercise.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/theme/theme.dart'; - -class NavigationFooter extends StatelessWidget { - final PageController _controller; - final double _ratioCompleted; - final bool showPrevious; - final bool showNext; - - const NavigationFooter( - this._controller, - this._ratioCompleted, { - this.showPrevious = true, - this.showNext = true, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - if (showPrevious) - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () { - _controller.previousPage( - duration: DEFAULT_ANIMATION_DURATION, - curve: DEFAULT_ANIMATION_CURVE, - ); - }, - ) - else - const SizedBox(width: 48), - Expanded( - child: LinearProgressIndicator( - minHeight: 3, - value: _ratioCompleted, - valueColor: const AlwaysStoppedAnimation(wgerPrimaryColor), - ), - ), - if (showNext) - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: () { - _controller.nextPage( - duration: DEFAULT_ANIMATION_DURATION, - curve: DEFAULT_ANIMATION_CURVE, - ); - }, - ) - else - const SizedBox(width: 48), - ], - ); - } -} +import 'package:wger/widgets/routines/gym_mode/workout_menu.dart'; class NavigationHeader extends StatelessWidget { final PageController _controller; final String _title; - final Map exercisePages; - final int? totalPages; + final bool showEndWorkoutButton; - const NavigationHeader( - this._title, - this._controller, { - this.totalPages, - required this.exercisePages, - }); - - Widget getDialog(BuildContext context) { - final TextButton? endWorkoutButton = totalPages != null - ? TextButton( - child: Text(AppLocalizations.of(context).endWorkout), - onPressed: () { - _controller.animateToPage( - totalPages!, - duration: DEFAULT_ANIMATION_DURATION, - curve: DEFAULT_ANIMATION_CURVE, - ); - - Navigator.of(context).pop(); - }, - ) - : null; - - return AlertDialog( - title: Text( - AppLocalizations.of(context).jumpTo, - textAlign: TextAlign.center, - ), - contentPadding: EdgeInsets.zero, - content: SingleChildScrollView( - child: Column( - children: [ - ...exercisePages.keys.map((e) { - return ListTile( - title: Text(e.getTranslation(Localizations.localeOf(context).languageCode).name), - trailing: const Icon(Icons.chevron_right), - onTap: () { - _controller.animateToPage( - exercisePages[e]!, - duration: DEFAULT_ANIMATION_DURATION, - curve: DEFAULT_ANIMATION_CURVE, - ); - Navigator.of(context).pop(); - }, - ); - }), - ], - ), - ), - actions: [ - ?endWorkoutButton, - TextButton( - child: Text(MaterialLocalizations.of(context).closeButtonLabel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ], - ); - } + const NavigationHeader(this._title, this._controller, {this.showEndWorkoutButton = true}); @override Widget build(BuildContext context) { @@ -163,11 +51,11 @@ class NavigationHeader extends StatelessWidget { ), ), IconButton( - icon: const Icon(Icons.toc), + icon: const Icon(Icons.menu), onPressed: () { showDialog( context: context, - builder: (ctx) => getDialog(context), + builder: (ctx) => WorkoutMenuDialog(_controller), ); }, ), @@ -175,3 +63,65 @@ class NavigationHeader extends StatelessWidget { ); } } + +class NavigationFooter extends ConsumerWidget { + final PageController _controller; + final bool showPrevious; + final bool showNext; + + const NavigationFooter( + this._controller, { + this.showPrevious = true, + this.showNext = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gymState = ref.watch(gymStateProvider); + + return Row( + children: [ + if (showPrevious) + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + _controller.previousPage( + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + }, + ) + else + const SizedBox(width: 48), + Expanded( + child: GestureDetector( + onTap: () => showDialog( + context: context, + builder: (ctx) => WorkoutMenuDialog(_controller, initialIndex: 1), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: LinearProgressIndicator( + minHeight: 3, + value: gymState.ratioCompleted, + valueColor: const AlwaysStoppedAnimation(wgerPrimaryColor), + ), + ), + ), + ), + if (showNext) + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + _controller.nextPage( + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + }, + ) + else + const SizedBox(width: 48), + ], + ); + } +} diff --git a/lib/widgets/routines/gym_mode/session_page.dart b/lib/widgets/routines/gym_mode/session_page.dart index 0e86bd6b..51830cb5 100644 --- a/lib/widgets/routines/gym_mode/session_page.dart +++ b/lib/widgets/routines/gym_mode/session_page.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -17,58 +17,57 @@ */ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/exercises/exercise.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/widgets/routines/forms/session.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; -class SessionPage extends StatelessWidget { - final Routine _routine; - final WorkoutSession _session; +class SessionPage extends ConsumerWidget { final PageController _controller; - final Map _exercisePages; - SessionPage( - this._routine, - this._controller, - TimeOfDay start, - this._exercisePages, { - int? dayId, - }) : _session = _routine.sessions.firstWhere( - (session) => session.date.isSameDayAs(clock.now()), - orElse: () => WorkoutSession( - dayId: dayId, - routineId: _routine.id, - impression: DEFAULT_IMPRESSION, - date: clock.now(), - timeStart: start, - timeEnd: TimeOfDay.fromDateTime(clock.now()), - ), - ); + const SessionPage(this._controller); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gymStateProvider); + + 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()), + ), + ); + return Column( children: [ NavigationHeader( AppLocalizations.of(context).workoutSession, _controller, - exercisePages: _exercisePages, ), Expanded(child: Container()), Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: SessionForm( - _routine.id!, - onSaved: () => Navigator.of(context).pop(), - session: _session, + state.routine.id, + onSaved: () => _controller.nextPage( + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ), + session: session, ), ), - NavigationFooter(_controller, 1, showNext: false), + NavigationFooter(_controller), ], ); } diff --git a/lib/widgets/routines/gym_mode/start_page.dart b/lib/widgets/routines/gym_mode/start_page.dart index ca672133..f609a142 100644 --- a/lib/widgets/routines/gym_mode/start_page.dart +++ b/lib/widgets/routines/gym_mode/start_page.dart @@ -1,78 +1,243 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (C) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * wger Workout Manager is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/exercises/exercise.dart'; import 'package:wger/models/workouts/day.dart'; -import 'package:wger/models/workouts/day_data.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/widgets/exercises/images.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; -class StartPage extends StatelessWidget { - final PageController _controller; - final DayData _dayData; - final Map _exercisePages; +class GymModeOptions extends ConsumerStatefulWidget { + const GymModeOptions({super.key}); - const StartPage(this._controller, this._dayData, this._exercisePages); + @override + ConsumerState createState() => _GymModeOptionsState(); +} + +class _GymModeOptionsState extends ConsumerState { + bool _showOptions = false; + late TextEditingController _countdownController; + + @override + void initState() { + super.initState(); + final initial = ref.read(gymStateProvider).countdownDuration.inSeconds.toString(); + _countdownController = TextEditingController(text: initial); + } + + @override + void dispose() { + _countdownController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final gymState = ref.watch(gymStateProvider); + final gymNotifier = ref.watch(gymStateProvider.notifier); + final i18n = AppLocalizations.of(context); + + // If the value in the provider changed, update the controller text + final currentText = gymState.countdownDuration.inSeconds.toString(); + if (_countdownController.text != currentText) { + _countdownController.text = currentText; + } + + return Column( + children: [ + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: Card( + child: SingleChildScrollView( + child: Column( + children: [ + SwitchListTile( + key: const ValueKey('gym-mode-option-show-exercises'), + title: Text(i18n.gymModeShowExercises), + value: gymState.showExercisePages, + onChanged: (value) => gymNotifier.setShowExercisePages(value), + ), + SwitchListTile( + key: const ValueKey('gym-mode-option-show-timer'), + title: Text(i18n.gymModeShowTimer), + value: gymState.showTimerPages, + onChanged: (value) => gymNotifier.setShowTimerPages(value), + ), + ListTile( + key: const ValueKey('gym-mode-timer-type'), + enabled: gymState.showTimerPages, + title: Text(i18n.gymModeTimerType), + trailing: DropdownButton( + key: const ValueKey('countdown-type-dropdown'), + value: gymState.useCountdownBetweenSets, + onChanged: gymState.showTimerPages + ? (bool? newValue) { + if (newValue != null) { + gymNotifier.setUseCountdownBetweenSets(newValue); + } + } + : null, + items: [false, true].map>((bool value) { + final label = value ? i18n.countdown : i18n.stopwatch; + + return DropdownMenuItem(value: value, child: Text(label)); + }).toList(), + ), + subtitle: Text(i18n.gymModeTimerTypeHelText), + ), + ListTile( + key: const ValueKey('gym-mode-default-countdown-time'), + enabled: gymState.showTimerPages, + title: TextFormField( + controller: _countdownController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: i18n.gymModeDefaultCountdownTime, + suffix: IconButton( + onPressed: gymState.showTimerPages && gymState.useCountdownBetweenSets + ? () => gymNotifier.setCountdownDuration( + DEFAULT_COUNTDOWN_DURATION, + ) + : null, + icon: const Icon(Icons.refresh), + ), + ), + onChanged: (value) { + final intValue = int.tryParse(value); + if (intValue != null && + intValue > 0 && + intValue < MAX_COUNTDOWN_DURATION) { + gymNotifier.setCountdownDuration(intValue); + } + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (String? value) { + final intValue = int.tryParse(value!); + if (intValue == null || + intValue < MIN_COUNTDOWN_DURATION || + intValue > MAX_COUNTDOWN_DURATION) { + return i18n.formMinMaxValues( + MIN_COUNTDOWN_DURATION, + MAX_COUNTDOWN_DURATION, + ); + } + return null; + }, + enabled: gymState.showTimerPages && gymState.useCountdownBetweenSets, + ), + ), + SwitchListTile( + key: const ValueKey('gym-mode-notify-countdown'), + title: Text(i18n.gymModeNotifyOnCountdownFinish), + value: gymState.alertOnCountdownEnd, + onChanged: (gymState.showTimerPages && gymState.useCountdownBetweenSets) + ? (value) => gymNotifier.setAlertOnCountdownEnd(value) + : null, + ), + ], + ), + ), + ), + ), + ), + crossFadeState: _showOptions ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + + ListTile( + key: const ValueKey('gym-mode-options-tile'), + title: Text(i18n.settingsTitle), + leading: const Icon(Icons.settings), + onTap: () => setState(() => _showOptions = !_showOptions), + ), + ], + ); + } +} + +class StartPage extends ConsumerWidget { + final PageController _controller; + + const StartPage(this._controller); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dayDataDisplay = ref.watch(gymStateProvider).dayDataDisplay; + return Column( children: [ NavigationHeader( AppLocalizations.of(context).todaysWorkout, _controller, - exercisePages: _exercisePages, + showEndWorkoutButton: false, ), + Expanded( child: ListView( children: [ - if (_dayData.day!.isSpecialType) + if (dayDataDisplay.day!.isSpecialType) Center( child: Padding( padding: const EdgeInsets.all(15), child: Text( - '${_dayData.day!.type.name.toUpperCase()}\n${_dayData.day!.type.i18Label(AppLocalizations.of(context))}', + '${dayDataDisplay.day!.type.name.toUpperCase()}\n${dayDataDisplay.day!.type.i18Label(AppLocalizations.of(context))}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), ), ), - ..._dayData.slots.map((slotData) { - return Column( - children: [ - ...slotData.setConfigs - .fold>>({}, (acc, entry) { - acc.putIfAbsent(entry.exercise, () => []).add(entry.textReprWithType); - return acc; - }) - .entries - .map((entry) { - final exercise = entry.key; - return Column( - children: [ - ListTile( - leading: SizedBox( - width: 45, - child: ExerciseImageWidget(image: exercise.getMainImage), - ), - title: Text( - exercise - .getTranslation(Localizations.localeOf(context).languageCode) - .name, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entry.value.map((text) => Text(text)).toList(), - ), - ), - ], - ); - }), - ], - ); - }), + ...dayDataDisplay.slots + .expand((slot) => slot.setConfigs) + .fold>>({}, (acc, entry) { + acc.putIfAbsent(entry.exercise, () => []).add(entry.textReprWithType); + return acc; + }) + .entries + .map((entry) { + final exercise = entry.key; + return Column( + children: [ + ListTile( + leading: SizedBox( + width: 45, + child: ExerciseImageWidget(image: exercise.getMainImage), + ), + title: Text( + exercise + .getTranslation(Localizations.localeOf(context).languageCode) + .name, + ), + subtitle: Text(entry.value.toList().join('\n')), + ), + ], + ); + }), ], ), ), + const GymModeOptions(), FilledButton( child: Text(AppLocalizations.of(context).start), onPressed: () { @@ -82,7 +247,7 @@ class StartPage extends StatelessWidget { ); }, ), - NavigationFooter(_controller, 0, showPrevious: false), + NavigationFooter(_controller, showPrevious: false), ], ); } diff --git a/lib/widgets/routines/gym_mode/summary.dart b/lib/widgets/routines/gym_mode/summary.dart new file mode 100644 index 00000000..439d7a0d --- /dev/null +++ b/lib/widgets/routines/gym_mode/summary.dart @@ -0,0 +1,209 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/helpers/date.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/workouts/routine.dart'; +import 'package:wger/models/workouts/session_api.dart'; +import 'package:wger/providers/gym_state.dart'; +import 'package:wger/providers/routines.dart'; +import 'package:wger/widgets/core/progress_indicator.dart'; +import 'package:wger/widgets/routines/gym_mode/navigation.dart'; + +import '../logs/exercises_expansion_card.dart'; +import '../logs/muscle_groups.dart'; + +class WorkoutSummary extends ConsumerStatefulWidget { + final _logger = Logger('WorkoutSummary'); + + final PageController _controller; + + WorkoutSummary(this._controller); + + @override + ConsumerState createState() => _WorkoutSummaryState(); +} + +class _WorkoutSummaryState extends ConsumerState { + late Future _initData; + late Routine _routine; + + @override + void initState() { + super.initState(); + _initData = _reloadRoutineData(); + } + + Future _reloadRoutineData() async { + widget._logger.fine('Loading routine data'); + final gymState = ref.read(gymStateProvider); + + _routine = await context.read().fetchAndSetRoutineFull( + gymState.routine.id!, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + NavigationHeader( + AppLocalizations.of(context).workoutCompleted, + widget._controller, + showEndWorkoutButton: false, + ), + Expanded( + child: FutureBuilder( + future: _initData, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const BoxedProgressIndicator(); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}')); + } else if (snapshot.connectionState == ConnectionState.done) { + return WorkoutSessionStats( + _routine.sessions.firstWhereOrNull( + (s) => s.session.date.isSameDayAs(clock.now()), + ), + ); + } + + return const Center(child: Text('Unexpected state!')); + }, + ), + ), + NavigationFooter(widget._controller, showNext: false), + ], + ); + } +} + +class WorkoutSessionStats extends ConsumerWidget { + final _logger = Logger('WorkoutSessionStats'); + final WorkoutSessionApi? _sessionApi; + + WorkoutSessionStats(this._sessionApi, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final i18n = AppLocalizations.of(context); + + if (_sessionApi == null) { + return Center( + child: Text( + 'Nothing logged yet.', + style: Theme.of(context).textTheme.titleMedium, + ), + ); + } + + final session = _sessionApi.session; + final sessionDuration = session.duration; + final totalVolume = _sessionApi.volume; + + /// We assume that users will do exercises (mostly) either in metric or imperial + /// units so we just display the higher one. + String volumeUnit; + num volumeValue; + if (totalVolume['metric']! > totalVolume['imperial']!) { + volumeValue = totalVolume['metric']!; + volumeUnit = i18n.kg; + } else { + volumeValue = totalVolume['imperial']!; + volumeUnit = i18n.lb; + } + + return ListView( + padding: const EdgeInsets.all(16.0), + children: [ + Row( + children: [ + Expanded( + child: InfoCard( + title: i18n.duration, + value: sessionDuration != null + ? i18n.durationHoursMinutes( + sessionDuration.inHours, + sessionDuration.inMinutes.remainder(60), + ) + : '-/-', + ), + ), + const SizedBox(width: 10), + Expanded( + child: InfoCard( + title: i18n.volume, + value: '${volumeValue.toStringAsFixed(0)} $volumeUnit', + ), + ), + ], + ), + // const SizedBox(height: 16), + // InfoCard( + // title: 'Personal Records', + // value: prCount.toString(), + // color: theme.colorScheme.tertiaryContainer, + // ), + const SizedBox(height: 10), + MuscleGroupsCard(_sessionApi.logs), + const SizedBox(height: 10), + ExercisesCard(_sessionApi), + FilledButton( + onPressed: () { + ref.read(gymStateProvider.notifier).clear(); + Navigator.of(context).pop(); + }, + child: Text(i18n.endWorkout), + ), + ], + ); + } +} + +class InfoCard extends StatelessWidget { + final String title; + final String value; + final Color? color; + + const InfoCard({required this.title, required this.value, this.color, super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + color: color, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(value, style: theme.textTheme.headlineMedium), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/routines/gym_mode/timer.dart b/lib/widgets/routines/gym_mode/timer.dart index e9ccb548..9d2201ee 100644 --- a/lib/widgets/routines/gym_mode/timer.dart +++ b/lib/widgets/routines/gym_mode/timer.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -18,19 +18,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/exercises/exercise.dart'; +import 'package:wger/providers/gym_state.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; class TimerWidget extends StatefulWidget { final PageController _controller; - final double _ratioCompleted; - final Map _exercisePages; - final _totalPages; - const TimerWidget(this._controller, this._ratioCompleted, this._exercisePages, this._totalPages); + const TimerWidget(this._controller); @override _TimerWidgetState createState() => _TimerWidgetState(); @@ -69,8 +68,6 @@ class _TimerWidgetState extends State { NavigationHeader( AppLocalizations.of(context).pause, widget._controller, - totalPages: widget._totalPages, - exercisePages: widget._exercisePages, ), Expanded( child: Center( @@ -80,35 +77,31 @@ class _TimerWidgetState extends State { ), ), ), - NavigationFooter(widget._controller, widget._ratioCompleted), + NavigationFooter(widget._controller), ], ); } } -class TimerCountdownWidget extends StatefulWidget { +class TimerCountdownWidget extends ConsumerStatefulWidget { final PageController _controller; - final double _ratioCompleted; final int _seconds; - final Map _exercisePages; - final int _totalPages; const TimerCountdownWidget( this._controller, this._seconds, - this._ratioCompleted, - this._exercisePages, - this._totalPages, ); @override _TimerCountdownWidgetState createState() => _TimerCountdownWidgetState(); } -class _TimerCountdownWidgetState extends State { +class _TimerCountdownWidgetState extends ConsumerState { late DateTime _endTime; late Timer _uiTimer; + bool _hasNotified = false; + @override void initState() { super.initState(); @@ -131,24 +124,40 @@ class _TimerCountdownWidgetState extends State { final remaining = _endTime.difference(DateTime.now()); final remainingSeconds = remaining.inSeconds <= 0 ? 0 : remaining.inSeconds; final displayTime = DateTime(2000, 1, 1, 0, 0, 0).add(Duration(seconds: remainingSeconds)); + final gymState = ref.watch(gymStateProvider); + + // When countdown finishes, notify ONCE, and respect settings + if (remainingSeconds == 0 && !_hasNotified) { + if (gymState.alertOnCountdownEnd) { + HapticFeedback.mediumImpact(); + + // Not that this only works on desktop platforms + SystemSound.play(SystemSoundType.alert); + } + setState(() { + _hasNotified = true; + }); + } return Column( children: [ NavigationHeader( AppLocalizations.of(context).pause, widget._controller, - totalPages: widget._totalPages, - exercisePages: widget._exercisePages, ), Expanded( - child: Center( - child: Text( - DateFormat('m:ss').format(displayTime), - style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor), - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat('m:ss').format(displayTime), + style: Theme.of(context).textTheme.displayLarge!.copyWith(color: wgerPrimaryColor), + ), + const SizedBox(height: 16), + ], ), ), - NavigationFooter(widget._controller, widget._ratioCompleted), + NavigationFooter(widget._controller), ], ); } diff --git a/lib/widgets/routines/gym_mode/workout_menu.dart b/lib/widgets/routines/gym_mode/workout_menu.dart new file mode 100644 index 00000000..84bcb191 --- /dev/null +++ b/lib/widgets/routines/gym_mode/workout_menu.dart @@ -0,0 +1,454 @@ +// /* +// * This file is part of wger Workout Manager . +// * Copyright (C) 2020, 2025 wger Team +// * +// * wger Workout Manager is free software: you can redistribute it and/or modify +// * it under the terms of the GNU Affero General Public License as published by +// * the Free Software Foundation, either version 3 of the License, or +// * (at your option) any later version. +// * +// * wger Workout Manager is distributed in the hope that it will be useful, +// * but WITHOUT ANY WARRANTY; without even the implied warranty of +// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// * GNU Affero General Public License for more details. +// * +// * You should have received a copy of the GNU Affero General Public License +// * along with this program. If not, see . +// */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/gym_state.dart'; +import 'package:wger/widgets/exercises/autocompleter.dart'; + +class WorkoutMenu extends StatelessWidget { + final PageController _controller; + final int initialIndex; + + const WorkoutMenu(this._controller, {this.initialIndex = 0, super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: initialIndex, + length: 2, + child: Column( + children: [ + const TabBar( + tabs: [ + Tab(icon: Icon(Icons.menu_open)), + Tab(icon: Icon(Icons.stacked_bar_chart)), + ], + ), + Flexible( + child: TabBarView( + children: [ + NavigationTab(_controller), + ProgressionTab(_controller), + ], + ), + ), + ], + ), + ); + } +} + +class NavigationTab extends ConsumerWidget { + final PageController _controller; + + const NavigationTab(this._controller); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gymStateProvider); + + return SingleChildScrollView( + child: Column( + children: [ + ...state.pages.where((pageEntry) => pageEntry.type == PageType.set).map((page) { + return ListTile( + leading: page.allLogsDone ? const Icon(Icons.check) : null, + title: Text( + page.exercises + .map( + (exercise) => exercise + .getTranslation(Localizations.localeOf(context).languageCode) + .name, + ) + .toList() + .join('\n'), + style: TextStyle( + decoration: page.allLogsDone ? TextDecoration.lineThrough : TextDecoration.none, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + _controller.animateToPage( + page.pageIndex, + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + Navigator.of(context).pop(); + }, + ); + }), + ], + ), + ); + } +} + +class ProgressionTab extends ConsumerStatefulWidget { + final _logger = Logger('ProgressionTab'); + final PageController _controller; + + ProgressionTab(this._controller, {super.key}); + + @override + ConsumerState createState() => _ProgressionTabState(); +} + +class _ProgressionTabState extends ConsumerState { + String? showSwapWidgetToPage; + String? showAddExerciseWidgetToPage; + _ProgressionTabState(); + + @override + Widget build(BuildContext context) { + final state = ref.watch(gymStateProvider); + final theme = Theme.of(context); + final languageCode = Localizations.localeOf(context).languageCode; + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + children: [ + ...state.pages.where((page) => page.type == PageType.set).map((page) { + if (page.exercises.isEmpty) { + widget._logger.warning('Page ${page.uuid} has no exercises, skipping'); + return Container(); + } + + // For supersets, prefix the exercise with A, B, C so it can be identified + // in the set list below + final isSuperset = page.exercises.length > 1; + final pageExerciseTitle = isSuperset + ? page.exercises + .asMap() + .entries + .map((entry) { + final label = String.fromCharCode(65 + entry.key); + final name = entry.value + .getTranslation(Localizations.localeOf(context).languageCode) + .name; + return '$label: $name'; + }) + .join('\n') + : page.exercises.first.getTranslation(languageCode).name; + + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(pageExerciseTitle, style: Theme.of(context).textTheme.bodyLarge), + ...page.slotPages.where((slotPage) => slotPage.type == SlotPageType.log).map( + (slotPage) { + String setPrefix = ''; + if (isSuperset) { + final exerciseIndex = page.exercises.indexWhere( + (ex) => ex.id == slotPage.setConfigData!.exercise.id, + ); + if (exerciseIndex != -1) { + setPrefix = '${String.fromCharCode(65 + exerciseIndex)}: '; + } + } + + // Sets that are done are marked with a strikethrough + final decoration = slotPage.logDone + ? TextDecoration.lineThrough + : TextDecoration.none; + + // Sets that are done have a lighter color + final color = slotPage.logDone + ? theme.colorScheme.onSurface.withValues(alpha: 0.6) + : null; + + // The row for the current page is highlighted in bold + final fontWeight = state.currentPage == slotPage.pageIndex + ? FontWeight.bold + : null; + + IconData icon = Icons.circle_outlined; + if (slotPage.logDone) { + icon = Icons.check_circle_rounded; + } else if (state.currentPage == slotPage.pageIndex) { + icon = Icons.play_circle_fill; + } + + return Row( + children: [ + Icon(icon, size: 16), + const SizedBox(width: 4), + Text( + '$setPrefix${slotPage.setConfigData!.textReprWithType}', + style: theme.textTheme.bodyMedium!.copyWith( + decoration: decoration, + fontWeight: fontWeight, + color: color, + ), + ), + ], + ); + }, + ), + + Row( + mainAxisSize: MainAxisSize.max, + //mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: page.allLogsDone + ? null + : () { + if (showSwapWidgetToPage == page.uuid) { + setState(() { + showSwapWidgetToPage = null; + }); + } else { + setState(() { + showSwapWidgetToPage = page.uuid; + showAddExerciseWidgetToPage = null; + }); + } + }, + icon: Icon( + key: ValueKey('swap-icon-${page.uuid}'), + showSwapWidgetToPage == page.uuid + ? Icons.change_circle + : Icons.change_circle_outlined, + ), + ), + IconButton( + onPressed: page.allLogsDone + ? null + : () { + if (showAddExerciseWidgetToPage == page.uuid) { + setState(() { + showAddExerciseWidgetToPage = null; + }); + } else { + setState(() { + showAddExerciseWidgetToPage = page.uuid; + showSwapWidgetToPage = null; + }); + } + }, + icon: Icon( + key: ValueKey('add-icon-${page.uuid}'), + showAddExerciseWidgetToPage == page.uuid ? Icons.add_circle : Icons.add, + ), + ), + Expanded(child: Container()), + IconButton( + onPressed: () { + widget._controller.animateToPage( + page.pageIndex, + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + if (showSwapWidgetToPage == page.uuid) + ExerciseSwapWidget( + page.uuid, + onDone: () { + setState(() { + showSwapWidgetToPage = null; + }); + }, + ), + if (showAddExerciseWidgetToPage == page.uuid) + ExerciseAddWidget( + page.uuid, + onDone: () { + setState(() { + showAddExerciseWidgetToPage = null; + }); + }, + ), + const SizedBox(height: 8), + ], + ); + }), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Swapping or adding an exercise only affects the current workout, ' + 'no changes are saved.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } +} + +class ExerciseSwapWidget extends ConsumerWidget { + final _logger = Logger('ExerciseSwapWidget'); + + final String pageUUID; + final VoidCallback? onDone; + + ExerciseSwapWidget(this.pageUUID, {this.onDone, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gymStateProvider); + final gymProvider = ref.read(gymStateProvider.notifier); + final page = state.pages.firstWhere((p) => p.uuid == pageUUID); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Card( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + ...page.exercises.map((e) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Text( + e.getTranslation(Localizations.localeOf(context).languageCode).name, + style: Theme.of(context).textTheme.bodyLarge, + ), + const Icon(Icons.swap_vert), + ExerciseAutocompleter( + onExerciseSelected: (exercise) { + gymProvider.replaceExercises( + page.uuid, + originalExerciseId: e.id!, + newExercise: exercise, + ); + onDone?.call(); + _logger.fine('Replaced exercise ${e.id} with ${exercise.id}'); + }, + ), + const SizedBox(height: 10), + ], + ); + }), + ], + ), + ), + ), + ); + } +} + +class ExerciseAddWidget extends ConsumerWidget { + final _logger = Logger('ExerciseAddWidget'); + + final String pageUUID; + final VoidCallback? onDone; + + ExerciseAddWidget(this.pageUUID, {this.onDone, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(gymStateProvider); + final gymProvider = ref.read(gymStateProvider.notifier); + final page = state.pages.firstWhere((p) => p.uuid == pageUUID); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Card( + child: Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + ExerciseAutocompleter( + onExerciseSelected: (exercise) { + gymProvider.addExerciseAfterPage( + page.uuid, + newExercise: exercise, + ); + onDone?.call(); + _logger.fine('Added exercise ${exercise.id} after page $pageUUID'); + }, + ), + const Icon(Icons.arrow_downward), + const SizedBox(height: 10), + ], + ), + ), + ), + ); + } +} + +class WorkoutMenuDialog extends ConsumerWidget { + final PageController controller; + final bool showEndWorkoutButton; + final int initialIndex; + + const WorkoutMenuDialog( + this.controller, { + super.key, + this.showEndWorkoutButton = true, + this.initialIndex = 0, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gymState = ref.watch(gymStateProvider); + + final endWorkoutButton = true + ? TextButton( + child: Text(AppLocalizations.of(context).endWorkout), + onPressed: () { + controller.animateToPage( + gymState.totalPages, + duration: DEFAULT_ANIMATION_DURATION, + curve: DEFAULT_ANIMATION_CURVE, + ); + + Navigator.of(context).pop(); + }, + ) + : null; + + return AlertDialog( + title: Text( + AppLocalizations.of(context).jumpTo, + textAlign: TextAlign.center, + ), + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: double.maxFinite, + child: WorkoutMenu(controller, initialIndex: initialIndex), + ), + actions: [ + ?endWorkoutButton, + TextButton( + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/routines/log.dart b/lib/widgets/routines/log.dart deleted file mode 100644 index f72b00f6..00000000 --- a/lib/widgets/routines/log.dart +++ /dev/null @@ -1,231 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team - * - * wger Workout Manager is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * wger Workout Manager is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:wger/helpers/colors.dart'; -import 'package:wger/helpers/date.dart'; -import 'package:wger/helpers/errors.dart'; -import 'package:wger/helpers/misc.dart'; -import 'package:wger/l10n/generated/app_localizations.dart'; -import 'package:wger/models/workouts/log.dart'; -import 'package:wger/models/workouts/routine.dart'; -import 'package:wger/models/workouts/session.dart'; -import 'package:wger/providers/network_provider.dart'; -import 'package:wger/widgets/measurements/charts.dart'; -import 'package:wger/widgets/routines/charts.dart'; -import 'package:wger/widgets/routines/forms/session.dart'; - -class SessionInfo extends ConsumerStatefulWidget { - final WorkoutSession _session; - - const SessionInfo(this._session); - - @override - ConsumerState createState() => _SessionInfoState(); -} - -class _SessionInfoState extends ConsumerState { - bool editMode = false; - - @override - Widget build(BuildContext context) { - final i18n = AppLocalizations.of(context); - final isOnline = ref.watch(networkStatusProvider); - - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - ListTile( - enabled: isOnline, - title: Text( - i18n.workoutSession, - style: Theme.of(context).textTheme.headlineSmall, - ), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(widget._session.date), - ), - onTap: () => setState(() => editMode = !editMode), - trailing: Icon(editMode ? Icons.edit_off : Icons.edit), - contentPadding: EdgeInsets.zero, - ), - if (editMode) - SessionForm( - widget._session.routineId!, - onSaved: () => setState(() => editMode = false), - session: widget._session, - ) - else - Column( - children: [ - _buildInfoRow( - context, - i18n.timeStart, - widget._session.timeStart != null - ? MaterialLocalizations.of( - context, - ).formatTimeOfDay(widget._session.timeStart!) - : '-/-', - ), - _buildInfoRow( - context, - i18n.timeEnd, - widget._session.timeEnd != null - ? MaterialLocalizations.of(context).formatTimeOfDay(widget._session.timeEnd!) - : '-/-', - ), - _buildInfoRow( - context, - i18n.impression, - widget._session.impressionAsString, - ), - _buildInfoRow( - context, - i18n.notes, - (widget._session.notes != null && widget._session.notes!.isNotEmpty) - ? widget._session.notes! - : '-/-', - ), - ], - ), - ], - ), - ); - } - - Widget _buildInfoRow(BuildContext context, String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$label: ', - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - Expanded(child: Text(value)), - ], - ), - ); - } -} - -class ExerciseLogChart extends StatelessWidget { - final Map> _logs; - final DateTime _selectedDate; - - const ExerciseLogChart(this._logs, this._selectedDate); - - @override - Widget build(BuildContext context) { - final colors = generateChartColors(_logs.keys.length).iterator; - - return Column( - mainAxisSize: MainAxisSize.max, - children: [ - LogChartWidgetFl(_logs, _selectedDate), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ..._logs.keys.map((reps) { - colors.moveNext(); - - return Indicator( - color: colors.current, - text: formatNum(reps).toString(), - isSquare: false, - ); - }), - ], - ), - ), - const SizedBox(height: 15), - ], - ); - } -} - -class DayLogWidget extends ConsumerWidget { - final DateTime _date; - final Routine _routine; - - const DayLogWidget(this._date, this._routine); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isOnline = ref.watch(networkStatusProvider); - - final session = _routine.sessions.firstWhere((s) => s.date.isSameDayAs(_date)); - final exercises = session.exercises; - - return Card( - child: Column( - children: [ - SessionInfo(session), - ...exercises.map((exercise) { - final translation = exercise.getTranslation( - Localizations.localeOf(context).languageCode, - ); - return Column( - children: [ - Text( - translation.name, - style: Theme.of(context).textTheme.titleMedium, - ), - ...session.logs - .where((l) => l.exerciseId == exercise.id) - .map( - (log) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(log.singleLogRepTextNoNl), - IconButton( - icon: const Icon(Icons.delete), - key: ValueKey('delete-log-${log.id}'), - onPressed: isOnline - ? () => showDeleteLogDialog(context, translation.name, log) - : null, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: ExerciseLogChart( - _routine.groupLogsByRepetition( - logs: _routine.filterLogsByExercise(exercise.id), - filterNullReps: true, - filterNullWeights: true, - ), - _date, - ), - ), - ], - ); - }), - ], - ), - ); - } -} diff --git a/lib/widgets/routines/logs/day_logs_container.dart b/lib/widgets/routines/logs/day_logs_container.dart new file mode 100644 index 00000000..49d829b0 --- /dev/null +++ b/lib/widgets/routines/logs/day_logs_container.dart @@ -0,0 +1,101 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/helpers/date.dart'; +import 'package:wger/helpers/errors.dart'; +import 'package:wger/models/workouts/routine.dart'; + +import 'exercise_log_chart.dart'; +import 'muscle_groups.dart'; +import 'session_info.dart'; + +class DayLogWidget extends StatelessWidget { + final DateTime _date; + final Routine _routine; + + const DayLogWidget(this._date, this._routine); + + @override + Widget build(BuildContext context) { + final sessionApi = _routine.sessions.firstWhere( + (sessionApi) => sessionApi.session.date.isSameDayAs(_date), + ); + final exercises = sessionApi.exercises; + + return Column( + spacing: 10, + children: [ + Card(child: SessionInfo(sessionApi.session)), + MuscleGroupsCard(sessionApi.logs), + + Column( + spacing: 10, + children: [ + ...exercises.map((exercise) { + final translation = exercise.getTranslation( + Localizations.localeOf(context).languageCode, + ); + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text( + translation.name, + style: Theme.of(context).textTheme.titleLarge, + ), + ...sessionApi.logs + .where((l) => l.exerciseId == exercise.id) + .map( + (log) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(log.repTextNoNl(context)), + IconButton( + icon: const Icon(Icons.delete), + key: ValueKey('delete-log-${log.id}'), + onPressed: () { + showDeleteDialog(context, translation.name, log); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: ExerciseLogChart( + _routine.groupLogsByRepetition( + logs: _routine.filterLogsByExercise(exercise.id!), + filterNullReps: true, + filterNullWeights: true, + ), + _date, + ), + ), + ], + ), + ), + ); + }), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/routines/logs/exercise_log_chart.dart b/lib/widgets/routines/logs/exercise_log_chart.dart new file mode 100644 index 00000000..c40af116 --- /dev/null +++ b/lib/widgets/routines/logs/exercise_log_chart.dart @@ -0,0 +1,61 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/widgets.dart'; +import 'package:wger/helpers/colors.dart'; +import 'package:wger/helpers/misc.dart'; +import 'package:wger/models/workouts/log.dart'; +import 'package:wger/widgets/measurements/charts.dart'; +import 'package:wger/widgets/routines/charts.dart'; + +class ExerciseLogChart extends StatelessWidget { + final Map> _logs; + final DateTime _selectedDate; + + const ExerciseLogChart(this._logs, this._selectedDate); + + @override + Widget build(BuildContext context) { + final colors = generateChartColors(_logs.keys.length).iterator; + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + LogChartWidgetFl(_logs, _selectedDate), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ..._logs.keys.map((reps) { + colors.moveNext(); + + return Indicator( + color: colors.current, + text: formatNum(reps).toString(), + isSquare: false, + ); + }), + ], + ), + ), + const SizedBox(height: 15), + ], + ); + } +} diff --git a/lib/widgets/routines/logs/exercises_expansion_card.dart b/lib/widgets/routines/logs/exercises_expansion_card.dart new file mode 100644 index 00000000..0286418f --- /dev/null +++ b/lib/widgets/routines/logs/exercises_expansion_card.dart @@ -0,0 +1,115 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/helpers/i18n.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/exercises/exercise.dart'; +import 'package:wger/models/workouts/log.dart'; +import 'package:wger/models/workouts/session_api.dart'; + +class ExercisesCard extends StatelessWidget { + final WorkoutSessionApi session; + + const ExercisesCard(this.session, {super.key}); + + @override + Widget build(BuildContext context) { + final exercises = session.exercises; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).exercises, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ...exercises.map((exercise) { + final logs = session.logs.where((log) => log.exerciseId == exercise.id).toList(); + return _ExerciseExpansionTile(exercise: exercise, logs: logs); + }), + ], + ), + ), + ); + } +} + +class _ExerciseExpansionTile extends StatelessWidget { + const _ExerciseExpansionTile({ + required this.exercise, + required this.logs, + }); + + final Exercise exercise; + final List logs; + + @override + Widget build(BuildContext context) { + final languageCode = Localizations.localeOf(context).languageCode; + final theme = Theme.of(context); + + final topSet = logs.isEmpty + ? null + : logs.reduce((a, b) => (a.weight ?? 0) > (b.weight ?? 0) ? a : b); + final topSetWeight = topSet?.weight?.toStringAsFixed(0) ?? 'N/A'; + final topSetWeightUnit = topSet?.weightUnitObj != null + ? getServerStringTranslation(topSet!.weightUnitObj!.name, context) + : ''; + return ExpansionTile( + // leading: const Icon(Icons.fitness_center), + title: Text(exercise.getTranslation(languageCode).name, style: theme.textTheme.titleMedium), + subtitle: Text('Top set: $topSetWeight $topSetWeightUnit'), + children: logs.map((log) => _SetDataRow(log: log)).toList(), + ); + } +} + +class _SetDataRow extends StatelessWidget { + const _SetDataRow({required this.log}); + + final Log log; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final i18n = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 5, + children: [ + Text( + log.repTextNoNl(context), + style: theme.textTheme.bodyMedium, + ), + // if (log.volume() > 0) + // Text( + // '${log.volume().toStringAsFixed(0)} ${getServerStringTranslation(log.weightUnitObj!.name, context)}', + // style: theme.textTheme.bodyMedium, + // ), + ], + ), + ); + } +} diff --git a/lib/widgets/routines/workout_logs.dart b/lib/widgets/routines/logs/log_overview_routine.dart similarity index 81% rename from lib/widgets/routines/workout_logs.dart rename to lib/widgets/routines/logs/log_overview_routine.dart index 06537c55..3329b8a9 100644 --- a/lib/widgets/routines/workout_logs.dart +++ b/lib/widgets/routines/logs/log_overview_routine.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (C) 2020, 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -23,7 +23,7 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/routine.dart'; import 'package:wger/theme/theme.dart'; -import 'package:wger/widgets/routines/log.dart'; +import 'package:wger/widgets/routines/logs/day_logs_container.dart'; class WorkoutLogs extends StatelessWidget { final Routine _routine; @@ -39,14 +39,6 @@ class WorkoutLogs extends StatelessWidget { AppLocalizations.of(context).labelWorkoutLogs, style: Theme.of(context).textTheme.headlineSmall, ), - Text( - AppLocalizations.of(context).logHelpEntries, - textAlign: TextAlign.justify, - ), - Text( - AppLocalizations.of(context).logHelpEntriesUnits, - textAlign: TextAlign.justify, - ), SizedBox( width: double.infinity, child: WorkoutLogCalendar(_routine), @@ -141,16 +133,29 @@ class _WorkoutLogCalendarState extends State { }, ), const SizedBox(height: 8.0), - SizedBox( - child: ValueListenableBuilder>( - valueListenable: _selectedEvents, - builder: (context, logEvents, _) { - // At the moment there is only one "event" per day - return logEvents.isNotEmpty - ? DayLogWidget(logEvents.first, widget._routine) - : Container(); - }, - ), + ExpansionTile( + showTrailingIcon: false, + dense: true, + title: const Align(alignment: Alignment.centerLeft, child: Icon(Icons.info_outline)), + children: [ + Text( + AppLocalizations.of(context).logHelpEntries, + textAlign: TextAlign.justify, + ), + Text( + AppLocalizations.of(context).logHelpEntriesUnits, + textAlign: TextAlign.justify, + ), + ], + ), + ValueListenableBuilder>( + valueListenable: _selectedEvents, + builder: (context, logEvents, _) { + // At the moment there is only one "event" per day + return logEvents.isNotEmpty + ? DayLogWidget(logEvents.first, widget._routine) + : Container(); + }, ), ], ); diff --git a/lib/widgets/routines/logs/muscle_groups.dart b/lib/widgets/routines/logs/muscle_groups.dart new file mode 100644 index 00000000..1ef2b0e9 --- /dev/null +++ b/lib/widgets/routines/logs/muscle_groups.dart @@ -0,0 +1,138 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:collection/collection.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/workouts/log.dart'; + +class MuscleGroup { + final String name; + final double percentage; + final Color color; + + MuscleGroup(this.name, this.percentage, this.color); +} + +class MuscleGroupsCard extends StatelessWidget { + final List logs; + + const MuscleGroupsCard(this.logs, {super.key}); + + List _getMuscleGroups(BuildContext context) { + final allMuscles = logs + .expand((log) => [...log.exercise.muscles, ...log.exercise.musclesSecondary]) + .toList(); + if (allMuscles.isEmpty) { + return []; + } + final muscleCounts = allMuscles.groupListsBy((muscle) => muscle.nameTranslated(context)); + final total = allMuscles.length; + + int colorIndex = 0; + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.deepOrange, + Colors.indigo, + Colors.pink, + Colors.brown, + Colors.cyan, + Colors.lime, + Colors.amber, + Colors.lightGreen, + Colors.deepPurple, + ]; + + return muscleCounts.entries.map((entry) { + final percentage = (entry.value.length / total) * 100; + final color = colors[colorIndex % colors.length]; + colorIndex++; + return MuscleGroup(entry.key, percentage, color); + }).toList(); + } + + @override + Widget build(BuildContext context) { + final muscles = _getMuscleGroups(context); + final theme = Theme.of(context); + final i18n = AppLocalizations.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.muscles, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: muscles.map((muscle) { + return PieChartSectionData( + color: muscle.color, + value: muscle.percentage, + title: i18n.percentValue(muscle.percentage.toStringAsFixed(0)), + radius: 50, + titleStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimary, + ), + ); + }).toList(), + sectionsSpace: 2, + centerSpaceRadius: 40, + ), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 8, + children: muscles.map((muscle) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + color: muscle.color, + ), + const SizedBox(width: 8), + Text(muscle.name), + ], + ); + }).toList(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/routines/logs/session_info.dart b/lib/widgets/routines/logs/session_info.dart new file mode 100644 index 00000000..a8293010 --- /dev/null +++ b/lib/widgets/routines/logs/session_info.dart @@ -0,0 +1,113 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/workouts/session.dart'; +import 'package:wger/widgets/routines/forms/session.dart'; + +class SessionInfo extends StatefulWidget { + final WorkoutSession _session; + + const SessionInfo(this._session); + + @override + State createState() => _SessionInfoState(); +} + +class _SessionInfoState extends State { + bool editMode = false; + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + ListTile( + title: Text( + i18n.workoutSession, + style: Theme.of(context).textTheme.headlineSmall, + ), + subtitle: Text( + DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(widget._session.date), + ), + onTap: () => setState(() => editMode = !editMode), + trailing: Icon(editMode ? Icons.edit_off : Icons.edit), + contentPadding: EdgeInsets.zero, + ), + if (editMode) + SessionForm( + widget._session.routineId!, + onSaved: () => setState(() => editMode = false), + session: widget._session, + ) + else + Column( + children: [ + SessionRow( + label: i18n.impression, + value: widget._session.impressionAsString(context), + ), + SessionRow( + label: i18n.duration, + value: widget._session.durationTxtWithStartEnd(context), + ), + SessionRow( + label: i18n.notes, + value: widget._session.notes.isNotEmpty ? widget._session.notes : '-/-', + ), + ], + ), + ], + ), + ); + } +} + +class SessionRow extends StatelessWidget { + const SessionRow({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label: ', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3dd47d91..2ed17f4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,6 @@ dependencies: equatable: ^2.0.7 fl_chart: ^1.1.1 flex_color_scheme: ^8.3.1 - flex_seed_scheme: ^4.0.1 flutter_html: ^3.0.0 flutter_staggered_grid_view: ^0.7.0 flutter_svg: ^2.2.3 @@ -50,25 +49,24 @@ dependencies: font_awesome_flutter: ^10.12.0 freezed_annotation: ^3.0.0 get_it: ^8.3.0 - http: ^1.5.0 - image_picker: ^1.2.0 + http: ^1.6.0 + image_picker: ^1.2.1 intl: ^0.20.0 json_annotation: ^4.8.1 multi_select_flutter: ^4.1.3 package_info_plus: ^9.0.0 path: ^1.9.0 path_provider: ^2.1.5 - provider: ^6.1.5 + provider: ^6.1.5+1 rive: ^0.13.20 shared_preferences: ^2.5.3 sqlite3_flutter_libs: ^0.5.41 table_calendar: ^3.0.8 url_launcher: ^6.3.2 version: ^3.0.2 - video_player: ^2.10.0 + video_player: ^2.10.1 logging: ^1.3.0 flutter_riverpod: ^3.0.3 - powersync: ^1.16.1 drift_sqlite_async: ^0.2.5 riverpod_annotation: ^3.0.3 stream_transform: ^2.1.1 @@ -86,7 +84,7 @@ dev_dependencies: flutter_lints: ^6.0.0 freezed: ^3.2.0 json_serializable: ^6.11.2 - mockito: ^5.6.1 + mockito: ^5.4.4 network_image_mock: ^2.1.1 shared_preferences_platform_interface: ^2.0.0 riverpod_generator: ^3.0.3 diff --git a/test/nutrition/nutritional_meal_item_form_test.dart b/test/nutrition/nutritional_meal_item_form_test.dart index eb28f0b0..6531e145 100644 --- a/test/nutrition/nutritional_meal_item_form_test.dart +++ b/test/nutrition/nutritional_meal_item_form_test.dart @@ -348,6 +348,10 @@ void main() { ), ).thenAnswer((_) => Future.value([ingredient1])); + when( + mockNutrition.cacheIngredient(any), + ).thenAnswer((_) => Future.value(null)); + await tester.enterText(find.byType(TextFormField).first, 'Water'); await tester.pumpAndSettle(const Duration(milliseconds: 600)); await tester.pumpAndSettle(); diff --git a/test/providers/gym_state_test.dart b/test/providers/gym_state_test.dart new file mode 100644 index 00000000..2333435a --- /dev/null +++ b/test/providers/gym_state_test.dart @@ -0,0 +1,276 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/providers/gym_state.dart'; + +import '../../test_data/exercises.dart'; +import '../../test_data/routines.dart'; + +void main() { + late GymStateNotifier notifier; + late ProviderContainer container; + + setUp(() { + container = ProviderContainer.test(); + notifier = container.read(gymStateProvider.notifier); + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: true, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); + notifier.calculatePages(); + }); + + group('GymStateNotifier.markSlotPageAsDone', () { + test('Correctly changes the flag', () { + // Arrange + final slotPage = notifier.state.pages[1].slotPages[1]; + expect(slotPage.type, SlotPageType.log); + expect( + notifier.state.pages.every((p) => p.slotPages.every((s) => !s.logDone)), + true, + reason: 'All slot pages are initially not done', + ); + + // Act + notifier.markSlotPageAsDone(slotPage.uuid, isDone: true); + + // Assert + for (final page in notifier.state.pages.where((p) => p.type == PageType.set)) { + for (final slot in page.slotPages.where((s) => s.type == SlotPageType.log)) { + if (slot.uuid == slotPage.uuid) { + expect(slot.logDone, true); + } else { + expect(slot.logDone, false); + } + } + } + }); + }); + + group('GymStateNotifier.recalculateIndices', () { + test('Correctly recalculates indices if new pages are added', () { + // Arrange + final newPages = [ + ...notifier.state.pages.sublist(0, 2), + PageEntry( + type: PageType.set, + pageIndex: 1111, + uuid: 'new-page-1', + ), + PageEntry( + type: PageType.set, + pageIndex: 9, + uuid: 'new-page-2', + ), + ...notifier.state.pages.sublist(2), + PageEntry( + type: PageType.set, + pageIndex: 0, + uuid: 'new-page-3', + slotPages: [ + SlotPageEntry( + type: SlotPageType.timer, + pageIndex: 10, + setIndex: 9, + uuid: 'new-slot-1', + ), + SlotPageEntry( + type: SlotPageType.timer, + pageIndex: 10, + setIndex: 6, + uuid: 'new-slot-2', + ), + SlotPageEntry( + type: SlotPageType.timer, + pageIndex: 100, + setIndex: 100, + uuid: 'new-slot-3', + ), + ], + ), + ]; + notifier.state = notifier.state.copyWith(pages: newPages); + + // Act + notifier.recalculateIndices(); + + // Assert + final pages = notifier.state.pages; + expect(pages[0].pageIndex, 0); + expect(pages[1].pageIndex, 1); + + // These three have the same pageIndex because the new ones don't have any slot + // pages (this should not happen in practice) + expect(pages[2].pageIndex, 8); + expect(pages[3].pageIndex, 8); + expect(pages[4].pageIndex, 8); + + expect(pages[5].pageIndex, 15); + expect(pages[6].pageIndex, 16); + expect(pages[7].pageIndex, 17); + + // Preserve the order of new pages + expect(pages[7].uuid, 'new-page-3'); + + // Slot pages have correct indices, the original order is preserved + final slotPages = pages[7].slotPages; + expect(slotPages[0].uuid, 'new-slot-1'); + expect(slotPages[0].pageIndex, 17); + expect(slotPages[0].setIndex, 0); + expect(slotPages[1].uuid, 'new-slot-2'); + expect(slotPages[1].pageIndex, 18); + expect(slotPages[1].setIndex, 1); + expect(slotPages[2].uuid, 'new-slot-3'); + expect(slotPages[2].pageIndex, 19); + expect(slotPages[2].setIndex, 2); + }); + }); + + group('GymStateNotifier.replaceExercises', () { + test('Correctly swaps an exercise', () { + // Arrange + final slotPage = notifier.state.pages[1].slotPages[1]; + expect(slotPage.type, SlotPageType.log); + notifier.state.pages.every((p) => p.exercises.every((s) => s.id != testSquats.id)); + + // Act + notifier.replaceExercises(slotPage.uuid, originalExerciseId: 1, newExercise: testSquats); + // print(notifier.readPageStructure()); + + // Assert + expect(notifier.state.pages[1].exercises.first.id, testSquats.id); + }); + }); + + group('GymStateNotifier.calculatePages', () { + test( + 'Correctly generates pages - exercise and timer', + () { + // Arrange + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: true, + ); + + // Act + notifier.calculatePages(); + + // Assert + final pages = notifier.state.pages; + final setEntry = pages.firstWhere((p) => p.type == PageType.set); + expect(pages.length, 5, reason: '5 PageEntries (start, set 1, set 2, session, summary)'); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.log).length, + 3, + reason: 'Three sets', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.timer).length, + 3, + reason: 'One timer after each set', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.exerciseOverview).length, + 1, + reason: 'One exercise overview at the start', + ); + expect(setEntry.slotPages[0].type, SlotPageType.exerciseOverview); + expect(setEntry.slotPages[1].type, SlotPageType.log); + expect(setEntry.slotPages[2].type, SlotPageType.timer); + expect(notifier.state.totalPages, 17); + }, + ); + + test('Correctly generates pages - no exercises and no timer', () { + // Arrange + notifier.state = notifier.state.copyWith( + showExercisePages: false, + showTimerPages: false, + ); + + // Act + notifier.calculatePages(); + + // Assert + final pages = notifier.state.pages; + final setEntry = pages.firstWhere((p) => p.type == PageType.set); + expect(pages.length, 5, reason: '4 PageEntries (start, set 1, set 2, session, summary)'); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.log).length, + 3, + reason: 'Three sets', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.timer).length, + 0, + reason: 'No timer', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.exerciseOverview).length, + 0, + reason: 'No overview', + ); + expect(setEntry.slotPages[0].type, SlotPageType.log); + expect(setEntry.slotPages[1].type, SlotPageType.log); + expect(setEntry.slotPages[2].type, SlotPageType.log); + expect(notifier.state.totalPages, 9); + }); + + test('Correctly generates pages - exercises and no timer', () { + // Arrange + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: false, + ); + + // Act + notifier.calculatePages(); + + // Assert + final pages = notifier.state.pages; + final setEntry = pages.firstWhere((p) => p.type == PageType.set); + expect(pages.length, 5, reason: '5 PageEntries (start, set 1, set 2, session, summary)'); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.log).length, + 3, + reason: 'Three sets', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.timer).length, + 0, + reason: 'No timer', + ); + expect( + setEntry.slotPages.where((p) => p.type == SlotPageType.exerciseOverview).length, + 1, + reason: 'One exercise overview at the start', + ); + expect(setEntry.slotPages.length, 4); + expect(setEntry.slotPages[0].type, SlotPageType.exerciseOverview); + expect(setEntry.slotPages[1].type, SlotPageType.log); + expect(setEntry.slotPages[2].type, SlotPageType.log); + expect(setEntry.slotPages[3].type, SlotPageType.log); + expect(notifier.state.totalPages, 11); + }); + }); +} diff --git a/test/providers/plate_calculator_test.dart b/test/providers/plate_calculator_test.dart index 0d12c622..5025e296 100644 --- a/test/providers/plate_calculator_test.dart +++ b/test/providers/plate_calculator_test.dart @@ -13,6 +13,7 @@ import 'plate_calculator_test.mocks.dart'; void main() { group('PlateWeightsNotifier', () { late PlateCalculatorNotifier notifier; + late ProviderContainer container; late MockSharedPreferencesAsync mockPrefs; late ProviderContainer container; @@ -21,9 +22,11 @@ void main() { when(mockPrefs.getString(PREFS_KEY_PLATES)).thenAnswer((_) async => null); when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); - container = ProviderContainer( + container = ProviderContainer.test( overrides: [ - plateCalculatorProvider.overrideWith(() => PlateCalculatorNotifier(prefs: mockPrefs)), + plateCalculatorProvider.overrideWith( + () => PlateCalculatorNotifier(prefs: mockPrefs), + ), ], ); notifier = container.read(plateCalculatorProvider.notifier); diff --git a/test/providers/plate_calculator_test.mocks.dart b/test/providers/plate_calculator_test.mocks.dart index 9b3fed8e..bcba37ab 100644 --- a/test/providers/plate_calculator_test.mocks.dart +++ b/test/providers/plate_calculator_test.mocks.dart @@ -21,7 +21,6 @@ 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]. /// diff --git a/test/routine/goldens/routine_logs_screen_detail.png b/test/routine/goldens/routine_logs_screen_detail.png index eb48d474..b9845ac4 100644 Binary files a/test/routine/goldens/routine_logs_screen_detail.png and b/test/routine/goldens/routine_logs_screen_detail.png differ diff --git a/test/routine/gym_mode/goldens/gym_mode_progression_tab.png b/test/routine/gym_mode/goldens/gym_mode_progression_tab.png new file mode 100644 index 00000000..62cb26de Binary files /dev/null and b/test/routine/gym_mode/goldens/gym_mode_progression_tab.png differ diff --git a/test/routine/gym_mode/gym_mode_test.dart b/test/routine/gym_mode/gym_mode_test.dart new file mode 100644 index 00000000..38a3e296 --- /dev/null +++ b/test/routine/gym_mode/gym_mode_test.dart @@ -0,0 +1,311 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.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/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/base_provider.dart'; +import 'package:wger/providers/exercises.dart'; +import 'package:wger/providers/routines.dart'; +import 'package:wger/screens/gym_mode.dart'; +import 'package:wger/screens/routine_screen.dart'; +import 'package:wger/widgets/routines/forms/reps_unit.dart'; +import 'package:wger/widgets/routines/forms/rir.dart'; +import 'package:wger/widgets/routines/forms/weight_unit.dart'; +import 'package:wger/widgets/routines/gym_mode/exercise_overview.dart'; +import 'package:wger/widgets/routines/gym_mode/log_page.dart'; +import 'package:wger/widgets/routines/gym_mode/session_page.dart'; +import 'package:wger/widgets/routines/gym_mode/start_page.dart'; +import 'package:wger/widgets/routines/gym_mode/summary.dart'; +import 'package:wger/widgets/routines/gym_mode/timer.dart'; + +import '../../../test_data/exercises.dart'; +import '../../../test_data/routines.dart'; +import 'gym_mode_test.mocks.dart'; + +@GenerateMocks([WgerBaseProvider, ExercisesProvider, RoutinesProvider]) +void main() { + final key = GlobalKey(); + + final mockRoutinesProvider = MockRoutinesProvider(); + final mockExerciseProvider = MockExercisesProvider(); + final testRoutine = getTestRoutine(); + final testExercises = getTestExercises(); + + setUp(() { + when(mockRoutinesProvider.findById(any)).thenReturn(testRoutine); + when(mockRoutinesProvider.items).thenReturn([testRoutine]); + when(mockRoutinesProvider.repetitionUnits).thenReturn(testRepetitionUnits); + when(mockRoutinesProvider.findRepetitionUnitById(1)).thenReturn(testRepetitionUnit1); + when(mockRoutinesProvider.weightUnits).thenReturn(testWeightUnits); + when(mockRoutinesProvider.findWeightUnitById(1)).thenReturn(testWeightUnit1); + when( + mockRoutinesProvider.fetchAndSetRoutineFull(any), + ).thenAnswer((_) => Future.value(testRoutine)); + + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + }); + + Widget renderGymMode({locale = 'en'}) { + return ChangeNotifierProvider( + create: (context) => mockRoutinesProvider, + child: ChangeNotifierProvider( + create: (context) => mockExerciseProvider, + child: riverpod.ProviderScope( + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + navigatorKey: key, + home: TextButton( + onPressed: () => key.currentState!.push( + MaterialPageRoute( + settings: const RouteSettings(arguments: GymModeArguments(1, 1, 1)), + builder: (_) => const GymModeScreen(), + ), + ), + child: const SizedBox(), + ), + routes: {RoutineScreen.routeName: (ctx) => const RoutineScreen()}, + ), + ), + ), + ); + } + + testWidgets( + 'Test the widgets on the gym mode screen', + (WidgetTester tester) async { + when(mockExerciseProvider.findExerciseById(1)).thenReturn(testExercises[0]); + when(mockExerciseProvider.findExerciseById(6)).thenReturn(testExercises[5]); + when( + mockExerciseProvider.findExercisesByVariationId( + null, + exerciseIdToExclude: anyNamed('exerciseIdToExclude'), + ), + ).thenReturn([]); + + 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 + // + expect(find.byType(StartPage), findsOneWidget); + expect(find.text('Your workout today'), findsOneWidget); + expect(find.text('Bench press'), findsOneWidget); + expect(find.text('Side raises'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.menu), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsNothing); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Bench press - exercise overview page + // + expect(find.text('Bench press'), findsOneWidget); + expect(find.byType(ExerciseOverview), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.menu), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + await tester.drag(find.byType(ExerciseOverview), const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + + // + // Bench press - Log + // + expect(find.text('Bench press'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + expect(find.byType(Form), findsOneWidget); + expect(find.text('10 × 10 kg (1.5 RiR)'), findsOneWidget); + expect(find.text('12 × 10 kg (2 RiR)'), findsOneWidget); + + // TODO: commented out for now + // expect(find.text('Make sure to warm up'), findsOneWidget, reason: 'Set comment'); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.menu), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + + // Form shows only weight and reps + expect(find.byType(SwitchListTile), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(2)); + expect(find.byType(RepetitionUnitInputWidget), findsNothing); + expect(find.byType(WeightUnitInputWidget), findsNothing); + expect(find.byType(RiRInputWidget), findsNothing); + + // Form shows unit and rir after tapping the toggle button + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(); + expect(find.byType(RepetitionUnitInputWidget), findsOneWidget); + expect(find.byType(WeightUnitInputWidget), findsOneWidget); + expect(find.byType(RiRInputWidget), findsOneWidget); + await tester.drag(find.byType(LogPage), const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + + // + // Bench press - pause + // + expect(find.text('Pause'), findsOneWidget); + expect(find.byType(TimerCountdownWidget), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.menu), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Bench press - log + // + expect(find.text('Bench press'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + expect(find.byType(Form), findsOneWidget); + await tester.drag(find.byType(LogPage), const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + + // + // Pause + // + expect(find.text('Pause'), findsOneWidget); + expect(find.byType(TimerCountdownWidget), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Bench press - log + // + expect(find.text('Bench press'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + expect(find.byType(Form), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Pause + // + expect(find.text('Pause'), findsOneWidget); + expect(find.byType(TimerCountdownWidget), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - overview + // + expect(find.text('Side raises'), findsOneWidget); + expect(find.byType(ExerciseOverview), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - log + // + expect(find.text('Side raises'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - timer + // + expect(find.byType(TimerWidget), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - log + // + expect(find.text('Side raises'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - timer + // + expect(find.byType(TimerWidget), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - log + // + expect(find.text('Side raises'), findsOneWidget); + expect(find.byType(LogPage), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Side raises - timer + // + expect(find.byType(TimerWidget), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Session + // + expect(find.text('Workout session'), findsOneWidget); + expect(find.byType(SessionPage), findsOneWidget); + expect(find.byType(Form), findsOneWidget); + expect(find.byIcon(Icons.sentiment_very_dissatisfied), findsOneWidget); + expect(find.byIcon(Icons.sentiment_neutral), findsOneWidget); + expect(find.byIcon(Icons.sentiment_very_satisfied), findsOneWidget); + expect( + find.text('14:33'), + findsNWidgets(2), + reason: 'start and end time are the same', + ); + final toggleButtons = tester.widget(find.byType(ToggleButtons)); + expect(toggleButtons.isSelected[1], isTrue); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + await tester.pumpAndSettle(); + + // + // Workout summary + // + expect(find.byType(WorkoutSummary), findsOneWidget); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsNothing); + }); + }, + semanticsEnabled: false, + ); +} diff --git a/test/routine/gym_mode_session_screen_test.dart b/test/routine/gym_mode/session_page_test.dart similarity index 73% rename from test/routine/gym_mode_session_screen_test.dart rename to test/routine/gym_mode/session_page_test.dart index bc86b421..273e8700 100644 --- a/test/routine/gym_mode_session_screen_test.dart +++ b/test/routine/gym_mode/session_page_test.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 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 @@ -26,37 +26,60 @@ import 'package:wger/helpers/json.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/workout_session_repository.dart'; import 'package:wger/widgets/routines/gym_mode/session_page.dart'; -import '../../test_data/routines.dart'; -import 'gym_mode_session_screen_test.mocks.dart'; +import '../../../test_data/routines.dart'; +import 'session_page_test.mocks.dart'; @GenerateMocks([WorkoutSessionRepository]) void main() { late MockWorkoutSessionRepository mockRepository; late Routine testRoutine; + late GymStateNotifier notifier; + late ProviderContainer container; setUp(() { testRoutine = getTestRoutine(); mockRepository = MockWorkoutSessionRepository(); + + container = ProviderContainer.test(); + notifier = container.read(gymStateProvider.notifier); + notifier.state = notifier.state.copyWith( + showExercisePages: true, + showTimerPages: true, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); + notifier.calculatePages(); + when(mockRoutinesProvider.editSession(any)).thenAnswer( + (_) => Future.value(testRoutine.sessions[0].session), + ); }); Widget renderSessionPage({locale = 'en'}) { - return ProviderScope( + //final controller = PageController(initialPage: 0); + + return UncontrolledProviderScope( overrides: [ workoutSessionRepositoryProvider.overrideWithValue(mockRepository), ], - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SessionPage( - testRoutine, - PageController(), - const TimeOfDay(hour: 13, minute: 35), - const {}, + container: container, + child: ChangeNotifierProvider( + create: (context) => mockRoutinesProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: PageView( + controller: controller, + children: [ + SessionPage(controller), + ], + ), ), ), ), @@ -78,6 +101,9 @@ void main() { testRoutine.sessions[0].timeStart = null; testRoutine.sessions[0].timeEnd = null; + notifier.state = notifier.state.copyWith(routine: testRoutine); + notifier.calculatePages(); + withClock(Clock.fixed(DateTime(2021, 5, 1)), () async { await tester.pumpWidget(renderSessionPage()); @@ -92,10 +118,17 @@ void main() { }); testWidgets('Test correct default data (no existing session)', (WidgetTester tester) async { + // Arrange testRoutine.sessions = []; final timeNow = timeToString(TimeOfDay.now())!; + notifier.state = notifier.state.copyWith( + startTime: const TimeOfDay(hour: 13, minute: 35), + ); + // Act await tester.pumpWidget(renderSessionPage()); + + // Assert expect(find.text('13:35'), findsOneWidget); expect(find.text(timeNow), findsOneWidget); final toggleButtons = tester.widget(find.byType(ToggleButtons)); diff --git a/test/routine/gym_mode_session_screen_test.mocks.dart b/test/routine/gym_mode/session_page_test.mocks.dart similarity index 97% rename from test/routine/gym_mode_session_screen_test.mocks.dart rename to test/routine/gym_mode/session_page_test.mocks.dart index bc50ded3..6cf8a23e 100644 --- a/test/routine/gym_mode_session_screen_test.mocks.dart +++ b/test/routine/gym_mode/session_page_test.mocks.dart @@ -1,5 +1,5 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in wger/test/routine/gym_mode_session_screen_test.dart. +// in wger/test/routine/gym_mode/session_page_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes diff --git a/test/routine/gym_mode/workout_menu_test.dart b/test/routine/gym_mode/workout_menu_test.dart new file mode 100644 index 00000000..6171deac --- /dev/null +++ b/test/routine/gym_mode/workout_menu_test.dart @@ -0,0 +1,98 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/gym_state.dart'; +import 'package:wger/widgets/routines/gym_mode/workout_menu.dart'; + +import '../../../test_data/routines.dart'; + +void main() { + late GymStateNotifier notifier; + late ProviderContainer container; + + setUp(() { + container = ProviderContainer.test(); + notifier = container.read(gymStateProvider.notifier); + notifier.state = notifier.state.copyWith( + showExercisePages: false, + showTimerPages: false, + dayId: 1, + iteration: 1, + routine: getTestRoutine(), + ); + notifier.calculatePages(); + }); + + Widget renderWidget({locale = 'en'}) { + return UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ProgressionTab(PageController()), + ), + ), + ); + } + + testWidgets( + 'Smoke and golden test', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(500, 1000); + tester.view.devicePixelRatio = 1.0; // Ensure correct pixel ratio + + await tester.pumpWidget(renderWidget()); + + if (Platform.isLinux) { + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/gym_mode_progression_tab.png'), + ); + } + }, + tags: ['golden'], + ); + + testWidgets('Opens the exercise swap widget', (WidgetTester tester) async { + await tester.pumpWidget(renderWidget()); + + expect(find.byType(ExerciseSwapWidget), findsNothing); + + await tester.tap(find.byKey(Key('swap-icon-${notifier.state.pages[1].uuid}'))); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseSwapWidget), findsOne); + }); + + testWidgets('Opens the add exercise widget', (WidgetTester tester) async { + await tester.pumpWidget(renderWidget()); + + expect(find.byType(ExerciseAddWidget), findsNothing); + + await tester.tap(find.byKey(Key('add-icon-${notifier.state.pages[1].uuid}'))); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseAddWidget), findsOne); + }); +} diff --git a/test/routine/gym_mode_screen_test.dart b/test/routine/gym_mode_screen_test.dart deleted file mode 100644 index 7a06e6d1..00000000 --- a/test/routine/gym_mode_screen_test.dart +++ /dev/null @@ -1,288 +0,0 @@ -/* - * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team - * - * wger Workout Manager is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * wger Workout Manager is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import 'package:clock/clock.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.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/l10n/generated/app_localizations.dart'; -import 'package:wger/models/exercises/category.dart'; -import 'package:wger/models/exercises/equipment.dart'; -import 'package:wger/providers/exercise_data.dart'; -import 'package:wger/providers/routines.dart'; -import 'package:wger/screens/gym_mode.dart'; -import 'package:wger/screens/routine_screen.dart'; -import 'package:wger/widgets/routines/forms/reps_unit.dart'; -import 'package:wger/widgets/routines/forms/rir.dart'; -import 'package:wger/widgets/routines/forms/weight_unit.dart'; -import 'package:wger/widgets/routines/gym_mode/exercise_overview.dart'; -import 'package:wger/widgets/routines/gym_mode/log_page.dart'; -import 'package:wger/widgets/routines/gym_mode/session_page.dart'; -import 'package:wger/widgets/routines/gym_mode/start_page.dart'; -import 'package:wger/widgets/routines/gym_mode/timer.dart'; - -import '../../test_data/exercises.dart'; -import '../../test_data/routines.dart'; - -void main() { - final key = GlobalKey(); - - setUp(() { - SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); - }); - - Widget renderGymMode({locale = 'en'}) { - final container = ProviderContainer.test( - overrides: [ - exercisesProvider.overrideWithValue(riverpod.AsyncValue.data(getTestExercises())), - exerciseEquipmentProvider.overrideWithValue( - const riverpod.AsyncValue.data([]), - ), - exerciseCategoriesProvider.overrideWithValue( - const riverpod.AsyncValue.data([]), - ), - routineRepetitionUnitProvider.overrideWithValue( - const riverpod.AsyncValue.data(testRepetitionUnits), - ), - routineWeightUnitProvider.overrideWithValue( - const riverpod.AsyncValue.data(testWeightUnits), - ), - ], - ); - - container.read(routinesRiverpodProvider.notifier).state = RoutinesState( - routines: [getTestRoutine()], - ); - - return UncontrolledProviderScope( - container: container, - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - navigatorKey: key, - home: TextButton( - onPressed: () => key.currentState!.push( - MaterialPageRoute( - settings: const RouteSettings(arguments: GymModeArguments(1, 1, 1)), - builder: (_) => const GymModeScreen(), - ), - ), - child: const SizedBox(), - ), - routes: {RoutineScreen.routeName: (ctx) => const RoutineScreen()}, - ), - ); - } - - testWidgets('Test the widgets on the gym mode screen', (WidgetTester tester) async { - 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 - // - expect(find.byType(StartPage), findsOneWidget); - expect(find.text('Your workout today'), findsOneWidget); - expect(find.text('Bench press'), findsOneWidget); - expect(find.text('Side raises'), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.toc), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsNothing); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Bench press - exercise overview page - // - expect(find.text('Bench press'), findsOneWidget); - expect(find.byType(ExerciseOverview), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.toc), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - await tester.drag(find.byType(ExerciseOverview), const Offset(-500.0, 0.0)); - await tester.pumpAndSettle(); - - // - // Bench press - Log - // - expect(find.text('Bench press'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - expect(find.byType(Form), findsOneWidget); - // print(find.byType(Form)); - expect(find.byType(ListTile), findsNWidgets(3), reason: 'Two logs and the switch tile'); - expect(find.text('10 × 10 kg (1.5 RiR)'), findsOneWidget); - expect(find.text('12 × 10 kg (2 RiR)'), findsOneWidget); - expect(find.text('Make sure to warm up'), findsOneWidget, reason: 'Set comment'); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.toc), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - - // Form shows only weight and reps - expect(find.byType(SwitchListTile), findsOneWidget); - expect(find.byType(TextFormField), findsNWidgets(2)); - expect(find.byType(RepetitionUnitInputWidget), findsNothing); - expect(find.byType(WeightUnitInputWidget), findsNothing); - expect(find.byType(RiRInputWidget), findsNothing); - - // Form shows unit and rir after tapping the toggle button - await tester.tap(find.byType(SwitchListTile)); - await tester.pump(); - expect(find.byType(RepetitionUnitInputWidget), findsOneWidget); - expect(find.byType(WeightUnitInputWidget), findsOneWidget); - expect(find.byType(RiRInputWidget), findsOneWidget); - await tester.drag(find.byType(LogPage), const Offset(-500.0, 0.0)); - await tester.pumpAndSettle(); - - // - // Bench press - pause - // - expect(find.text('Pause'), findsOneWidget); - expect(find.byType(TimerCountdownWidget), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.toc), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Bench press - log - // - expect(find.text('Bench press'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - expect(find.byType(Form), findsOneWidget); - await tester.drag(find.byType(LogPage), const Offset(-500.0, 0.0)); - await tester.pumpAndSettle(); - - // - // Pause - // - expect(find.text('Pause'), findsOneWidget); - expect(find.byType(TimerCountdownWidget), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Bench press - log - // - expect(find.text('Bench press'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - expect(find.byType(Form), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Pause - // - expect(find.text('Pause'), findsOneWidget); - expect(find.byType(TimerCountdownWidget), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - overview - // - expect(find.text('Side raises'), findsOneWidget); - expect(find.byType(ExerciseOverview), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - log - // - expect(find.text('Side raises'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - timer - // - expect(find.byType(TimerWidget), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - log - // - expect(find.text('Side raises'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - timer - // - expect(find.byType(TimerWidget), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - log - // - expect(find.text('Side raises'), findsOneWidget); - expect(find.byType(LogPage), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Side raises - timer - // - expect(find.byType(TimerWidget), findsOneWidget); - await tester.tap(find.byIcon(Icons.chevron_right)); - await tester.pumpAndSettle(); - - // - // Session - // - expect(find.text('Workout session'), findsOneWidget); - expect(find.byType(SessionPage), findsOneWidget); - expect(find.byType(Form), findsOneWidget); - expect(find.byIcon(Icons.sentiment_very_dissatisfied), findsOneWidget); - expect(find.byIcon(Icons.sentiment_neutral), findsOneWidget); - expect(find.byIcon(Icons.sentiment_very_satisfied), findsOneWidget); - expect( - find.text('14:33'), - findsNWidgets(2), - reason: 'start and end time are the same', - ); - final toggleButtons = tester.widget(find.byType(ToggleButtons)); - expect(toggleButtons.isSelected[1], isTrue); - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.close), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsNothing); - - // - }); - }); -} diff --git a/test/routine/models/log_test.dart b/test/routine/models/log_test.dart new file mode 100644 index 00000000..ba8f5336 --- /dev/null +++ b/test/routine/models/log_test.dart @@ -0,0 +1,128 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/models/workouts/log.dart'; + +void main() { + group('Log.volume', () { + test('returns weight * repetitions for metric (kg) and repetition unit', () { + final log = Log( + exerciseId: 1, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 100, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 5, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + expect(log.volume(metric: true), equals(500)); + }); + + test('returns 0 when weight unit does not match metric flag', () { + final log = Log( + exerciseId: 2, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 100, + weightUnitId: WEIGHT_UNIT_LB, // pounds + repetitions: 5, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + // metric = true expects KG -> mismatch -> 0 + expect(log.volume(metric: true), equals(0)); + }); + + test('returns weight * repetitions for imperial (lb) when metric=false', () { + final log = Log( + exerciseId: 3, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 220, // lb + weightUnitId: WEIGHT_UNIT_LB, + repetitions: 3, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + expect(log.volume(metric: false), equals(660)); + }); + + test('returns 0 when repetitions unit is not repetitions', () { + final log = Log( + exerciseId: 4, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 50, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 10, + repetitionsUnitId: 999, // some other unit id + ); + + expect(log.volume(metric: true), equals(0)); + }); + + test('returns 0 when weight or repetitions are null', () { + final a = Log( + exerciseId: 5, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: null, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 5, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final b = Log( + exerciseId: 6, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 50, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: null, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + expect(a.volume(metric: true), equals(0)); + expect(b.volume(metric: true), equals(0)); + }); + + test('works with non-integer (num) weight and repetitions', () { + final log = Log( + exerciseId: 7, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 10.5, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 3, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + expect(log.volume(metric: true), closeTo(31.5, 0.0001)); + }); + }); +} diff --git a/test/routine/models/session.dart b/test/routine/models/session.dart new file mode 100644 index 00000000..14fd1297 --- /dev/null +++ b/test/routine/models/session.dart @@ -0,0 +1,175 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/models/workouts/log.dart'; +import 'package:wger/models/workouts/session.dart'; + +void main() { + group('WorkoutSession.volume', () { + test('sums metric volumes correctly', () { + final session = WorkoutSession(routineId: 1); + + final a = Log( + exerciseId: 1, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 100, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 3, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final b = Log( + exerciseId: 2, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 50, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 2, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + session.logs = [a, b]; + + final vol = session.volume; + expect(vol['metric'], equals(100 * 3 + 50 * 2)); + expect(vol['imperial'], equals(0)); + }); + + test('sums imperial volumes correctly', () { + final session = WorkoutSession(routineId: 1); + + final a = Log( + exerciseId: 3, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 220, + weightUnitId: WEIGHT_UNIT_LB, + repetitions: 4, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final b = Log( + exerciseId: 4, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 150, + weightUnitId: WEIGHT_UNIT_LB, + repetitions: 1, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + session.logs = [a, b]; + + final vol = session.volume; + expect(vol['imperial'], equals(220 * 4 + 150 * 1)); + expect(vol['metric'], equals(0)); + }); + + test('ignores logs with non-matching units or non-rep units', () { + final session = WorkoutSession(routineId: 1); + + final a = Log( + exerciseId: 5, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 100, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 5, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final b = Log( + exerciseId: 6, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 60, + weightUnitId: WEIGHT_UNIT_LB, // different unit + repetitions: 2, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final c = Log( + exerciseId: 7, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 30, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 20, + repetitionsUnitId: 999, // some other repetition unit -> should be ignored + ); + + session.logs = [a, b, c]; + + final vol = session.volume; + // only 'a' should count for metric, only 'b' for imperial + expect(vol['metric'], equals(100 * 5)); + expect(vol['imperial'], equals(60 * 2)); + }); + + test('returns zero for empty logs', () { + final session = WorkoutSession(routineId: 1); + session.logs = []; + + final vol = session.volume; + expect(vol['metric'], equals(0)); + expect(vol['imperial'], equals(0)); + }); + + test('works with fractional weights and reps', () { + final session = WorkoutSession(routineId: 1); + + final a = Log( + exerciseId: 8, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 10.5, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 3, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + final b = Log( + exerciseId: 9, + routineId: 1, + rir: null, + date: DateTime.now(), + weight: 5.25, + weightUnitId: WEIGHT_UNIT_KG, + repetitions: 2.5, + repetitionsUnitId: REP_UNIT_REPETITIONS_ID, + ); + + session.logs = [a, b]; + + final vol = session.volume; + expect(vol['metric'], closeTo(10.5 * 3 + 5.25 * 2.5, 1e-9)); + expect(vol['imperial'], equals(0)); + }); + }); +} diff --git a/test/routine/routine_logs_screen_test.dart b/test/routine/routine_logs_screen_test.dart index feb2135e..78e32704 100644 --- a/test/routine/routine_logs_screen_test.dart +++ b/test/routine/routine_logs_screen_test.dart @@ -31,7 +31,7 @@ import 'package:wger/providers/routines.dart'; import 'package:wger/providers/workout_log_repository.dart'; import 'package:wger/screens/routine_logs_screen.dart'; import 'package:wger/screens/routine_screen.dart'; -import 'package:wger/widgets/routines/workout_logs.dart'; +import 'package:wger/widgets/routines/logs/log_overview_routine.dart'; import '../../test_data/routines.dart'; import 'routine_logs_screen_test.mocks.dart'; diff --git a/test/widgets/routines/gym_mode/gym_mode_options_test.dart b/test/widgets/routines/gym_mode/gym_mode_options_test.dart new file mode 100644 index 00000000..cd7ea51f --- /dev/null +++ b/test/widgets/routines/gym_mode/gym_mode_options_test.dart @@ -0,0 +1,171 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020, 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.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/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/gym_state.dart'; +import 'package:wger/widgets/routines/gym_mode/start_page.dart'; + +import '../../../../test_data/routines.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ProviderContainer container; + + setUp(() { + // Use in-memory shared preferences to avoid platform channels during tests + SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); + + container = ProviderContainer( + overrides: [ + gymStateProvider.overrideWith(() => GymStateNotifier()), + ], + ); + + final routine = getTestRoutine(); + final notifier = container.read(gymStateProvider.notifier); + notifier.state = GymModeState( + showExercisePages: true, + showTimerPages: true, + useCountdownBetweenSets: true, + countdownDuration: const Duration(seconds: DEFAULT_COUNTDOWN_DURATION), + alertOnCountdownEnd: false, + dayId: routine.days.first.id, + iteration: 1, + routine: routine, + ); + + notifier.calculatePages(); + }); + + tearDown(() { + container.dispose(); + }); + + Future pumpGymModeOptions(WidgetTester tester) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: GymModeOptions(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('Switches update the notifier state', (tester) async { + await pumpGymModeOptions(tester); + + // Open options (tap the ListTile to toggle _showOptions) + final optionsTile = find.byKey(const ValueKey('gym-mode-options-tile')); + expect(optionsTile, findsOneWidget); + await tester.tap(optionsTile); + await tester.pumpAndSettle(); + + // Toggle notify countdown first (it is only enabled while timer/countdown are active) + final notifySwitch = find.byKey(const ValueKey('gym-mode-notify-countdown')); + expect(notifySwitch, findsOneWidget); + await tester.tap(notifySwitch); + await tester.pumpAndSettle(); + + // Now toggle show exercises + final showExercisesSwitch = find.byKey(const ValueKey('gym-mode-option-show-exercises')); + expect(showExercisesSwitch, findsOneWidget); + await tester.tap(showExercisesSwitch); + await tester.pumpAndSettle(); + + // Toggle show timer (this will disable notify switch) + final showTimerSwitch = find.byKey(const ValueKey('gym-mode-option-show-timer')); + expect(showTimerSwitch, findsOneWidget); + await tester.tap(showTimerSwitch); + await tester.pumpAndSettle(); + + final notifier = container.read(gymStateProvider.notifier); + expect(notifier.state.showExercisePages, isFalse); + expect(notifier.state.showTimerPages, isFalse); + expect(notifier.state.alertOnCountdownEnd, isTrue); + }); + + testWidgets('Dropdown, text field and refresh button update notifier state', (tester) async { + await pumpGymModeOptions(tester); + + // Open options + final optionsTile = find.byKey(const ValueKey('gym-mode-options-tile')); + await tester.tap(optionsTile); + await tester.pumpAndSettle(); + + final notifier = container.read(gymStateProvider.notifier); + + // Change dropdown (countdown type) -> switch to stopwatch (false) + final dropdown = find.byKey(const ValueKey('countdown-type-dropdown')); + expect(dropdown, findsOneWidget); + + await tester.tap(dropdown); + await tester.pumpAndSettle(); + + // Select the visible menu entry by its text label (English: 'Stopwatch') to avoid hit-test issues + final stopwatchText = find.text('Stopwatch'); + expect(stopwatchText, findsWidgets); + await tester.tap(stopwatchText.first); + await tester.pumpAndSettle(); + + expect(notifier.state.useCountdownBetweenSets, isFalse); + + // switch back to countdown (true) + await tester.tap(dropdown); + await tester.pumpAndSettle(); + final countdownText = find.text('Countdown'); + expect(countdownText, findsWidgets); + await tester.tap(countdownText.first); + await tester.pumpAndSettle(); + + expect(notifier.state.useCountdownBetweenSets, isTrue); + + // Enter a new countdown duration in the TextFormField + final countdownField = find.byKey(const ValueKey('gym-mode-default-countdown-time')); + expect(countdownField, findsOneWidget); + + // The TextFormField is a descendant; find the editable TextField + final textField = find.descendant(of: countdownField, matching: find.byType(TextFormField)); + expect(textField, findsOneWidget); + + await tester.enterText(textField, '60'); + await tester.pumpAndSettle(); + + expect(notifier.state.countdownDuration.inSeconds, 60); + + // Tap refresh button (suffix icon). Find IconButton inside the input decoration + final refreshIcon = find.descendant(of: countdownField, matching: find.byIcon(Icons.refresh)); + expect(refreshIcon, findsOneWidget); + await tester.tap(refreshIcon); + await tester.pumpAndSettle(); + + expect(notifier.state.countdownDuration.inSeconds, DEFAULT_COUNTDOWN_DURATION); + }); +}