diff --git a/lib/helpers/json.dart b/lib/helpers/json.dart index 91cd0756..b0202ca3 100644 --- a/lib/helpers/json.dart +++ b/lib/helpers/json.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -70,6 +70,14 @@ DateTime utcIso8601ToLocalDate(String dateTime) { return DateTime.parse(dateTime).toLocal(); } +DateTime? utcIso8601ToLocalDateNull(String? dateTime) { + if (dateTime == null) { + return null; + } + + return utcIso8601ToLocalDate(dateTime); +} + /* * Converts a time to a date object. * Needed e.g. when the wger api only sends a time but no date information. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6d845a4f..81ac6e4c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -176,6 +176,7 @@ "slotEntryTypeTut": "Time under Tension", "slotEntryTypeIso": "Isometric hold", "slotEntryTypeJump": "Jump", + "trophies": "Trophies", "routines": "Routines", "newRoutine": "New routine", "noRoutines": "You have no routines", @@ -233,6 +234,16 @@ "@comment": { "description": "Comment, additional information" }, + "topSet": "Top set: {value}", + "@topSet": { + "description": "Value is a representation of the set, like '10 x 80kg'", + "type": "text", + "placeholders": { + "value": { + "type": "String" + } + } + }, "impressionGood": "Good", "impressionNeutral": "Neutral", "impressionBad": "Bad", @@ -263,6 +274,7 @@ }, "selectExercises": "If you want to do a superset you can search for several exercises, they will be grouped together", "@selectExercises": {}, + "personalRecords": "Personal records", "gymMode": "Gym mode", "@gymMode": { "description": "Label when starting the gym mode" @@ -591,6 +603,7 @@ "@newEntry": { "description": "Title when adding a new entry such as a weight or log entry" }, + "noTrophies": "You have no trophies yet", "noWeightEntries": "You have no weight entries", "@noWeightEntries": { "description": "Message shown when the user has no logged weight entries" diff --git a/lib/main.dart b/lib/main.dart index 577f346e..956c4874 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2026 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -35,6 +35,7 @@ import 'package:wger/providers/measurement.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/routines.dart'; import 'package:wger/providers/user.dart'; +import 'package:wger/providers/wger_base_riverpod.dart'; import 'package:wger/screens/add_exercise_screen.dart'; import 'package:wger/screens/auth_screen.dart'; import 'package:wger/screens/dashboard.dart'; @@ -58,6 +59,7 @@ import 'package:wger/screens/routine_screen.dart'; import 'package:wger/screens/settings_dashboard_widgets_screen.dart'; import 'package:wger/screens/settings_plates_screen.dart'; import 'package:wger/screens/splash_screen.dart'; +import 'package:wger/screens/trophy_screen.dart'; import 'package:wger/screens/update_app_screen.dart'; import 'package:wger/screens/weight_screen.dart'; import 'package:wger/theme/theme.dart'; @@ -130,7 +132,7 @@ void main() async { }; // Application - runApp(const riverpod.ProviderScope(child: MainApp())); + runApp(const MainApp()); } class MainApp extends StatelessWidget { @@ -218,48 +220,63 @@ class MainApp extends StatelessWidget { ), ], child: Consumer( - builder: (ctx, auth, _) => Consumer( - builder: (ctx, user, _) => MaterialApp( - title: 'wger', - navigatorKey: navigatorKey, - theme: wgerLightTheme, - darkTheme: wgerDarkTheme, - highContrastTheme: wgerLightThemeHc, - highContrastDarkTheme: wgerDarkThemeHc, - themeMode: user.themeMode, - home: _getHomeScreen(auth), - routes: { - DashboardScreen.routeName: (ctx) => const DashboardScreen(), - FormScreen.routeName: (ctx) => const FormScreen(), - GalleryScreen.routeName: (ctx) => const GalleryScreen(), - GymModeScreen.routeName: (ctx) => const GymModeScreen(), - HomeTabsScreen.routeName: (ctx) => HomeTabsScreen(), - MeasurementCategoriesScreen.routeName: (ctx) => const MeasurementCategoriesScreen(), - MeasurementEntriesScreen.routeName: (ctx) => const MeasurementEntriesScreen(), - NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), - NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), - NutritionalPlanScreen.routeName: (ctx) => const NutritionalPlanScreen(), - LogMealsScreen.routeName: (ctx) => const LogMealsScreen(), - LogMealScreen.routeName: (ctx) => const LogMealScreen(), - WeightScreen.routeName: (ctx) => const WeightScreen(), - RoutineScreen.routeName: (ctx) => const RoutineScreen(), - RoutineEditScreen.routeName: (ctx) => const RoutineEditScreen(), - WorkoutLogsScreen.routeName: (ctx) => const WorkoutLogsScreen(), - RoutineListScreen.routeName: (ctx) => const RoutineListScreen(), - ExercisesScreen.routeName: (ctx) => const ExercisesScreen(), - ExerciseDetailScreen.routeName: (ctx) => const ExerciseDetailScreen(), - AddExerciseScreen.routeName: (ctx) => const AddExerciseScreen(), - AboutPage.routeName: (ctx) => const AboutPage(), - SettingsPage.routeName: (ctx) => const SettingsPage(), - LogOverviewPage.routeName: (ctx) => const LogOverviewPage(), - ConfigurePlatesScreen.routeName: (ctx) => const ConfigurePlatesScreen(), - ConfigureDashboardWidgetsScreen.routeName: (ctx) => - const ConfigureDashboardWidgetsScreen(), - }, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - ), - ), + builder: (ctx, auth, _) { + final baseInstance = WgerBaseProvider(Provider.of(ctx, listen: false)); + + return Consumer( + builder: (ctx, user, _) => riverpod.ProviderScope( + overrides: [ + wgerBaseProvider.overrideWithValue(baseInstance), + ], + child: riverpod.Consumer( + builder: (rpCtx, ref, _) { + return MaterialApp( + title: 'wger', + navigatorKey: navigatorKey, + theme: wgerLightTheme, + darkTheme: wgerDarkTheme, + highContrastTheme: wgerLightThemeHc, + highContrastDarkTheme: wgerDarkThemeHc, + themeMode: user.themeMode, + home: _getHomeScreen(auth), + routes: { + DashboardScreen.routeName: (ctx) => const DashboardScreen(), + FormScreen.routeName: (ctx) => const FormScreen(), + GalleryScreen.routeName: (ctx) => const GalleryScreen(), + GymModeScreen.routeName: (ctx) => const GymModeScreen(), + HomeTabsScreen.routeName: (ctx) => HomeTabsScreen(), + MeasurementCategoriesScreen.routeName: (ctx) => + const MeasurementCategoriesScreen(), + MeasurementEntriesScreen.routeName: (ctx) => const MeasurementEntriesScreen(), + NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), + NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), + NutritionalPlanScreen.routeName: (ctx) => const NutritionalPlanScreen(), + LogMealsScreen.routeName: (ctx) => const LogMealsScreen(), + LogMealScreen.routeName: (ctx) => const LogMealScreen(), + WeightScreen.routeName: (ctx) => const WeightScreen(), + RoutineScreen.routeName: (ctx) => const RoutineScreen(), + RoutineEditScreen.routeName: (ctx) => const RoutineEditScreen(), + WorkoutLogsScreen.routeName: (ctx) => const WorkoutLogsScreen(), + RoutineListScreen.routeName: (ctx) => const RoutineListScreen(), + ExercisesScreen.routeName: (ctx) => const ExercisesScreen(), + ExerciseDetailScreen.routeName: (ctx) => const ExerciseDetailScreen(), + AddExerciseScreen.routeName: (ctx) => const AddExerciseScreen(), + AboutPage.routeName: (ctx) => const AboutPage(), + SettingsPage.routeName: (ctx) => const SettingsPage(), + LogOverviewPage.routeName: (ctx) => const LogOverviewPage(), + ConfigurePlatesScreen.routeName: (ctx) => const ConfigurePlatesScreen(), + ConfigureDashboardWidgetsScreen.routeName: (ctx) => + const ConfigureDashboardWidgetsScreen(), + TrophyScreen.routeName: (ctx) => const TrophyScreen(), + }, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ); + }, + ), + ), + ); + }, ), ); } diff --git a/lib/models/trophies/trophy.dart b/lib/models/trophies/trophy.dart new file mode 100644 index 00000000..d58f5512 --- /dev/null +++ b/lib/models/trophies/trophy.dart @@ -0,0 +1,66 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:json_annotation/json_annotation.dart'; + +part 'trophy.g.dart'; + +enum TrophyType { time, volume, count, sequence, date, pr, other } + +@JsonSerializable() +class Trophy { + @JsonKey(required: true) + final int id; + + @JsonKey(required: true) + final String uuid; + + @JsonKey(required: true) + final String name; + + @JsonKey(required: true) + final String description; + + @JsonKey(required: true) + final String image; + + @JsonKey(required: true, name: 'trophy_type') + final TrophyType type; + + @JsonKey(required: true, name: 'is_hidden') + final bool isHidden; + + @JsonKey(required: true, name: 'is_progressive') + final bool isProgressive; + + Trophy({ + required this.id, + required this.uuid, + required this.name, + required this.description, + required this.image, + required this.type, + required this.isHidden, + required this.isProgressive, + }); + + // Boilerplate + factory Trophy.fromJson(Map json) => _$TrophyFromJson(json); + + Map toJson() => _$TrophyToJson(this); +} diff --git a/lib/models/trophies/trophy.g.dart b/lib/models/trophies/trophy.g.dart new file mode 100644 index 00000000..23eb2a47 --- /dev/null +++ b/lib/models/trophies/trophy.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trophy.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Trophy _$TrophyFromJson(Map json) { + $checkKeys( + json, + requiredKeys: const [ + 'id', + 'uuid', + 'name', + 'description', + 'image', + 'trophy_type', + 'is_hidden', + 'is_progressive', + ], + ); + return Trophy( + id: (json['id'] as num).toInt(), + uuid: json['uuid'] as String, + name: json['name'] as String, + description: json['description'] as String, + image: json['image'] as String, + type: $enumDecode(_$TrophyTypeEnumMap, json['trophy_type']), + isHidden: json['is_hidden'] as bool, + isProgressive: json['is_progressive'] as bool, + ); +} + +Map _$TrophyToJson(Trophy instance) => { + 'id': instance.id, + 'uuid': instance.uuid, + 'name': instance.name, + 'description': instance.description, + 'image': instance.image, + 'trophy_type': _$TrophyTypeEnumMap[instance.type]!, + 'is_hidden': instance.isHidden, + 'is_progressive': instance.isProgressive, +}; + +const _$TrophyTypeEnumMap = { + TrophyType.time: 'time', + TrophyType.volume: 'volume', + TrophyType.count: 'count', + TrophyType.sequence: 'sequence', + TrophyType.date: 'date', + TrophyType.pr: 'pr', + TrophyType.other: 'other', +}; diff --git a/lib/models/trophies/user_trophy.dart b/lib/models/trophies/user_trophy.dart new file mode 100644 index 00000000..8e1e7a81 --- /dev/null +++ b/lib/models/trophies/user_trophy.dart @@ -0,0 +1,62 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:json_annotation/json_annotation.dart'; +import 'package:wger/helpers/json.dart'; + +import 'trophy.dart'; +import 'user_trophy_context_data.dart'; + +part 'user_trophy.g.dart'; + +/// A trophy awarded to a user for achieving a specific milestone. + +@JsonSerializable() +class UserTrophy { + @JsonKey(required: true) + final int id; + + @JsonKey(required: true) + final Trophy trophy; + + @JsonKey(required: true, name: 'earned_at', fromJson: utcIso8601ToLocalDate) + final DateTime earnedAt; + + @JsonKey(required: true) + final num progress; + + @JsonKey(required: true, name: 'is_notified') + final bool isNotified; + + @JsonKey(required: true, name: 'context_data') + final ContextData? contextData; + + UserTrophy({ + required this.id, + required this.trophy, + required this.earnedAt, + required this.progress, + required this.isNotified, + this.contextData, + }); + + // Boilerplate + factory UserTrophy.fromJson(Map json) => _$UserTrophyFromJson(json); + + Map toJson() => _$UserTrophyToJson(this); +} diff --git a/lib/models/trophies/user_trophy.g.dart b/lib/models/trophies/user_trophy.g.dart new file mode 100644 index 00000000..97d9da44 --- /dev/null +++ b/lib/models/trophies/user_trophy.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_trophy.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserTrophy _$UserTrophyFromJson(Map json) { + $checkKeys( + json, + requiredKeys: const [ + 'id', + 'trophy', + 'earned_at', + 'progress', + 'is_notified', + 'context_data', + ], + ); + return UserTrophy( + id: (json['id'] as num).toInt(), + trophy: Trophy.fromJson(json['trophy'] as Map), + earnedAt: utcIso8601ToLocalDate(json['earned_at'] as String), + progress: json['progress'] as num, + isNotified: json['is_notified'] as bool, + contextData: json['context_data'] == null + ? null + : ContextData.fromJson(json['context_data'] as Map), + ); +} + +Map _$UserTrophyToJson(UserTrophy instance) => { + 'id': instance.id, + 'trophy': instance.trophy, + 'earned_at': instance.earnedAt.toIso8601String(), + 'progress': instance.progress, + 'is_notified': instance.isNotified, + 'context_data': instance.contextData, +}; diff --git a/lib/models/trophies/user_trophy_context_data.dart b/lib/models/trophies/user_trophy_context_data.dart new file mode 100644 index 00000000..d114e1e1 --- /dev/null +++ b/lib/models/trophies/user_trophy_context_data.dart @@ -0,0 +1,75 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:json_annotation/json_annotation.dart'; +import 'package:wger/helpers/json.dart'; + +part 'user_trophy_context_data.g.dart'; + +/// A trophy awarded to a user for achieving a specific milestone. + +@JsonSerializable() +class ContextData { + @JsonKey(required: true, name: 'log_id') + final int logId; + + @JsonKey(required: true, fromJson: utcIso8601ToLocalDate) + final DateTime date; + + @JsonKey(required: true, name: 'session_id') + final int sessionId; + + @JsonKey(required: true, name: 'exercise_id') + final int exerciseId; + + @JsonKey(required: true, name: 'repetitions_unit_id') + final int repetitionsUnitId; + + @JsonKey(required: true) + final num repetitions; + + @JsonKey(required: true, name: 'weight_unit_id') + final int weightUnitId; + + @JsonKey(required: true) + final num weight; + + @JsonKey(required: true) + final int? iteration; + + @JsonKey(required: true, name: 'one_rep_max_estimate') + final num oneRepMaxEstimate; + + ContextData({ + required this.logId, + required this.date, + required this.sessionId, + required this.exerciseId, + required this.repetitionsUnitId, + required this.repetitions, + required this.weightUnitId, + required this.weight, + this.iteration, + required this.oneRepMaxEstimate, + }); + + // Boilerplate + factory ContextData.fromJson(Map json) => _$ContextDataFromJson(json); + + Map toJson() => _$ContextDataToJson(this); +} diff --git a/lib/models/trophies/user_trophy_context_data.g.dart b/lib/models/trophies/user_trophy_context_data.g.dart new file mode 100644 index 00000000..e1fcc63a --- /dev/null +++ b/lib/models/trophies/user_trophy_context_data.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_trophy_context_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContextData _$ContextDataFromJson(Map json) { + $checkKeys( + json, + requiredKeys: const [ + 'log_id', + 'date', + 'session_id', + 'exercise_id', + 'repetitions_unit_id', + 'repetitions', + 'weight_unit_id', + 'weight', + 'iteration', + 'one_rep_max_estimate', + ], + ); + return ContextData( + logId: (json['log_id'] as num).toInt(), + date: utcIso8601ToLocalDate(json['date'] as String), + sessionId: (json['session_id'] as num).toInt(), + exerciseId: (json['exercise_id'] as num).toInt(), + repetitionsUnitId: (json['repetitions_unit_id'] as num).toInt(), + repetitions: json['repetitions'] as num, + weightUnitId: (json['weight_unit_id'] as num).toInt(), + weight: json['weight'] as num, + iteration: (json['iteration'] as num?)?.toInt(), + oneRepMaxEstimate: json['one_rep_max_estimate'] as num, + ); +} + +Map _$ContextDataToJson(ContextData instance) => { + 'log_id': instance.logId, + 'date': instance.date.toIso8601String(), + 'session_id': instance.sessionId, + 'exercise_id': instance.exerciseId, + 'repetitions_unit_id': instance.repetitionsUnitId, + 'repetitions': instance.repetitions, + 'weight_unit_id': instance.weightUnitId, + 'weight': instance.weight, + 'iteration': instance.iteration, + 'one_rep_max_estimate': instance.oneRepMaxEstimate, +}; diff --git a/lib/models/trophies/user_trophy_progression.dart b/lib/models/trophies/user_trophy_progression.dart new file mode 100644 index 00000000..6cf115ba --- /dev/null +++ b/lib/models/trophies/user_trophy_progression.dart @@ -0,0 +1,67 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:json_annotation/json_annotation.dart'; +import 'package:wger/helpers/json.dart'; +import 'package:wger/models/trophies/trophy.dart'; + +part 'user_trophy_progression.g.dart'; + +@JsonSerializable() +class UserTrophyProgression { + @JsonKey(required: true) + final Trophy trophy; + + @JsonKey(required: true, name: 'is_earned') + final bool isEarned; + + @JsonKey(required: true, name: 'earned_at', fromJson: utcIso8601ToLocalDateNull) + final DateTime? earnedAt; + + /// Progress towards earning the trophy (0-100%) + @JsonKey(required: true) + final num progress; + + /// Current value towards the trophy goal (e.g., number of workouts completed) + @JsonKey(required: true, name: 'current_value', fromJson: stringToNumNull) + num? currentValue; + + /// Target value to achieve the trophy goal + @JsonKey(required: true, name: 'target_value', fromJson: stringToNumNull) + num? targetValue; + + /// Human-readable progress display (e.g., "3 / 10" or "51%") + @JsonKey(required: true, name: 'progress_display') + String? progressDisplay; + + UserTrophyProgression({ + required this.trophy, + required this.isEarned, + required this.earnedAt, + required this.progress, + required this.currentValue, + required this.targetValue, + this.progressDisplay, + }); + + // Boilerplate + factory UserTrophyProgression.fromJson(Map json) => + _$UserTrophyProgressionFromJson(json); + + Map toJson() => _$UserTrophyProgressionToJson(this); +} diff --git a/lib/models/trophies/user_trophy_progression.g.dart b/lib/models/trophies/user_trophy_progression.g.dart new file mode 100644 index 00000000..de2a49e7 --- /dev/null +++ b/lib/models/trophies/user_trophy_progression.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_trophy_progression.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserTrophyProgression _$UserTrophyProgressionFromJson( + Map json, +) { + $checkKeys( + json, + requiredKeys: const [ + 'trophy', + 'is_earned', + 'earned_at', + 'progress', + 'current_value', + 'target_value', + 'progress_display', + ], + ); + return UserTrophyProgression( + trophy: Trophy.fromJson(json['trophy'] as Map), + isEarned: json['is_earned'] as bool, + earnedAt: utcIso8601ToLocalDateNull(json['earned_at'] as String?), + progress: json['progress'] as num, + currentValue: stringToNumNull(json['current_value'] as String?), + targetValue: stringToNumNull(json['target_value'] as String?), + progressDisplay: json['progress_display'] as String?, + ); +} + +Map _$UserTrophyProgressionToJson( + UserTrophyProgression instance, +) => { + 'trophy': instance.trophy, + 'is_earned': instance.isEarned, + 'earned_at': instance.earnedAt?.toIso8601String(), + 'progress': instance.progress, + 'current_value': instance.currentValue, + 'target_value': instance.targetValue, + 'progress_display': instance.progressDisplay, +}; diff --git a/lib/providers/base_provider.dart b/lib/providers/base_provider.dart index b905e29c..041871ae 100644 --- a/lib/providers/base_provider.dart +++ b/lib/providers/base_provider.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2026 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -45,7 +45,7 @@ class WgerBaseProvider { this.client = client ?? http.Client(); } - Map getDefaultHeaders({bool includeAuth = false}) { + Map getDefaultHeaders({bool includeAuth = false, String? language}) { final out = { HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8', HttpHeaders.userAgentHeader: auth.getAppNameHeader(), @@ -55,6 +55,10 @@ class WgerBaseProvider { out[HttpHeaders.authorizationHeader] = 'Token ${auth.token}'; } + if (language != null) { + out[HttpHeaders.acceptLanguageHeader] = language; + } + return out; } @@ -69,6 +73,7 @@ class WgerBaseProvider { Uri uri, { int maxRetries = 3, Duration initialDelay = const Duration(milliseconds: 250), + String? language, }) async { int attempt = 0; final random = math.Random(); @@ -85,7 +90,7 @@ class WgerBaseProvider { while (true) { try { final response = await client - .get(uri, headers: getDefaultHeaders(includeAuth: true)) + .get(uri, headers: getDefaultHeaders(includeAuth: true, language: language)) .timeout(const Duration(seconds: 5)); if (response.statusCode >= 400) { @@ -114,13 +119,13 @@ class WgerBaseProvider { } /// Fetch and retrieve the overview list of objects, returns the JSON parsed response - Future> fetchPaginated(Uri uri) async { + Future> fetchPaginated(Uri uri, {String? language}) async { final out = []; var url = uri; var allPagesProcessed = false; while (!allPagesProcessed) { - final data = await fetch(url); + final data = await fetch(url, language: language); data['results'].forEach((e) => out.add(e)); diff --git a/lib/providers/trophies.dart b/lib/providers/trophies.dart new file mode 100644 index 00000000..19289af5 --- /dev/null +++ b/lib/providers/trophies.dart @@ -0,0 +1,179 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:logging/logging.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/models/trophies/trophy.dart'; +import 'package:wger/models/trophies/user_trophy.dart'; +import 'package:wger/models/trophies/user_trophy_progression.dart'; +import 'package:wger/providers/wger_base_riverpod.dart'; + +import 'base_provider.dart'; + +part 'trophies.g.dart'; + +class TrophyState { + final _logger = Logger('TrophyState'); + + final List trophies; + final List userTrophies; + final List trophyProgression; + + TrophyState({ + this.trophies = const [], + this.userTrophies = const [], + this.trophyProgression = const [], + }); + + TrophyState copyWith({ + List? trophies, + List? userTrophies, + List? trophyProgression, + }) { + return TrophyState( + trophies: trophies ?? this.trophies, + userTrophies: userTrophies ?? this.userTrophies, + trophyProgression: trophyProgression ?? this.trophyProgression, + ); + } + + List get prTrophies => + userTrophies.where((t) => t.trophy.type == TrophyType.pr).toList(); + + List get nonPrTrophies => + userTrophies.where((t) => t.trophy.type != TrophyType.pr).toList(); +} + +class TrophyRepository { + final _logger = Logger('TrophyRepository'); + + final WgerBaseProvider base; + final trophiesPath = 'trophy'; + final userTrophiesPath = 'user-trophy'; + final userTrophyProgressionPath = 'trophy/progress'; + + TrophyRepository(this.base); + + Future> fetchTrophies({String? language}) async { + try { + final url = base.makeUrl(trophiesPath, query: {'limit': API_MAX_PAGE_SIZE}); + final trophyData = await base.fetchPaginated(url, language: language); + return trophyData.map((e) => Trophy.fromJson(e)).toList(); + } catch (e, stk) { + _logger.warning('Error fetching trophies:', e, stk); + return []; + } + } + + Future> fetchUserTrophies({ + Map? filterQuery, + String? language, + }) async { + final query = {'limit': API_MAX_PAGE_SIZE}; + if (filterQuery != null) { + query.addAll(filterQuery); + } + + try { + final url = base.makeUrl(userTrophiesPath, query: query); + final trophyData = await base.fetchPaginated(url, language: language); + return trophyData.map((e) => UserTrophy.fromJson(e)).toList(); + } catch (e, stk) { + _logger.warning('Error fetching user trophies:'); + _logger.warning(e); + _logger.warning(stk); + return []; + } + } + + Future> fetchProgression({ + Map? filterQuery, + String? language, + }) async { + try { + final url = base.makeUrl(userTrophyProgressionPath, query: filterQuery); + final List data = await base.fetch(url, language: language); + return data.map((e) => UserTrophyProgression.fromJson(e)).toList(); + } catch (e, stk) { + _logger.warning('Error fetching user trophy progression:', e, stk); + return []; + } + } + + List filterByType(List list, TrophyType type) => + list.where((t) => t.type == type).toList(); +} + +@riverpod +TrophyRepository trophyRepository(Ref ref) { + final base = ref.read(wgerBaseProvider); + return TrophyRepository(base); +} + +@Riverpod(keepAlive: true) +final class TrophyStateNotifier extends _$TrophyStateNotifier { + final _logger = Logger('TrophyStateNotifier'); + + @override + TrophyState build() { + return TrophyState(); + } + + Future fetchAll({String? language}) async { + await Future.wait([ + fetchTrophies(language: language), + fetchUserTrophies(language: language), + fetchTrophyProgression(language: language), + ]); + } + + /// Fetch all available trophies + Future> fetchTrophies({String? language}) async { + _logger.finer('Fetching trophies'); + + final repo = ref.read(trophyRepositoryProvider); + final result = await repo.fetchTrophies(language: language); + state = state.copyWith(trophies: result); + return result; + } + + /// Fetch trophies awarded to the user, excludes hidden trophies + Future> fetchUserTrophies({String? language}) async { + _logger.finer('Fetching user trophies'); + + final repo = ref.read(trophyRepositoryProvider); + final result = await repo.fetchUserTrophies( + filterQuery: {'trophy__is_hidden': 'false'}, //'trophy__is_repeatable': 'false' + language: language, + ); + state = state.copyWith(userTrophies: result); + return result; + } + + /// Fetch trophy progression for the user + Future> fetchTrophyProgression({String? language}) async { + _logger.finer('Fetching user trophy progression'); + + // Note that repeatable trophies are filtered out in the backend + final repo = ref.read(trophyRepositoryProvider); + final result = await repo.fetchProgression(language: language); + state = state.copyWith(trophyProgression: result); + return result; + } +} diff --git a/lib/providers/trophies.g.dart b/lib/providers/trophies.g.dart new file mode 100644 index 00000000..3f1ffecd --- /dev/null +++ b/lib/providers/trophies.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trophies.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(trophyRepository) +final trophyRepositoryProvider = TrophyRepositoryProvider._(); + +final class TrophyRepositoryProvider + extends $FunctionalProvider + with $Provider { + TrophyRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'trophyRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$trophyRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + TrophyRepository create(Ref ref) { + return trophyRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TrophyRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$trophyRepositoryHash() => r'0699f0c0f7f324f3ba9b21420d9845a3e3096b61'; + +@ProviderFor(TrophyStateNotifier) +final trophyStateProvider = TrophyStateNotifierProvider._(); + +final class TrophyStateNotifierProvider + extends $NotifierProvider { + TrophyStateNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'trophyStateProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$trophyStateNotifierHash(); + + @$internal + @override + TrophyStateNotifier create() => TrophyStateNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TrophyState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$trophyStateNotifierHash() => r'c80c732272cf843b698f28152f60b9a5f37ee449'; + +abstract class _$TrophyStateNotifier extends $Notifier { + TrophyState build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + TrophyState, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/providers/user.dart b/lib/providers/user.dart index e2c4c0a8..5e4004e7 100644 --- a/lib/providers/user.dart +++ b/lib/providers/user.dart @@ -28,6 +28,7 @@ import 'package:wger/models/user/profile.dart'; import 'package:wger/providers/base_provider.dart'; enum DashboardWidget { + trophies('trophies'), routines('routines'), nutrition('nutrition'), weight('weight'), @@ -124,7 +125,13 @@ class UserProvider with ChangeNotifier { // Add any missing widgets (e.g. newly added features) for (final widget in DashboardWidget.values) { if (!loaded.any((item) => item.widget == widget)) { - loaded.add(DashboardItem(widget)); + // Try to insert at the original position defined in the enum + // taking into account the current size of the list + var index = DashboardWidget.values.indexOf(widget); + if (index > loaded.length) { + index = loaded.length; + } + loaded.insert(index, DashboardItem(widget)); } } diff --git a/lib/providers/wger_base_riverpod.dart b/lib/providers/wger_base_riverpod.dart new file mode 100644 index 00000000..0c73cbd2 --- /dev/null +++ b/lib/providers/wger_base_riverpod.dart @@ -0,0 +1,31 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 - 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:wger/providers/auth.dart'; +import 'package:wger/providers/base_provider.dart'; + +/// Central provider that maps an existing [AuthProvider] (from the provider package) +/// to a [WgerBaseProvider] used by repositories. +/// +/// Usage: ref.watch(wgerBaseProvider(authProvider)) +final wgerBaseProvider = Provider((ref) { + throw UnimplementedError( + 'Override wgerBaseProvider in a ProviderScope with your existing WgerBaseProvider instance', + ); +}); diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index b7162bd7..51319722 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:wger/helpers/material.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/providers/user.dart'; import 'package:wger/widgets/core/app_bar.dart'; @@ -25,6 +26,7 @@ import 'package:wger/widgets/dashboard/calendar.dart'; import 'package:wger/widgets/dashboard/widgets/measurements.dart'; import 'package:wger/widgets/dashboard/widgets/nutrition.dart'; import 'package:wger/widgets/dashboard/widgets/routines.dart'; +import 'package:wger/widgets/dashboard/widgets/trophies.dart'; import 'package:wger/widgets/dashboard/widgets/weight.dart'; class DashboardScreen extends StatelessWidget { @@ -44,22 +46,56 @@ class DashboardScreen extends StatelessWidget { return const DashboardCalendarWidget(); case DashboardWidget.nutrition: return const DashboardNutritionWidget(); + case DashboardWidget.trophies: + return const DashboardTrophiesWidget(); } - } - - @override - Widget build(BuildContext context) { - final user = Provider.of(context); - - return Scaffold( - appBar: MainAppBar(AppLocalizations.of(context).labelDashboard), - body: SingleChildScrollView( - padding: const EdgeInsets.all(10), - child: Column( + /* + child: Column( children: user.dashboardOrder .where((w) => user.isDashboardWidgetVisible(w)) .map(_getDashboardWidget) .toList(), + */ + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final isMobile = width < MATERIAL_XS_BREAKPOINT; + final user = Provider.of(context); + + late final int crossAxisCount; + if (width < MATERIAL_XS_BREAKPOINT) { + crossAxisCount = 1; + } else if (width < MATERIAL_MD_BREAKPOINT) { + crossAxisCount = 2; + } else if (width < MATERIAL_LG_BREAKPOINT) { + crossAxisCount = 3; + } else { + crossAxisCount = 4; + } + + return Scaffold( + appBar: MainAppBar(AppLocalizations.of(context).labelDashboard), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: MATERIAL_LG_BREAKPOINT), + child: isMobile + ? ListView.builder( + padding: const EdgeInsets.all(10), + itemBuilder: (context, index) => _getDashboardWidget(user.dashboardOrder[index]), + itemCount: user.dashboardOrder.length, + ) + : GridView.builder( + padding: const EdgeInsets.all(10), + itemBuilder: (context, index) => + SingleChildScrollView(child: _getDashboardWidget(user.dashboardOrder[index])), + itemCount: user.dashboardOrder.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: 0.7, + ), + ), ), ), ); diff --git a/lib/screens/home_tabs_screen.dart b/lib/screens/home_tabs_screen.dart index ebb70398..10de168f 100644 --- a/lib/screens/home_tabs_screen.dart +++ b/lib/screens/home_tabs_screen.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -17,6 +17,7 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; @@ -30,6 +31,7 @@ import 'package:wger/providers/gallery.dart'; import 'package:wger/providers/measurement.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/providers/routines.dart'; +import 'package:wger/providers/trophies.dart'; import 'package:wger/providers/user.dart'; import 'package:wger/screens/dashboard.dart'; import 'package:wger/screens/gallery_screen.dart'; @@ -37,7 +39,7 @@ import 'package:wger/screens/nutritional_plans_screen.dart'; import 'package:wger/screens/routine_list_screen.dart'; import 'package:wger/screens/weight_screen.dart'; -class HomeTabsScreen extends StatefulWidget { +class HomeTabsScreen extends ConsumerStatefulWidget { final _logger = Logger('HomeTabsScreen'); HomeTabsScreen(); @@ -48,25 +50,20 @@ class HomeTabsScreen extends StatefulWidget { _HomeTabsScreenState createState() => _HomeTabsScreenState(); } -class _HomeTabsScreenState extends State with SingleTickerProviderStateMixin { - late Future _initialData; +class _HomeTabsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + Future? _initialData; bool _errorHandled = false; int _selectedIndex = 0; bool _isWideScreen = false; - @override - void initState() { - super.initState(); - // Loading data here, since the build method can be called more than once - _initialData = _loadEntries(); - } - @override void didChangeDependencies() { super.didChangeDependencies(); final size = MediaQuery.sizeOf(context); _isWideScreen = size.width > MATERIAL_XS_BREAKPOINT; + _initialData ??= _loadEntries(); } void _onItemTapped(int index) { @@ -85,7 +82,9 @@ class _HomeTabsScreenState extends State with SingleTickerProvid /// Load initial data from the server Future _loadEntries() async { + final languageCode = Localizations.localeOf(context).languageCode; final authProvider = context.read(); + final trophyNotifier = ref.read(trophyStateProvider.notifier); if (!authProvider.dataInit) { final routinesProvider = context.read(); @@ -127,6 +126,7 @@ class _HomeTabsScreenState extends State with SingleTickerProvid // routinesProvider.fetchAndSetAllRoutinesFull(), weightProvider.fetchAndSetEntries(), measurementProvider.fetchAndSetAllCategoriesAndEntries(), + trophyNotifier.fetchAll(language: languageCode), ]); // diff --git a/lib/screens/routine_logs_screen.dart b/lib/screens/routine_logs_screen.dart index 93da5d5c..c9909b1f 100644 --- a/lib/screens/routine_logs_screen.dart +++ b/lib/screens/routine_logs_screen.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2025 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. diff --git a/lib/screens/trophy_screen.dart b/lib/screens/trophy_screen.dart new file mode 100644 index 00000000..5df5344b --- /dev/null +++ b/lib/screens/trophy_screen.dart @@ -0,0 +1,37 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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/core/wide_screen_wrapper.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/widgets/core/app_bar.dart'; +import 'package:wger/widgets/trophies/trophies_overview.dart'; + +class TrophyScreen extends StatelessWidget { + const TrophyScreen(); + + static const routeName = '/trophies'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: EmptyAppBar(AppLocalizations.of(context).trophies), + body: const WidescreenWrapper(child: TrophiesOverview()), + ); + } +} diff --git a/lib/widgets/core/settings/dashboard_visibility.dart b/lib/widgets/core/settings/dashboard_visibility.dart index b7d110b8..a86b8d5f 100644 --- a/lib/widgets/core/settings/dashboard_visibility.dart +++ b/lib/widgets/core/settings/dashboard_visibility.dart @@ -40,6 +40,8 @@ class SettingsDashboardVisibility extends StatelessWidget { return i18n.calendar; case DashboardWidget.nutrition: return i18n.nutritionalPlans; + case DashboardWidget.trophies: + return i18n.trophies; } } diff --git a/lib/widgets/dashboard/widgets/nothing_found.dart b/lib/widgets/dashboard/widgets/nothing_found.dart index 326d3417..86afc140 100644 --- a/lib/widgets/dashboard/widgets/nothing_found.dart +++ b/lib/widgets/dashboard/widgets/nothing_found.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -22,10 +22,10 @@ import 'package:wger/theme/theme.dart'; class NothingFound extends StatelessWidget { final String _title; - final String _titleForm; - final Widget _form; + final String? _titleForm; + final Widget? _form; - const NothingFound(this._title, this._titleForm, this._form); + const NothingFound(this._title, [this._titleForm, this._form]); @override Widget build(BuildContext context) { @@ -35,21 +35,22 @@ class NothingFound extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text(_title), - IconButton( - iconSize: 30, - icon: const Icon(Icons.add_box, color: wgerPrimaryButtonColor), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - _titleForm, - hasListView: true, - _form, - ), - ); - }, - ), + if (_titleForm != null && _form != null) + IconButton( + iconSize: 30, + icon: const Icon(Icons.add_box, color: wgerPrimaryButtonColor), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + _titleForm, + hasListView: true, + _form, + ), + ); + }, + ), ], ), ); diff --git a/lib/widgets/dashboard/widgets/routines.dart b/lib/widgets/dashboard/widgets/routines.dart index 5077d905..dba38045 100644 --- a/lib/widgets/dashboard/widgets/routines.dart +++ b/lib/widgets/dashboard/widgets/routines.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -58,7 +58,7 @@ class _DashboardRoutineWidgetState extends State { ), subtitle: Text( _hasContent - ? '${dateFormat.format(routine!.start)} - ${dateFormat.format(routine!.end)}' + ? '${dateFormat.format(routine!.start)} - ${dateFormat.format(routine.end)}' : '', ), leading: Icon( diff --git a/lib/widgets/dashboard/widgets/trophies.dart b/lib/widgets/dashboard/widgets/trophies.dart new file mode 100644 index 00000000..42e1cfd4 --- /dev/null +++ b/lib/widgets/dashboard/widgets/trophies.dart @@ -0,0 +1,142 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020 - 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/trophies/trophy.dart'; +import 'package:wger/providers/trophies.dart'; +import 'package:wger/screens/trophy_screen.dart'; + +class DashboardTrophiesWidget extends ConsumerWidget { + const DashboardTrophiesWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trophiesState = ref.read(trophyStateProvider); + final i18n = AppLocalizations.of(context); + + return Card( + color: Colors.transparent, + shadowColor: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (trophiesState.nonPrTrophies.isEmpty) + Card( + child: Column( + children: [ + ListTile( + title: Text( + i18n.trophies, + style: Theme.of(context).textTheme.headlineSmall, + ), + // leading: Icon(Icons.widgets_outlined), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + i18n.noTrophies, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ) + else + SizedBox( + height: 140, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + scrollDirection: Axis.horizontal, + itemCount: trophiesState.nonPrTrophies.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final userTrophy = trophiesState.nonPrTrophies[index]; + + return SizedBox( + width: 220, + child: TrophyCard(trophy: userTrophy.trophy), + ); + }, + ), + ), + ], + ), + ); + } +} + +class TrophyCard extends StatelessWidget { + const TrophyCard({ + super.key, + required this.trophy, + }); + + final Trophy trophy; + + @override + Widget build(BuildContext context) { + return Card.filled( + child: InkWell( + onTap: () { + Navigator.of(context).pushNamed(TrophyScreen.routeName); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 30, + backgroundImage: NetworkImage(trophy.image), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + trophy.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + trophy.description, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dashboard/widgets/weight.dart b/lib/widgets/dashboard/widgets/weight.dart index 35d407b2..d13e1419 100644 --- a/lib/widgets/dashboard/widgets/weight.dart +++ b/lib/widgets/dashboard/widgets/weight.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -42,7 +42,7 @@ class DashboardWeightWidget extends StatelessWidget { ); return Consumer( - builder: (context, _, __) => Card( + builder: (context, _, _) => Card( child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/routines/gym_mode/summary.dart b/lib/widgets/routines/gym_mode/summary.dart index 439d7a0d..e5097500 100644 --- a/lib/widgets/routines/gym_mode/summary.dart +++ b/lib/widgets/routines/gym_mode/summary.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2020, 2025 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -24,10 +24,12 @@ 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/trophies/user_trophy.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/providers/trophies.dart'; import 'package:wger/widgets/core/progress_indicator.dart'; import 'package:wger/widgets/routines/gym_mode/navigation.dart'; @@ -36,7 +38,6 @@ import '../logs/muscle_groups.dart'; class WorkoutSummary extends ConsumerStatefulWidget { final _logger = Logger('WorkoutSummary'); - final PageController _controller; WorkoutSummary(this._controller); @@ -48,24 +49,39 @@ class WorkoutSummary extends ConsumerStatefulWidget { class _WorkoutSummaryState extends ConsumerState { late Future _initData; late Routine _routine; + bool _didInit = false; @override void initState() { super.initState(); - _initData = _reloadRoutineData(); } - Future _reloadRoutineData() async { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_didInit) { + final languageCode = Localizations.localeOf(context).languageCode; + _initData = _reloadRoutineData(languageCode); + _didInit = true; + } + } + + Future _reloadRoutineData(String languageCode) async { widget._logger.fine('Loading routine data'); final gymState = ref.read(gymStateProvider); _routine = await context.read().fetchAndSetRoutineFull( gymState.routine.id!, ); + + final trophyNotifier = ref.read(trophyStateProvider.notifier); + await trophyNotifier.fetchUserTrophies(language: languageCode); } @override Widget build(BuildContext context) { + final trophyState = ref.watch(trophyStateProvider); + return Column( children: [ NavigationHeader( @@ -80,12 +96,20 @@ class _WorkoutSummaryState extends ConsumerState { if (snapshot.connectionState == ConnectionState.waiting) { return const BoxedProgressIndicator(); } else if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}: ${snapshot.stackTrace}')); + widget._logger.warning(snapshot.error); + widget._logger.warning(snapshot.stackTrace); + return Center(child: Text('Error: ${snapshot.error}')); } else if (snapshot.connectionState == ConnectionState.done) { + final apiSession = _routine.sessions.firstWhereOrNull( + (s) => s.session.date.isSameDayAs(clock.now()), + ); + final userTrophies = trophyState.prTrophies + .where((t) => t.contextData?.sessionId == apiSession?.session.id) + .toList(); + return WorkoutSessionStats( - _routine.sessions.firstWhereOrNull( - (s) => s.session.date.isSameDayAs(clock.now()), - ), + apiSession, + userTrophies, ); } @@ -102,12 +126,14 @@ class _WorkoutSummaryState extends ConsumerState { class WorkoutSessionStats extends ConsumerWidget { final _logger = Logger('WorkoutSessionStats'); final WorkoutSessionApi? _sessionApi; + final List _userPrTrophies; - WorkoutSessionStats(this._sessionApi, {super.key}); + WorkoutSessionStats(this._sessionApi, this._userPrTrophies, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final i18n = AppLocalizations.of(context); + final theme = Theme.of(context); if (_sessionApi == null) { return Center( @@ -159,16 +185,19 @@ class WorkoutSessionStats extends ConsumerWidget { ), ], ), - // const SizedBox(height: 16), - // InfoCard( - // title: 'Personal Records', - // value: prCount.toString(), - // color: theme.colorScheme.tertiaryContainer, - // ), + if (_userPrTrophies.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: InfoCard( + title: i18n.personalRecords, + value: _userPrTrophies.length.toString(), + color: theme.colorScheme.tertiaryContainer, + ), + ), const SizedBox(height: 10), MuscleGroupsCard(_sessionApi.logs), const SizedBox(height: 10), - ExercisesCard(_sessionApi), + ExercisesCard(_sessionApi, _userPrTrophies), FilledButton( onPressed: () { ref.read(gymStateProvider.notifier).clear(); diff --git a/lib/widgets/routines/logs/day_logs_container.dart b/lib/widgets/routines/logs/day_logs_container.dart index 49d829b0..506723d2 100644 --- a/lib/widgets/routines/logs/day_logs_container.dart +++ b/lib/widgets/routines/logs/day_logs_container.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2020, 2025 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -17,33 +17,54 @@ */ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/helpers/date.dart'; import 'package:wger/helpers/errors.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/routine.dart'; +import 'package:wger/providers/trophies.dart'; +import '../gym_mode/summary.dart'; import 'exercise_log_chart.dart'; import 'muscle_groups.dart'; import 'session_info.dart'; -class DayLogWidget extends StatelessWidget { +class DayLogWidget extends ConsumerWidget { final DateTime _date; final Routine _routine; const DayLogWidget(this._date, this._routine); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final i18n = AppLocalizations.of(context); + final theme = Theme.of(context); + + final trophyState = ref.read(trophyStateProvider); + final sessionApi = _routine.sessions.firstWhere( (sessionApi) => sessionApi.session.date.isSameDayAs(_date), ); final exercises = sessionApi.exercises; + final prTrophies = trophyState.prTrophies + .where((t) => t.contextData?.sessionId == sessionApi.session.id) + .toList(); + return Column( spacing: 10, children: [ Card(child: SessionInfo(sessionApi.session)), + if (prTrophies.isNotEmpty) + SizedBox( + width: double.infinity, + child: InfoCard( + title: i18n.personalRecords, + value: prTrophies.length.toString(), + color: theme.colorScheme.tertiaryContainer, + ), + ), MuscleGroupsCard(sessionApi.logs), - Column( spacing: 10, children: [ @@ -66,7 +87,17 @@ class DayLogWidget extends StatelessWidget { (log) => Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(log.repTextNoNl(context)), + Row( + children: [ + if (prTrophies.any((t) => t.contextData?.logId == log.id)) + Icon( + Icons.emoji_events, + color: theme.colorScheme.primary, + size: 20, + ), + Text(log.repTextNoNl(context)), + ], + ), IconButton( icon: const Icon(Icons.delete), key: ValueKey('delete-log-${log.id}'), diff --git a/lib/widgets/routines/logs/exercises_expansion_card.dart b/lib/widgets/routines/logs/exercises_expansion_card.dart index 0286418f..0a1a3516 100644 --- a/lib/widgets/routines/logs/exercises_expansion_card.dart +++ b/lib/widgets/routines/logs/exercises_expansion_card.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (c) 2020, 2025 wger Team + * Copyright (c) 2020 - 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -20,13 +20,15 @@ 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/trophies/user_trophy.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/session_api.dart'; class ExercisesCard extends StatelessWidget { final WorkoutSessionApi session; + final List userPrTrophies; - const ExercisesCard(this.session, {super.key}); + const ExercisesCard(this.session, this.userPrTrophies, {super.key}); @override Widget build(BuildContext context) { @@ -44,7 +46,11 @@ class ExercisesCard extends StatelessWidget { const SizedBox(height: 16), ...exercises.map((exercise) { final logs = session.logs.where((log) => log.exerciseId == exercise.id).toList(); - return _ExerciseExpansionTile(exercise: exercise, logs: logs); + return _ExerciseExpansionTile( + exercise: exercise, + logs: logs, + userPrTrophies: userPrTrophies, + ); }), ], ), @@ -57,8 +63,10 @@ class _ExerciseExpansionTile extends StatelessWidget { const _ExerciseExpansionTile({ required this.exercise, required this.logs, + required this.userPrTrophies, }); + final List userPrTrophies; final Exercise exercise; final List logs; @@ -66,6 +74,7 @@ class _ExerciseExpansionTile extends StatelessWidget { Widget build(BuildContext context) { final languageCode = Localizations.localeOf(context).languageCode; final theme = Theme.of(context); + final i18n = AppLocalizations.of(context); final topSet = logs.isEmpty ? null @@ -77,21 +86,21 @@ class _ExerciseExpansionTile extends StatelessWidget { 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(), + subtitle: Text(i18n.topSet('$topSetWeight $topSetWeightUnit')), + children: logs.map((log) => _SetDataRow(log: log, userPrTrophies: userPrTrophies)).toList(), ); } } class _SetDataRow extends StatelessWidget { - const _SetDataRow({required this.log}); + const _SetDataRow({required this.log, required this.userPrTrophies}); final Log log; + final List userPrTrophies; @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), @@ -103,6 +112,8 @@ class _SetDataRow extends StatelessWidget { log.repTextNoNl(context), style: theme.textTheme.bodyMedium, ), + if (userPrTrophies.any((trophy) => trophy.contextData?.logId == log.id)) + Icon(Icons.emoji_events, color: theme.colorScheme.primary, size: 20), // if (log.volume() > 0) // Text( // '${log.volume().toStringAsFixed(0)} ${getServerStringTranslation(log.weightUnitObj!.name, context)}', diff --git a/lib/widgets/routines/logs/log_overview_routine.dart b/lib/widgets/routines/logs/log_overview_routine.dart index dfcfc1aa..34f0be5a 100644 --- a/lib/widgets/routines/logs/log_overview_routine.dart +++ b/lib/widgets/routines/logs/log_overview_routine.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2025 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -18,20 +18,26 @@ import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/routine.dart'; +import 'package:wger/providers/trophies.dart'; import 'package:wger/theme/theme.dart'; import 'package:wger/widgets/routines/logs/day_logs_container.dart'; -class WorkoutLogs extends StatelessWidget { +class WorkoutLogs extends ConsumerWidget { final Routine _routine; const WorkoutLogs(this._routine); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final languageCode = Localizations.localeOf(context).languageCode; + final trophyNotifier = ref.read(trophyStateProvider.notifier); + trophyNotifier.fetchUserTrophies(language: languageCode); + return ListView( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), children: [ diff --git a/lib/widgets/trophies/trophies_overview.dart b/lib/widgets/trophies/trophies_overview.dart new file mode 100644 index 00000000..4f37f087 --- /dev/null +++ b/lib/widgets/trophies/trophies_overview.dart @@ -0,0 +1,179 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 - 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/helpers/material.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/trophies/user_trophy_progression.dart'; +import 'package:wger/providers/trophies.dart'; + +class TrophiesOverview extends ConsumerWidget { + const TrophiesOverview({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trophyState = ref.watch(trophyStateProvider); + final i18n = AppLocalizations.of(context); + + // Responsive grid: determine columns based on screen width + final width = MediaQuery.widthOf(context); + int crossAxisCount = 1; + if (width <= MATERIAL_XS_BREAKPOINT) { + crossAxisCount = 2; + } else if (width > MATERIAL_XS_BREAKPOINT && width < MATERIAL_MD_BREAKPOINT) { + crossAxisCount = 3; + } else if (width >= MATERIAL_MD_BREAKPOINT && width < MATERIAL_LG_BREAKPOINT) { + crossAxisCount = 4; + } else { + crossAxisCount = 5; + } + + // If empty, show placeholder + if (trophyState.trophyProgression.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + i18n.noTrophies, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + ); + } + + return RepaintBoundary( + child: GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + ), + key: const ValueKey('trophy-grid'), + itemCount: trophyState.trophyProgression.length, + itemBuilder: (context, index) { + return _TrophyCardImage(userProgression: trophyState.trophyProgression[index]); + }, + ), + ); + } +} + +class _TrophyCardImage extends StatelessWidget { + final UserTrophyProgression userProgression; + + const _TrophyCardImage({required this.userProgression}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final double progress = (userProgression.progress.toDouble() / 100.0).clamp(0.0, 1.0); + + return Opacity( + opacity: userProgression.isEarned ? 1.0 : 0.5, + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.18), + width: userProgression.isEarned ? 1.2 : 0, + ), + ), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 70, + height: 70, + child: ClipOval( + child: Image.network( + userProgression.trophy.image, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Center( + child: Icon(Icons.emoji_events, size: 28, color: colorScheme.primary), + ), + ), + ), + ), + + Text( + userProgression.trophy.name, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + + Text( + userProgression.trophy.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + + if (userProgression.trophy.isProgressive && !userProgression.isEarned) + Tooltip( + message: 'Progress: ${userProgression.progressDisplay}', + child: SizedBox( + height: 6, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + valueColor: AlwaysStoppedAnimation(colorScheme.primary), + backgroundColor: colorScheme.onSurface.withAlpha((0.06 * 255).round()), + ), + ), + ), + ), + ], + ), + ), + if (userProgression.isEarned) + Positioned( + top: 6, + right: 6, + child: Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check, size: 16, color: Colors.white), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/core/settings_dashboard_visibility_test.dart b/test/core/settings_dashboard_visibility_test.dart index a171bbd4..1aedab97 100644 --- a/test/core/settings_dashboard_visibility_test.dart +++ b/test/core/settings_dashboard_visibility_test.dart @@ -29,10 +29,7 @@ import 'package:wger/widgets/core/settings/dashboard_visibility.dart'; import 'settings_dashboard_visibility_test.mocks.dart'; -@GenerateMocks([ - UserProvider, - WgerBaseProvider, -]) +@GenerateMocks([WgerBaseProvider]) void main() { late UserProvider userProvider; late MockWgerBaseProvider mockBaseProvider; @@ -98,24 +95,23 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - // Initial order: routines, nutrition, weight... - expect(userProvider.dashboardOrder[0], DashboardWidget.routines); - expect(userProvider.dashboardOrder[1], DashboardWidget.nutrition); + // Initial order: trophies, routines, nutrition, weight... + expect(userProvider.dashboardOrder[0], DashboardWidget.trophies); + expect(userProvider.dashboardOrder[1], DashboardWidget.routines); - // Find drag handle for Routines (index 0) + // Find drag handle for Trophies (index 0) final handleFinder = find.byIcon(Icons.drag_handle); final firstHandle = handleFinder.at(0); - // final secondHandle = handleFinder.at(1); // Drag first item down await tester.drag(firstHandle, const Offset(0, 100)); // Drag down enough to swap await tester.pumpAndSettle(); // Verify order changed - // If swapped with second item (nutrition) and maybe third (weight) depending on height - // Based on running test: index 0 is nutrition, index 1 is weight. - expect(userProvider.dashboardOrder[0], DashboardWidget.nutrition); - expect(userProvider.dashboardOrder[1], DashboardWidget.weight); - expect(userProvider.dashboardOrder[2], DashboardWidget.routines); + // 100px drag seems to skip 2 items (trophies moves to index 2) + // [routines, nutrition, trophies, ...] + expect(userProvider.dashboardOrder[0], DashboardWidget.routines); + expect(userProvider.dashboardOrder[1], DashboardWidget.nutrition); + expect(userProvider.dashboardOrder[2], DashboardWidget.trophies); }); } diff --git a/test/core/settings_dashboard_visibility_test.mocks.dart b/test/core/settings_dashboard_visibility_test.mocks.dart index 01803e1b..bc9ac783 100644 --- a/test/core/settings_dashboard_visibility_test.mocks.dart +++ b/test/core/settings_dashboard_visibility_test.mocks.dart @@ -3,17 +3,12 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i9; -import 'dart:ui' as _i10; +import 'dart:async' as _i5; -import 'package:flutter/material.dart' as _i7; -import 'package:http/http.dart' as _i5; +import 'package:http/http.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; -import 'package:shared_preferences/shared_preferences.dart' as _i3; -import 'package:wger/models/user/profile.dart' as _i8; -import 'package:wger/providers/auth.dart' as _i4; -import 'package:wger/providers/base_provider.dart' as _i2; -import 'package:wger/providers/user.dart' as _i6; +import 'package:wger/providers/auth.dart' as _i2; +import 'package:wger/providers/base_provider.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -30,234 +25,67 @@ import 'package:wger/providers/user.dart' as _i6; // ignore_for_file: subtype_of_sealed_class // ignore_for_file: invalid_use_of_internal_member -class _FakeWgerBaseProvider_0 extends _i1.SmartFake implements _i2.WgerBaseProvider { - _FakeWgerBaseProvider_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeAuthProvider_0 extends _i1.SmartFake implements _i2.AuthProvider { + _FakeAuthProvider_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSharedPreferencesAsync_1 extends _i1.SmartFake implements _i3.SharedPreferencesAsync { - _FakeSharedPreferencesAsync_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeClient_1 extends _i1.SmartFake implements _i3.Client { + _FakeClient_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeAuthProvider_2 extends _i1.SmartFake implements _i4.AuthProvider { - _FakeAuthProvider_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +class _FakeUri_2 extends _i1.SmartFake implements Uri { + _FakeUri_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeClient_3 extends _i1.SmartFake implements _i5.Client { - _FakeClient_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeUri_4 extends _i1.SmartFake implements Uri { - _FakeUri_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -class _FakeResponse_5 extends _i1.SmartFake implements _i5.Response { - _FakeResponse_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); -} - -/// A class which mocks [UserProvider]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockUserProvider extends _i1.Mock implements _i6.UserProvider { - MockUserProvider() { - _i1.throwOnMissingStub(this); - } - - @override - _i7.ThemeMode get themeMode => - (super.noSuchMethod( - Invocation.getter(#themeMode), - returnValue: _i7.ThemeMode.system, - ) - as _i7.ThemeMode); - - @override - _i2.WgerBaseProvider get baseProvider => - (super.noSuchMethod( - Invocation.getter(#baseProvider), - returnValue: _FakeWgerBaseProvider_0( - this, - Invocation.getter(#baseProvider), - ), - ) - as _i2.WgerBaseProvider); - - @override - _i3.SharedPreferencesAsync get prefs => - (super.noSuchMethod( - Invocation.getter(#prefs), - returnValue: _FakeSharedPreferencesAsync_1( - this, - Invocation.getter(#prefs), - ), - ) - as _i3.SharedPreferencesAsync); - - @override - List<_i6.DashboardWidget> get dashboardOrder => - (super.noSuchMethod( - Invocation.getter(#dashboardOrder), - returnValue: <_i6.DashboardWidget>[], - ) - as List<_i6.DashboardWidget>); - - @override - set themeMode(_i7.ThemeMode? value) => super.noSuchMethod( - Invocation.setter(#themeMode, value), - returnValueForMissingStub: null, - ); - - @override - set prefs(_i3.SharedPreferencesAsync? value) => super.noSuchMethod( - Invocation.setter(#prefs, value), - returnValueForMissingStub: null, - ); - - @override - set profile(_i8.Profile? value) => super.noSuchMethod( - Invocation.setter(#profile, value), - returnValueForMissingStub: null, - ); - - @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) as bool); - - @override - void clear() => super.noSuchMethod( - Invocation.method(#clear, []), - returnValueForMissingStub: null, - ); - - @override - bool isDashboardWidgetVisible(_i6.DashboardWidget? key) => - (super.noSuchMethod( - Invocation.method(#isDashboardWidgetVisible, [key]), - returnValue: false, - ) - as bool); - - @override - _i9.Future setDashboardWidgetVisible( - _i6.DashboardWidget? key, - bool? visible, - ) => - (super.noSuchMethod( - Invocation.method(#setDashboardWidgetVisible, [key, visible]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future setDashboardOrder(int? oldIndex, int? newIndex) => - (super.noSuchMethod( - Invocation.method(#setDashboardOrder, [oldIndex, newIndex]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - void setThemeMode(_i7.ThemeMode? mode) => super.noSuchMethod( - Invocation.method(#setThemeMode, [mode]), - returnValueForMissingStub: null, - ); - - @override - _i9.Future fetchAndSetProfile() => - (super.noSuchMethod( - Invocation.method(#fetchAndSetProfile, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future saveProfile() => - (super.noSuchMethod( - Invocation.method(#saveProfile, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future verifyEmail() => - (super.noSuchMethod( - Invocation.method(#verifyEmail, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#addListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i10.VoidCallback? listener) => super.noSuchMethod( - Invocation.method(#removeListener, [listener]), - returnValueForMissingStub: null, - ); - - @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - void notifyListeners() => super.noSuchMethod( - Invocation.method(#notifyListeners, []), - returnValueForMissingStub: null, - ); +class _FakeResponse_3 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [WgerBaseProvider]. /// /// See the documentation for Mockito's code generation for more information. -class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { +class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { MockWgerBaseProvider() { _i1.throwOnMissingStub(this); } @override - _i4.AuthProvider get auth => + _i2.AuthProvider get auth => (super.noSuchMethod( Invocation.getter(#auth), - returnValue: _FakeAuthProvider_2(this, Invocation.getter(#auth)), + returnValue: _FakeAuthProvider_0(this, Invocation.getter(#auth)), ) - as _i4.AuthProvider); + as _i2.AuthProvider); @override - _i5.Client get client => + _i3.Client get client => (super.noSuchMethod( Invocation.getter(#client), - returnValue: _FakeClient_3(this, Invocation.getter(#client)), + returnValue: _FakeClient_1(this, Invocation.getter(#client)), ) - as _i5.Client); + as _i3.Client); @override - set auth(_i4.AuthProvider? value) => super.noSuchMethod( + set auth(_i2.AuthProvider? value) => super.noSuchMethod( Invocation.setter(#auth, value), returnValueForMissingStub: null, ); @override - set client(_i5.Client? value) => super.noSuchMethod( + set client(_i3.Client? value) => super.noSuchMethod( Invocation.setter(#client, value), returnValueForMissingStub: null, ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -276,7 +104,7 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { [path], {#id: id, #objectMethod: objectMethod, #query: query}, ), - returnValue: _FakeUri_4( + returnValue: _FakeUri_2( this, Invocation.method( #makeUrl, @@ -288,62 +116,67 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { as Uri); @override - _i9.Future fetch( + _i5.Future fetch( Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), - returnValue: _i9.Future.value(), + returnValue: _i5.Future.value(), ) - as _i9.Future); + as _i5.Future); @override - _i9.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), - returnValue: _i9.Future>.value([]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), + returnValue: _i5.Future>.value([]), ) - as _i9.Future>); + as _i5.Future>); @override - _i9.Future> post(Map? data, Uri? uri) => + _i5.Future> post(Map? data, Uri? uri) => (super.noSuchMethod( Invocation.method(#post, [data, uri]), - returnValue: _i9.Future>.value( + returnValue: _i5.Future>.value( {}, ), ) - as _i9.Future>); + as _i5.Future>); @override - _i9.Future> patch( + _i5.Future> patch( Map? data, Uri? uri, ) => (super.noSuchMethod( Invocation.method(#patch, [data, uri]), - returnValue: _i9.Future>.value( + returnValue: _i5.Future>.value( {}, ), ) - as _i9.Future>); + as _i5.Future>); @override - _i9.Future<_i5.Response> deleteRequest(String? url, int? id) => + _i5.Future<_i3.Response> deleteRequest(String? url, int? id) => (super.noSuchMethod( Invocation.method(#deleteRequest, [url, id]), - returnValue: _i9.Future<_i5.Response>.value( - _FakeResponse_5( + returnValue: _i5.Future<_i3.Response>.value( + _FakeResponse_3( this, Invocation.method(#deleteRequest, [url, id]), ), ), ) - as _i9.Future<_i5.Response>); + as _i5.Future<_i3.Response>); } diff --git a/test/core/settings_test.mocks.dart b/test/core/settings_test.mocks.dart index 6df8a7d9..0c0b6b32 100644 --- a/test/core/settings_test.mocks.dart +++ b/test/core/settings_test.mocks.dart @@ -1103,10 +1103,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -1141,21 +1145,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i18.Future.value(), ) as _i18.Future); @override - _i18.Future> fetchPaginated(Uri? uri) => + _i18.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i18.Future>.value([]), ) as _i18.Future>); diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index dad59660..b89be452 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -778,6 +778,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get trophies => + (super.noSuchMethod( + Invocation.getter(#trophies), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#trophies), + ), + ) + as String); + @override String get routines => (super.noSuchMethod( @@ -1107,6 +1118,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get personalRecords => + (super.noSuchMethod( + Invocation.getter(#personalRecords), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#personalRecords), + ), + ) + as String); + @override String get gymMode => (super.noSuchMethod( @@ -2185,6 +2207,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get noTrophies => + (super.noSuchMethod( + Invocation.getter(#noTrophies), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#noTrophies), + ), + ) + as String); + @override String get noWeightEntries => (super.noSuchMethod( @@ -3821,6 +3854,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String topSet(String? value) => + (super.noSuchMethod( + Invocation.method(#topSet, [value]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#topSet, [value]), + ), + ) + as String); + @override String durationHoursMinutes(int? hours, int? minutes) => (super.noSuchMethod( diff --git a/test/exercises/contribute_exercise_image_test.mocks.dart b/test/exercises/contribute_exercise_image_test.mocks.dart index c20d1d75..58b8b260 100644 --- a/test/exercises/contribute_exercise_image_test.mocks.dart +++ b/test/exercises/contribute_exercise_image_test.mocks.dart @@ -368,10 +368,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -406,21 +410,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i2.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i14.Future.value(), ) as _i14.Future); @override - _i14.Future> fetchPaginated(Uri? uri) => + _i14.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i14.Future>.value([]), ) as _i14.Future>); diff --git a/test/gallery/gallery_form_test.mocks.dart b/test/gallery/gallery_form_test.mocks.dart index ad71121f..5e048896 100644 --- a/test/gallery/gallery_form_test.mocks.dart +++ b/test/gallery/gallery_form_test.mocks.dart @@ -141,10 +141,14 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider { as _i6.Future); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -179,21 +183,26 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i6.Future.value(), ) as _i6.Future); @override - _i6.Future> fetchPaginated(Uri? uri) => + _i6.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i6.Future>.value([]), ) as _i6.Future>); diff --git a/test/gallery/gallery_screen_test.mocks.dart b/test/gallery/gallery_screen_test.mocks.dart index 7ea2e32f..9b6edcbd 100644 --- a/test/gallery/gallery_screen_test.mocks.dart +++ b/test/gallery/gallery_screen_test.mocks.dart @@ -141,10 +141,14 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider { as _i6.Future); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -179,21 +183,26 @@ class MockGalleryProvider extends _i1.Mock implements _i4.GalleryProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i6.Future.value(), ) as _i6.Future); @override - _i6.Future> fetchPaginated(Uri? uri) => + _i6.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i6.Future>.value([]), ) as _i6.Future>); diff --git a/test/measurements/measurement_provider_test.mocks.dart b/test/measurements/measurement_provider_test.mocks.dart index 7c766730..b5127325 100644 --- a/test/measurements/measurement_provider_test.mocks.dart +++ b/test/measurements/measurement_provider_test.mocks.dart @@ -78,10 +78,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -116,21 +120,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/nutrition/nutritional_plan_screen_test.mocks.dart b/test/nutrition/nutritional_plan_screen_test.mocks.dart index 9b7d92ee..0ed491a6 100644 --- a/test/nutrition/nutritional_plan_screen_test.mocks.dart +++ b/test/nutrition/nutritional_plan_screen_test.mocks.dart @@ -88,10 +88,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -126,21 +130,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/nutrition/nutritional_plans_screen_test.mocks.dart b/test/nutrition/nutritional_plans_screen_test.mocks.dart index 1d9f0fb0..7fa3dec2 100644 --- a/test/nutrition/nutritional_plans_screen_test.mocks.dart +++ b/test/nutrition/nutritional_plans_screen_test.mocks.dart @@ -323,10 +323,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i8.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -361,21 +365,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i8.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/routine/gym_mode/gym_mode_test.mocks.dart b/test/routine/gym_mode/gym_mode_test.mocks.dart index 9541140d..8d17cc4f 100644 --- a/test/routine/gym_mode/gym_mode_test.mocks.dart +++ b/test/routine/gym_mode/gym_mode_test.mocks.dart @@ -167,10 +167,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -205,21 +209,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i20.Future.value(), ) as _i20.Future); @override - _i20.Future> fetchPaginated(Uri? uri) => + _i20.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i20.Future>.value([]), ) as _i20.Future>); diff --git a/test/routine/routine_logs_screen_test.dart b/test/routine/routine_logs_screen_test.dart index c61bdf3a..1081171c 100644 --- a/test/routine/routine_logs_screen_test.dart +++ b/test/routine/routine_logs_screen_test.dart @@ -1,13 +1,13 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2026 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * - * wger Workout Manager is distributed in the hope that it will be useful, + * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. @@ -20,6 +20,7 @@ import 'dart:io'; import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -27,14 +28,16 @@ import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/workouts/routine.dart'; import 'package:wger/providers/routines.dart'; +import 'package:wger/providers/trophies.dart'; import 'package:wger/screens/routine_logs_screen.dart'; import 'package:wger/screens/routine_screen.dart'; import 'package:wger/widgets/routines/logs/log_overview_routine.dart'; import '../../test_data/routines.dart'; +import '../test_data/trophies.dart'; import 'routine_logs_screen_test.mocks.dart'; -@GenerateMocks([RoutinesProvider]) +@GenerateMocks([RoutinesProvider, TrophyRepository]) void main() { late Routine routine; final mockRoutinesProvider = MockRoutinesProvider(); @@ -49,25 +52,39 @@ void main() { Widget renderWidget({locale = 'en'}) { final key = GlobalKey(); - return ChangeNotifierProvider( - create: (context) => mockRoutinesProvider, - child: MaterialApp( - locale: Locale(locale), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - navigatorKey: key, - home: TextButton( - onPressed: () => key.currentState!.push( - MaterialPageRoute( - settings: RouteSettings(arguments: routine.id), - builder: (_) => const WorkoutLogsScreen(), + // Arrange + final mockRepository = MockTrophyRepository(); + when( + mockRepository.fetchUserTrophies( + filterQuery: anyNamed('filterQuery'), + language: anyNamed('language'), + ), + ).thenAnswer((_) async => getUserTrophies()); + + return ProviderScope( + overrides: [ + trophyRepositoryProvider.overrideWithValue(mockRepository), + ], + child: ChangeNotifierProvider( + create: (context) => mockRoutinesProvider, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + navigatorKey: key, + home: TextButton( + onPressed: () => key.currentState!.push( + MaterialPageRoute( + settings: RouteSettings(arguments: routine.id), + builder: (_) => const WorkoutLogsScreen(), + ), ), + child: const SizedBox(), ), - child: const SizedBox(), + routes: { + RoutineScreen.routeName: (ctx) => const WorkoutLogsScreen(), + }, ), - routes: { - RoutineScreen.routeName: (ctx) => const WorkoutLogsScreen(), - }, ), ); } diff --git a/test/routine/routine_logs_screen_test.mocks.dart b/test/routine/routine_logs_screen_test.mocks.dart index dca48e93..015095bb 100644 --- a/test/routine/routine_logs_screen_test.mocks.dart +++ b/test/routine/routine_logs_screen_test.mocks.dart @@ -9,6 +9,9 @@ import 'dart:ui' as _i17; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i16; import 'package:wger/models/exercises/exercise.dart' as _i15; +import 'package:wger/models/trophies/trophy.dart' as _i19; +import 'package:wger/models/trophies/user_trophy.dart' as _i20; +import 'package:wger/models/trophies/user_trophy_progression.dart' as _i21; import 'package:wger/models/workouts/base_config.dart' as _i9; import 'package:wger/models/workouts/day.dart' as _i6; import 'package:wger/models/workouts/day_data.dart' as _i14; @@ -21,6 +24,7 @@ import 'package:wger/models/workouts/slot_entry.dart' as _i8; import 'package:wger/models/workouts/weight_unit.dart' as _i3; import 'package:wger/providers/base_provider.dart' as _i2; import 'package:wger/providers/routines.dart' as _i12; +import 'package:wger/providers/trophies.dart' as _i18; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -592,3 +596,107 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider { returnValueForMissingStub: null, ); } + +/// A class which mocks [TrophyRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTrophyRepository extends _i1.Mock implements _i18.TrophyRepository { + MockTrophyRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.WgerBaseProvider get base => + (super.noSuchMethod( + Invocation.getter(#base), + returnValue: _FakeWgerBaseProvider_0( + this, + Invocation.getter(#base), + ), + ) + as _i2.WgerBaseProvider); + + @override + String get trophiesPath => + (super.noSuchMethod( + Invocation.getter(#trophiesPath), + returnValue: _i16.dummyValue( + this, + Invocation.getter(#trophiesPath), + ), + ) + as String); + + @override + String get userTrophiesPath => + (super.noSuchMethod( + Invocation.getter(#userTrophiesPath), + returnValue: _i16.dummyValue( + this, + Invocation.getter(#userTrophiesPath), + ), + ) + as String); + + @override + String get userTrophyProgressionPath => + (super.noSuchMethod( + Invocation.getter(#userTrophyProgressionPath), + returnValue: _i16.dummyValue( + this, + Invocation.getter(#userTrophyProgressionPath), + ), + ) + as String); + + @override + _i13.Future> fetchTrophies({String? language}) => + (super.noSuchMethod( + Invocation.method(#fetchTrophies, [], {#language: language}), + returnValue: _i13.Future>.value(<_i19.Trophy>[]), + ) + as _i13.Future>); + + @override + _i13.Future> fetchUserTrophies({ + Map? filterQuery, + String? language, + }) => + (super.noSuchMethod( + Invocation.method(#fetchUserTrophies, [], { + #filterQuery: filterQuery, + #language: language, + }), + returnValue: _i13.Future>.value( + <_i20.UserTrophy>[], + ), + ) + as _i13.Future>); + + @override + _i13.Future> fetchProgression({ + Map? filterQuery, + String? language, + }) => + (super.noSuchMethod( + Invocation.method(#fetchProgression, [], { + #filterQuery: filterQuery, + #language: language, + }), + returnValue: _i13.Future>.value( + <_i21.UserTrophyProgression>[], + ), + ) + as _i13.Future>); + + @override + List<_i19.Trophy> filterByType( + List<_i19.Trophy>? list, + _i19.TrophyType? type, + ) => + (super.noSuchMethod( + Invocation.method(#filterByType, [list, type]), + returnValue: <_i19.Trophy>[], + ) + as List<_i19.Trophy>); +} diff --git a/test/routine/routine_screen_test.mocks.dart b/test/routine/routine_screen_test.mocks.dart index 7c623651..8a11f7d1 100644 --- a/test/routine/routine_screen_test.mocks.dart +++ b/test/routine/routine_screen_test.mocks.dart @@ -78,10 +78,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -116,21 +120,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/routine/routines_provider_test.mocks.dart b/test/routine/routines_provider_test.mocks.dart index 3fc8c4ee..e70a0c48 100644 --- a/test/routine/routines_provider_test.mocks.dart +++ b/test/routine/routines_provider_test.mocks.dart @@ -117,10 +117,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -155,21 +159,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i11.Future.value(), ) as _i11.Future); @override - _i11.Future> fetchPaginated(Uri? uri) => + _i11.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i11.Future>.value([]), ) as _i11.Future>); diff --git a/test/routine/routines_screen_test.mocks.dart b/test/routine/routines_screen_test.mocks.dart index 95d3ca97..5502f3a0 100644 --- a/test/routine/routines_screen_test.mocks.dart +++ b/test/routine/routines_screen_test.mocks.dart @@ -78,10 +78,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -116,21 +120,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/test_data/trophies.dart b/test/test_data/trophies.dart new file mode 100644 index 00000000..8fc13d28 --- /dev/null +++ b/test/test_data/trophies.dart @@ -0,0 +1,85 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:wger/models/trophies/trophy.dart'; +import 'package:wger/models/trophies/user_trophy.dart'; +import 'package:wger/models/trophies/user_trophy_progression.dart'; + +List getTestTrophies() { + return [ + Trophy( + id: 1, + uuid: '31a71d9a-bf26-4f18-b82f-afefe6f50df2', + name: 'New Year, New Me', + description: 'Work out on January 1st', + image: 'https://example.com/5362e55b-eaf1-4e34-9ef8-661538a3bdd9.png', + type: TrophyType.date, + isHidden: false, + isProgressive: false, + ), + Trophy( + id: 2, + uuid: 'b605b6a1-953d-41fb-87c9-a2f88b5f5907', + name: 'Unstoppable', + description: 'Maintain a 30-day workout streak', + image: 'https://example.com/b605b6a1-953d-41fb-87c9-a2f88b5f5907.png', + type: TrophyType.sequence, + isHidden: false, + isProgressive: true, + ), + ]; +} + +List getUserTrophyProgression() { + final trophies = getTestTrophies(); + + return [ + UserTrophyProgression( + trophy: trophies[0], + progress: 100, + isEarned: true, + earnedAt: DateTime(2025, 12, 20), + currentValue: null, + targetValue: null, + progressDisplay: null, + ), + UserTrophyProgression( + trophy: trophies[1], + progress: 40, + isEarned: false, + earnedAt: null, + currentValue: 12, + targetValue: 30, + progressDisplay: '12 / 30', + ), + ]; +} + +List getUserTrophies() { + final trophies = getTestTrophies(); + + return [ + UserTrophy( + id: 4, + earnedAt: DateTime(2025, 12, 20), + isNotified: true, + progress: 100, + trophy: trophies[0], + ), + ]; +} diff --git a/test/trophies/models/trophy_test.dart b/test/trophies/models/trophy_test.dart new file mode 100644 index 00000000..3285b589 --- /dev/null +++ b/test/trophies/models/trophy_test.dart @@ -0,0 +1,72 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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/models/trophies/trophy.dart'; + +void main() { + group('Trophy model', () { + final sampleJson = { + 'id': 1, + 'uuid': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'First Steps', + 'description': 'Awarded for the first workout', + 'image': 'https://example.org/trophy.png', + 'trophy_type': 'count', + 'is_hidden': false, + 'is_progressive': true, + }; + + test('fromJson creates valid Trophy instance', () { + final trophy = Trophy.fromJson(sampleJson); + + expect(trophy.id, 1); + expect(trophy.uuid, '550e8400-e29b-41d4-a716-446655440000'); + expect(trophy.name, 'First Steps'); + expect(trophy.description, 'Awarded for the first workout'); + expect(trophy.image, 'https://example.org/trophy.png'); + expect(trophy.type, TrophyType.count); + expect(trophy.isHidden, isFalse); + expect(trophy.isProgressive, isTrue); + }); + + test('toJson returns expected map', () { + final trophy = Trophy( + id: 2, + uuid: '00000000-0000-0000-0000-000000000000', + name: 'Progressor', + description: 'Progressive trophy', + image: 'https://example.org/prog.png', + type: TrophyType.time, + isHidden: true, + isProgressive: false, + ); + + final json = trophy.toJson(); + + expect(json['id'], 2); + expect(json['uuid'], '00000000-0000-0000-0000-000000000000'); + expect(json['name'], 'Progressor'); + expect(json['description'], 'Progressive trophy'); + expect(json['image'], 'https://example.org/prog.png'); + expect(json['trophy_type'], 'time'); + expect(json['is_hidden'], true); + expect(json['is_progressive'], false); + }); + }); +} diff --git a/test/trophies/models/user_trophy_progression_test.dart b/test/trophies/models/user_trophy_progression_test.dart new file mode 100644 index 00000000..ccbb1d06 --- /dev/null +++ b/test/trophies/models/user_trophy_progression_test.dart @@ -0,0 +1,97 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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/models/trophies/trophy.dart'; +import 'package:wger/models/trophies/user_trophy_progression.dart'; + +void main() { + group('UserTrophyProgression model', () { + final trophyJson = { + 'id': 1, + 'uuid': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'First Steps', + 'description': 'Awarded for the first workout', + 'image': 'https://example.org/trophy.png', + 'trophy_type': 'count', + 'is_hidden': false, + 'is_progressive': true, + }; + + final trophyProgressionJson = { + 'trophy': trophyJson, + 'is_earned': false, + 'earned_at': '2020-01-02T15:04:05Z', + 'progress': 42.5, + 'current_value': '12.5', + 'target_value': '100', + 'progress_display': '12.5/100', + }; + + test('fromJson creates valid UserTrophyProgression instance', () { + final utp = UserTrophyProgression.fromJson(trophyProgressionJson); + + expect(utp.trophy.id, 1); + expect(utp.trophy.uuid, '550e8400-e29b-41d4-a716-446655440000'); + expect(utp.isEarned, isFalse); + + final expectedEarnedAt = DateTime.parse('2020-01-02T15:04:05Z').toLocal(); + expect(utp.earnedAt, expectedEarnedAt); + + expect(utp.progress, 42.5); + expect(utp.currentValue, 12.5); + expect(utp.targetValue, 100); + expect(utp.progressDisplay, '12.5/100'); + }); + + test('toJson returns expected map', () { + final trophy = Trophy( + id: 2, + uuid: '00000000-0000-0000-0000-000000000000', + name: 'Progressor', + description: 'Progressive trophy', + image: 'https://example.org/prog.png', + type: TrophyType.time, + isHidden: true, + isProgressive: false, + ); + + final earnedAt = DateTime.parse('2020-01-02T15:04:05Z').toLocal(); + + final utp = UserTrophyProgression( + trophy: trophy, + isEarned: true, + earnedAt: earnedAt, + progress: 75, + currentValue: 75, + targetValue: 100, + progressDisplay: '75/100', + ); + + final json = utp.toJson(); + + expect(json['trophy'], same(trophy)); + expect(json['is_earned'], true); + expect(json['earned_at'], earnedAt.toIso8601String()); + expect(json['progress'], 75); + expect(json['current_value'], 75); + expect(json['target_value'], 100); + expect(json['progress_display'], '75/100'); + }); + }); +} diff --git a/test/trophies/provider/trophies_provider_test.dart b/test/trophies/provider/trophies_provider_test.dart new file mode 100644 index 00000000..456a806a --- /dev/null +++ b/test/trophies/provider/trophies_provider_test.dart @@ -0,0 +1,99 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:wger/providers/base_provider.dart'; +import 'package:wger/providers/trophies.dart'; + +import 'trophies_provider_test.mocks.dart'; + +const trophyJson = { + 'id': 1, + 'uuid': '550e8400-e29b-41d4-a716-446655440000', + 'name': 'First Steps', + 'description': 'Awarded for the first workout', + 'image': 'https://example.org/trophy.png', + 'trophy_type': 'count', + 'is_hidden': false, + 'is_progressive': true, +}; + +@GenerateMocks([WgerBaseProvider]) +void main() { + group('Trophy repository', () { + test('fetches list of trophies', () async { + // Arrange + final mockBase = MockWgerBaseProvider(); + when(mockBase.fetchPaginated(any)).thenAnswer((_) async => [trophyJson]); + when( + mockBase.makeUrl( + any, + id: anyNamed('id'), + objectMethod: anyNamed('objectMethod'), + query: anyNamed('query'), + ), + ).thenReturn(Uri.parse('https://example.org/trophies')); + final repository = TrophyRepository(mockBase); + + // Act + final result = await repository.fetchTrophies(); + + // Assert + expect(result, isA()); + expect(result, hasLength(1)); + final trophy = result.first; + expect(trophy.id, 1); + expect(trophy.name, 'First Steps'); + expect(trophy.type.toString(), contains('count')); + }); + + test('fetches list of user trophy progression', () async { + // Arrange + final progressionJson = { + 'trophy': trophyJson, + 'is_earned': true, + 'earned_at': '2020-01-02T15:04:05Z', + 'progress': 42.5, + 'current_value': '12.5', + 'target_value': '100', + 'progress_display': '12.5/100', + }; + + final mockBase = MockWgerBaseProvider(); + when(mockBase.fetch(any)).thenAnswer((_) async => [progressionJson]); + when(mockBase.makeUrl(any)).thenReturn(Uri.parse('https://example.org/user_progressions')); + final repository = TrophyRepository(mockBase); + + // Act + final result = await repository.fetchProgression(); + + // Assert + expect(result, isA()); + expect(result, hasLength(1)); + final p = result.first; + expect(p.isEarned, isTrue); + expect(p.progress, 42.5); + expect(p.currentValue, 12.5); + expect(p.progressDisplay, '12.5/100'); + + verify(mockBase.fetch(any)).called(1); + }); + }); +} diff --git a/test/trophies/provider/trophies_provider_test.mocks.dart b/test/trophies/provider/trophies_provider_test.mocks.dart new file mode 100644 index 00000000..15b59d6e --- /dev/null +++ b/test/trophies/provider/trophies_provider_test.mocks.dart @@ -0,0 +1,182 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in wger/test/trophies/provider/trophies_provider_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:http/http.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:wger/providers/auth.dart' as _i2; +import 'package:wger/providers/base_provider.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeAuthProvider_0 extends _i1.SmartFake implements _i2.AuthProvider { + _FakeAuthProvider_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeClient_1 extends _i1.SmartFake implements _i3.Client { + _FakeClient_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeUri_2 extends _i1.SmartFake implements Uri { + _FakeUri_2(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +class _FakeResponse_3 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); +} + +/// A class which mocks [WgerBaseProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { + MockWgerBaseProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.AuthProvider get auth => + (super.noSuchMethod( + Invocation.getter(#auth), + returnValue: _FakeAuthProvider_0(this, Invocation.getter(#auth)), + ) + as _i2.AuthProvider); + + @override + _i3.Client get client => + (super.noSuchMethod( + Invocation.getter(#client), + returnValue: _FakeClient_1(this, Invocation.getter(#client)), + ) + as _i3.Client); + + @override + set auth(_i2.AuthProvider? value) => super.noSuchMethod( + Invocation.setter(#auth, value), + returnValueForMissingStub: null, + ); + + @override + set client(_i3.Client? value) => super.noSuchMethod( + Invocation.setter(#client, value), + returnValueForMissingStub: null, + ); + + @override + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => + (super.noSuchMethod( + Invocation.method(#getDefaultHeaders, [], { + #includeAuth: includeAuth, + #language: language, + }), + returnValue: {}, + ) + as Map); + + @override + Uri makeUrl( + String? path, { + int? id, + String? objectMethod, + Map? query, + }) => + (super.noSuchMethod( + Invocation.method( + #makeUrl, + [path], + {#id: id, #objectMethod: objectMethod, #query: query}, + ), + returnValue: _FakeUri_2( + this, + Invocation.method( + #makeUrl, + [path], + {#id: id, #objectMethod: objectMethod, #query: query}, + ), + ), + ) + as Uri); + + @override + _i5.Future fetch( + Uri? uri, { + int? maxRetries = 3, + Duration? initialDelay = const Duration(milliseconds: 250), + String? language, + }) => + (super.noSuchMethod( + Invocation.method( + #fetch, + [uri], + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, + ), + returnValue: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => + (super.noSuchMethod( + Invocation.method(#fetchPaginated, [uri], {#language: language}), + returnValue: _i5.Future>.value([]), + ) + as _i5.Future>); + + @override + _i5.Future> post(Map? data, Uri? uri) => + (super.noSuchMethod( + Invocation.method(#post, [data, uri]), + returnValue: _i5.Future>.value( + {}, + ), + ) + as _i5.Future>); + + @override + _i5.Future> patch( + Map? data, + Uri? uri, + ) => + (super.noSuchMethod( + Invocation.method(#patch, [data, uri]), + returnValue: _i5.Future>.value( + {}, + ), + ) + as _i5.Future>); + + @override + _i5.Future<_i3.Response> deleteRequest(String? url, int? id) => + (super.noSuchMethod( + Invocation.method(#deleteRequest, [url, id]), + returnValue: _i5.Future<_i3.Response>.value( + _FakeResponse_3( + this, + Invocation.method(#deleteRequest, [url, id]), + ), + ), + ) + as _i5.Future<_i3.Response>); +} diff --git a/test/trophies/widgets/dashboard_trophies_widget_test.dart b/test/trophies/widgets/dashboard_trophies_widget_test.dart new file mode 100644 index 00000000..d8549d92 --- /dev/null +++ b/test/trophies/widgets/dashboard_trophies_widget_test.dart @@ -0,0 +1,79 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/trophies.dart'; +import 'package:wger/widgets/dashboard/widgets/trophies.dart'; + +import '../../test_data/trophies.dart'; + +void main() { + Future pumpOverview(WidgetTester tester, [List overrides = const []]) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: DashboardTrophiesWidget(), + ), + ), + ), + ); + } + + group('DashboardTrophiesWidget tests', () { + testWidgets('shows trophies', (WidgetTester tester) async { + // Act + await mockNetworkImagesFor(() async { + await pumpOverview( + tester, + [ + trophyStateProvider.overrideWithValue( + TrophyState( + userTrophies: getUserTrophies(), + trophies: getTestTrophies(), + ), + ), + ], + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('New Year, New Me'), findsOneWidget); + }); + }); + + testWidgets('handles empty results', (WidgetTester tester) async { + // Act + await pumpOverview(tester); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('Trophies'), findsOneWidget); + expect(find.text('You have no trophies yet'), findsOneWidget); + }); + }); +} diff --git a/test/trophies/widgets/trophies_overview_test.dart b/test/trophies/widgets/trophies_overview_test.dart new file mode 100644 index 00000000..1a843a21 --- /dev/null +++ b/test/trophies/widgets/trophies_overview_test.dart @@ -0,0 +1,62 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2026 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/trophies.dart'; +import 'package:wger/widgets/trophies/trophies_overview.dart'; + +import '../../test_data/trophies.dart'; + +void main() { + testWidgets('TrophiesOverview shows trophies', (WidgetTester tester) async { + // Act + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + trophyStateProvider.overrideWithValue( + TrophyState( + trophyProgression: getUserTrophyProgression(), + userTrophies: getUserTrophies(), + trophies: getTestTrophies(), + ), + ), + ], + child: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: TrophiesOverview()), + ), + ), + ); + await tester.pumpAndSettle(); + + // Assert + expect(find.text('New Year, New Me'), findsOneWidget); + expect(find.text('Work out on January 1st'), findsOneWidget); + + expect(find.text('Unstoppable'), findsOneWidget); + expect(find.text('Maintain a 30-day workout streak'), findsOneWidget); + }); + }); +} diff --git a/test/user/provider_test.dart b/test/user/provider_test.dart index e12951f8..c5a9206e 100644 --- a/test/user/provider_test.dart +++ b/test/user/provider_test.dart @@ -106,11 +106,12 @@ void main() { group('dashboard config', () { test('initial config should be default (all visible, default order)', () { - expect(userProvider.dashboardOrder.length, 5); + expect(userProvider.dashboardOrder.length, 6); expect( userProvider.dashboardOrder, orderedEquals([ + DashboardWidget.trophies, DashboardWidget.routines, DashboardWidget.nutrition, DashboardWidget.weight, @@ -161,22 +162,28 @@ void main() { // act final newProvider = UserProvider(mockWgerBaseProvider, prefs: prefs); - await Future.delayed(const Duration(milliseconds: 50)); // wait for async prefs load + await Future.delayed(const Duration(milliseconds: 100)); // wait for async prefs load // assert - // The loaded ones come first - expect(newProvider.dashboardOrder[0], DashboardWidget.nutrition); - expect(newProvider.dashboardOrder[1], DashboardWidget.routines); + // Loaded: [nutrition, routines] + // Missing: trophies (0), weight (3), measurements (4), calendar (5) + // 1. trophies (index 0) inserted at 0 -> [trophies, nutrition, routines] + // 2. weight (index 3) inserted at 3 -> [trophies, nutrition, routines, weight] + + expect(newProvider.dashboardOrder[0], DashboardWidget.trophies); + expect(newProvider.dashboardOrder[1], DashboardWidget.nutrition); + expect(newProvider.dashboardOrder[2], DashboardWidget.routines); + expect(newProvider.dashboardOrder[3], DashboardWidget.weight); // Check visibility expect(newProvider.isDashboardWidgetVisible(DashboardWidget.nutrition), true); expect(newProvider.isDashboardWidgetVisible(DashboardWidget.routines), false); - // Remaining items are added after - expect(newProvider.dashboardOrder.length, 5); - - // Items not in the prefs are visible by default - expect(newProvider.isDashboardWidgetVisible(DashboardWidget.weight), true); + // Missing items should be visible by default + expect( + newProvider.isDashboardWidgetVisible(DashboardWidget.weight), + true, + ); }); }); } diff --git a/test/user/provider_test.mocks.dart b/test/user/provider_test.mocks.dart index 4e7436d0..d16a2200 100644 --- a/test/user/provider_test.mocks.dart +++ b/test/user/provider_test.mocks.dart @@ -78,10 +78,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -116,21 +120,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>); diff --git a/test/weight/weight_provider_test.mocks.dart b/test/weight/weight_provider_test.mocks.dart index be3d3857..e1ff97ab 100644 --- a/test/weight/weight_provider_test.mocks.dart +++ b/test/weight/weight_provider_test.mocks.dart @@ -78,10 +78,14 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { ); @override - Map getDefaultHeaders({bool? includeAuth = false}) => + Map getDefaultHeaders({ + bool? includeAuth = false, + String? language, + }) => (super.noSuchMethod( Invocation.method(#getDefaultHeaders, [], { #includeAuth: includeAuth, + #language: language, }), returnValue: {}, ) @@ -116,21 +120,26 @@ class MockWgerBaseProvider extends _i1.Mock implements _i4.WgerBaseProvider { Uri? uri, { int? maxRetries = 3, Duration? initialDelay = const Duration(milliseconds: 250), + String? language, }) => (super.noSuchMethod( Invocation.method( #fetch, [uri], - {#maxRetries: maxRetries, #initialDelay: initialDelay}, + { + #maxRetries: maxRetries, + #initialDelay: initialDelay, + #language: language, + }, ), returnValue: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future> fetchPaginated(Uri? uri) => + _i5.Future> fetchPaginated(Uri? uri, {String? language}) => (super.noSuchMethod( - Invocation.method(#fetchPaginated, [uri]), + Invocation.method(#fetchPaginated, [uri], {#language: language}), returnValue: _i5.Future>.value([]), ) as _i5.Future>);