Add dashboard widget and rework the provider

This commit is contained in:
Roland Geider
2025-12-20 02:06:08 +01:00
parent 5d39ae5088
commit b9fd061d33
7 changed files with 290 additions and 74 deletions

View File

@@ -0,0 +1,57 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
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<String, dynamic> json) => _$UserTrophyFromJson(json);
Map<String, dynamic> toJson() => _$UserTrophyToJson(this);
}

View File

@@ -0,0 +1,53 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* 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 <http://www.gnu.org/licenses/>.
*/
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_trophy.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UserTrophy _$UserTrophyFromJson(Map<String, dynamic> 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<String, dynamic>),
earnedAt: utcIso8601ToLocalDate(json['earned_at'] as String),
progress: json['progress'] as num,
isNotified: json['is_notified'] as bool,
);
}
Map<String, dynamic> _$UserTrophyToJson(UserTrophy instance) => <String, dynamic>{
'id': instance.id,
'trophy': instance.trophy,
'earned_at': instance.earnedAt.toIso8601String(),
'progress': instance.progress,
'is_notified': instance.isNotified,
};

View File

@@ -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<List<Trophy>> 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<List<Trophy>> 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<List<UserTrophy>> 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<List<UserTrophyProgression>> 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<Trophy> filterByType(List<Trophy> list, TrophyType type) =>
list.where((t) => t.type == type).toList();
}
@riverpod
Future<List<UserTrophyProgression>> 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<List<Trophy>> fetchTrophies() async {
final repo = ref.read(trophyRepositoryProvider);
return repo.fetchTrophies();
}
/// Fetch trophies awarded to the user
Future<List<UserTrophy>> fetchUserTrophies() async {
final repo = ref.read(trophyRepositoryProvider);
return repo.fetchUserTrophies();
}
/// Fetch trophy progression for the user
Future<List<UserTrophyProgression>> fetchTrophyProgression() async {
final repo = ref.read(trophyRepositoryProvider);
return repo.fetchProgression();
}
}

View File

@@ -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<AsyncValue<List<Trophy>>, List<Trophy>, FutureOr<List<Trophy>>>
with $FutureModifier<List<Trophy>>, $FutureProvider<List<Trophy>> {
const TrophiesProvider._()
final class TrophyRepositoryProvider
extends $FunctionalProvider<TrophyRepository, TrophyRepository, TrophyRepository>
with $Provider<TrophyRepository> {
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<List<Trophy>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
$ProviderElement<TrophyRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
FutureOr<List<Trophy>> 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<TrophyRepository>(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<UserTrophyProgression>>,
List<UserTrophyProgression>,
FutureOr<List<UserTrophyProgression>>
>
with
$FutureModifier<List<UserTrophyProgression>>,
$FutureProvider<List<UserTrophyProgression>> {
const TrophyProgressionProvider._()
final class TrophyStateNotifierProvider extends $NotifierProvider<TrophyStateNotifier, void> {
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<List<UserTrophyProgression>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
TrophyStateNotifier create() => TrophyStateNotifier();
@override
FutureOr<List<UserTrophyProgression>> create(Ref ref) {
return trophyProgression(ref);
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$trophyProgressionHash() => r'444caf04f3d0a7845e840d452e4b4a822b59df9b';
String _$trophyStateNotifierHash() => r'e5c8f2a9477b8f7e5efe4e9ba23765f951627a9f';
abstract class _$TrophyStateNotifier extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<void, void>;
final element =
ref.element as $ClassProviderElement<AnyNotifier<void, void>, void, Object?, Object?>;
element.handleValue(ref, null);
}
}

View File

@@ -1,6 +1,6 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
* Copyright (c) 2020 - 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -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(),

View File

@@ -0,0 +1,65 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (c) 2020 - 2025 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package: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),
),
),
],
),
);
},
);
}
}

View File

@@ -16,13 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<List>());
@@ -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<List>());