diff --git a/lib/models/trophies/user_trophy.dart b/lib/models/trophies/user_trophy.dart new file mode 100644 index 00000000..81cb51e3 --- /dev/null +++ b/lib/models/trophies/user_trophy.dart @@ -0,0 +1,57 @@ +/* + * 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'; + +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; + + UserTrophy({ + required this.id, + required this.trophy, + required this.earnedAt, + required this.progress, + required this.isNotified, + }); + + // 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..62d85ac3 --- /dev/null +++ b/lib/models/trophies/user_trophy.g.dart @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +// 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', + ], + ); + 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, + ); +} + +Map _$UserTrophyToJson(UserTrophy instance) => { + 'id': instance.id, + 'trophy': instance.trophy, + 'earned_at': instance.earnedAt.toIso8601String(), + 'progress': instance.progress, + 'is_notified': instance.isNotified, +}; diff --git a/lib/providers/trophies.dart b/lib/providers/trophies.dart index 77a3a1bc..13ee1df7 100644 --- a/lib/providers/trophies.dart +++ b/lib/providers/trophies.dart @@ -19,33 +19,70 @@ 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'; -@riverpod -Future> trophies(Ref ref) async { - const trophiesPath = 'trophy'; +class TrophyRepository { + final WgerBaseProvider base; + final trophiesPath = 'trophy'; + final userTrophiesPath = 'user-trophy'; + final userTrophyProgressionPath = 'trophy/progress'; - final baseProvider = ref.read(wgerBaseProvider); + TrophyRepository(this.base); - final trophyData = await baseProvider.fetchPaginated( - baseProvider.makeUrl(trophiesPath, query: {'limit': API_MAX_PAGE_SIZE}), - ); + Future> fetchTrophies() async { + final url = base.makeUrl(trophiesPath, query: {'limit': API_MAX_PAGE_SIZE}); + final trophyData = await base.fetchPaginated(url); + return trophyData.map((e) => Trophy.fromJson(e)).toList(); + } - return trophyData.map((e) => Trophy.fromJson(e)).toList(); + Future> fetchUserTrophies() async { + final url = base.makeUrl(userTrophiesPath, query: {'limit': API_MAX_PAGE_SIZE}); + final trophyData = await base.fetchPaginated(url); + return trophyData.map((e) => UserTrophy.fromJson(e)).toList(); + } + + Future> fetchProgression() async { + final url = base.makeUrl(userTrophyProgressionPath, query: {'limit': API_MAX_PAGE_SIZE}); + final data = await base.fetchPaginated(url); + return data.map((e) => UserTrophyProgression.fromJson(e)).toList(); + } + + List filterByType(List list, TrophyType type) => + list.where((t) => t.type == type).toList(); } @riverpod -Future> trophyProgression(Ref ref) async { - const userTrophyProgressionPath = 'trophy/progress'; - - final baseProvider = ref.read(wgerBaseProvider); - - final trophyData = await baseProvider.fetchPaginated( - baseProvider.makeUrl(userTrophyProgressionPath, query: {'limit': API_MAX_PAGE_SIZE}), - ); - - return trophyData.map((e) => UserTrophyProgression.fromJson(e)).toList(); +TrophyRepository trophyRepository(Ref ref) { + final base = ref.read(wgerBaseProvider); + return TrophyRepository(base); +} + +@Riverpod(keepAlive: true) +final class TrophyStateNotifier extends _$TrophyStateNotifier { + @override + void build() {} + + /// Fetch all available trophies + Future> fetchTrophies() async { + final repo = ref.read(trophyRepositoryProvider); + return repo.fetchTrophies(); + } + + /// Fetch trophies awarded to the user + Future> fetchUserTrophies() async { + final repo = ref.read(trophyRepositoryProvider); + return repo.fetchUserTrophies(); + } + + /// Fetch trophy progression for the user + Future> fetchTrophyProgression() async { + final repo = ref.read(trophyRepositoryProvider); + return repo.fetchProgression(); + } } diff --git a/lib/providers/trophies.g.dart b/lib/providers/trophies.g.dart index b9b825d5..04df81d6 100644 --- a/lib/providers/trophies.g.dart +++ b/lib/providers/trophies.g.dart @@ -27,77 +27,89 @@ part of 'trophies.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(trophies) -const trophiesProvider = TrophiesProvider._(); +@ProviderFor(trophyRepository) +const trophyRepositoryProvider = TrophyRepositoryProvider._(); -final class TrophiesProvider - extends $FunctionalProvider>, List, FutureOr>> - with $FutureModifier>, $FutureProvider> { - const TrophiesProvider._() +final class TrophyRepositoryProvider + extends $FunctionalProvider + with $Provider { + const TrophyRepositoryProvider._() : super( from: null, argument: null, retry: null, - name: r'trophiesProvider', + name: r'trophyRepositoryProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$trophiesHash(); + String debugGetCreateSourceHash() => _$trophyRepositoryHash(); @$internal @override - $FutureProviderElement> $createElement( - $ProviderPointer pointer, - ) => $FutureProviderElement(pointer); + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); @override - FutureOr> create(Ref ref) { - return trophies(ref); + TrophyRepository create(Ref ref) { + return trophyRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TrophyRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); } } -String _$trophiesHash() => r'44dd5e9a820f4e37599daac2a49a9358386758a8'; +String _$trophyRepositoryHash() => r'0699f0c0f7f324f3ba9b21420d9845a3e3096b61'; -@ProviderFor(trophyProgression) -const trophyProgressionProvider = TrophyProgressionProvider._(); +@ProviderFor(TrophyStateNotifier) +const trophyStateProvider = TrophyStateNotifierProvider._(); -final class TrophyProgressionProvider - extends - $FunctionalProvider< - AsyncValue>, - List, - FutureOr> - > - with - $FutureModifier>, - $FutureProvider> { - const TrophyProgressionProvider._() +final class TrophyStateNotifierProvider extends $NotifierProvider { + const TrophyStateNotifierProvider._() : super( from: null, argument: null, retry: null, - name: r'trophyProgressionProvider', - isAutoDispose: true, + name: r'trophyStateProvider', + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$trophyProgressionHash(); + String debugGetCreateSourceHash() => _$trophyStateNotifierHash(); @$internal @override - $FutureProviderElement> $createElement( - $ProviderPointer pointer, - ) => $FutureProviderElement(pointer); + TrophyStateNotifier create() => TrophyStateNotifier(); - @override - FutureOr> create(Ref ref) { - return trophyProgression(ref); + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); } } -String _$trophyProgressionHash() => r'444caf04f3d0a7845e840d452e4b4a822b59df9b'; +String _$trophyStateNotifierHash() => r'e5c8f2a9477b8f7e5efe4e9ba23765f951627a9f'; + +abstract class _$TrophyStateNotifier extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref; + final element = + ref.element as $ClassProviderElement, void, Object?, Object?>; + element.handleValue(ref, null); + } +} diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 3be5a4b4..0a714308 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -1,6 +1,6 @@ /* * This file is part of wger Workout Manager . - * Copyright (C) 2020, 2021 wger Team + * Copyright (c) 2020 - 2025 wger Team * * wger Workout Manager is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -26,6 +26,8 @@ import 'package:wger/widgets/dashboard/widgets/nutrition.dart'; import 'package:wger/widgets/dashboard/widgets/routines.dart'; import 'package:wger/widgets/dashboard/widgets/weight.dart'; +import '../widgets/dashboard/widgets/trophies.dart'; + class DashboardScreen extends StatelessWidget { const DashboardScreen({super.key}); @@ -48,6 +50,7 @@ class DashboardScreen extends StatelessWidget { } final items = [ + const DashboardTrophiesWidget(), const DashboardRoutineWidget(), const DashboardNutritionWidget(), const DashboardWeightWidget(), diff --git a/lib/widgets/dashboard/widgets/trophies.dart b/lib/widgets/dashboard/widgets/trophies.dart new file mode 100644 index 00000000..9d2087c7 --- /dev/null +++ b/lib/widgets/dashboard/widgets/trophies.dart @@ -0,0 +1,65 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2020 - 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/providers/trophies.dart'; +import 'package:wger/widgets/core/progress_indicator.dart'; + +class DashboardTrophiesWidget extends ConsumerWidget { + const DashboardTrophiesWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = ref.watch(trophyStateProvider.notifier); + + return FutureBuilder( + future: provider.fetchUserTrophies(), + builder: (context, asyncSnapshot) { + if (asyncSnapshot.connectionState != ConnectionState.done) { + return const Card( + child: BoxedProgressIndicator(), + ); + } + + return Card( + child: Column( + children: [ + ListTile( + title: Text( + 'Trophies', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + + ...(asyncSnapshot.data ?? []).map( + (userTrophy) => ListTile( + leading: CircleAvatar( + backgroundImage: NetworkImage(userTrophy.trophy.image), + ), + title: Text(userTrophy.trophy.name), + subtitle: Text(userTrophy.trophy.description, overflow: TextOverflow.ellipsis), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/test/trophies/trophies_provider_test.dart b/test/trophies/trophies_provider_test.dart index 279afcc2..5f159b94 100644 --- a/test/trophies/trophies_provider_test.dart +++ b/test/trophies/trophies_provider_test.dart @@ -16,13 +16,11 @@ * along with this program. If not, see . */ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:wger/providers/base_provider.dart'; import 'package:wger/providers/trophies.dart'; -import 'package:wger/providers/wger_base_riverpod.dart'; import 'trophies_provider_test.mocks.dart'; @@ -39,8 +37,8 @@ const trophyJson = { @GenerateMocks([WgerBaseProvider]) void main() { - group('Trophies providers', () { - test('trophies provider returns list of Trophy models', () async { + group('Trophy repository', () { + test('fetches list of trophies', () async { // Arrange final mockBase = MockWgerBaseProvider(); when(mockBase.fetchPaginated(any)).thenAnswer((_) async => [trophyJson]); @@ -52,15 +50,10 @@ void main() { query: anyNamed('query'), ), ).thenReturn(Uri.parse('https://example.org/trophies')); - - final container = ProviderContainer.test( - overrides: [ - wgerBaseProvider.overrideWithValue(mockBase), - ], - ); + final repository = TrophyRepository(mockBase); // Act - final result = await container.read(trophiesProvider.future); + final result = await repository.fetchTrophies(); // Assert expect(result, isA()); @@ -71,7 +64,7 @@ void main() { expect(trophy.type.toString(), contains('count')); }); - test('trophyProgression provider returns list of UserTrophyProgression models', () async { + test('fetches list of user trophy progression', () async { // Arrange final progressionJson = { 'trophy': trophyJson, @@ -93,14 +86,10 @@ void main() { query: anyNamed('query'), ), ).thenReturn(Uri.parse('https://example.org/user_progressions')); - final container = ProviderContainer.test( - overrides: [ - wgerBaseProvider.overrideWithValue(mockBase), - ], - ); + final repository = TrophyRepository(mockBase); // Act - final result = await container.read(trophyProgressionProvider.future); + final result = await repository.fetchProgression(); // Assert expect(result, isA());