Skip to content

Commit 54e0634

Browse files
committed
Don't try to load user trophies in the gym mode when offline
1 parent 65dbf42 commit 54e0634

3 files changed

Lines changed: 332 additions & 135 deletions

File tree

lib/widgets/routines/gym_mode/summary.dart

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ import 'package:wger/l10n/generated/app_localizations.dart';
2626
import 'package:wger/models/trophies/user_trophy.dart';
2727
import 'package:wger/models/workouts/session.dart';
2828
import 'package:wger/providers/gym_state_notifier.dart';
29+
import 'package:wger/providers/network_provider.dart';
2930
import 'package:wger/providers/routines_notifier.dart';
3031
import 'package:wger/providers/trophy_notifier.dart';
32+
import 'package:wger/widgets/core/error.dart';
3133
import 'package:wger/widgets/core/progress_indicator.dart';
3234
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
3335

@@ -52,10 +54,17 @@ class _WorkoutSummaryState extends ConsumerState<WorkoutSummary> {
5254
void didChangeDependencies() {
5355
super.didChangeDependencies();
5456
if (!_didInit) {
55-
final languageCode = Localizations.localeOf(context).languageCode;
56-
_trophyFuture = ref
57-
.read(trophyStateProvider.notifier)
58-
.fetchUserTrophies(language: languageCode);
57+
// Trophies are REST-only and only enrich the summary (PR count + markers).
58+
// Fetch them when the server is reachable; offline we skip the doomed
59+
// request so the local session stats render right away.
60+
if (ref.read(networkStatusProvider)) {
61+
final languageCode = Localizations.localeOf(context).languageCode;
62+
_trophyFuture = ref
63+
.read(trophyStateProvider.notifier)
64+
.fetchUserTrophies(language: languageCode);
65+
} else {
66+
_trophyFuture = Future<void>.value();
67+
}
5968
_didInit = true;
6069
}
6170
}
@@ -79,9 +88,13 @@ class _WorkoutSummaryState extends ConsumerState<WorkoutSummary> {
7988
future: _trophyFuture,
8089
builder: (context, snapshot) {
8190
if (snapshot.hasError) {
82-
widget._logger.warning(snapshot.error);
83-
widget._logger.warning(snapshot.stackTrace);
84-
return Center(child: Text('Error: ${snapshot.error}'));
91+
// An error reaching here is a genuine, unexpected exception worth surfacing
92+
widget._logger.warning(
93+
'Could not fetch user trophies',
94+
snapshot.error,
95+
snapshot.stackTrace,
96+
);
97+
return StreamErrorIndicator(snapshot.error!, stacktrace: snapshot.stackTrace);
8598
}
8699
if (snapshot.connectionState == ConnectionState.waiting || routine == null) {
87100
return const BoxedProgressIndicator();

test/routine/gym_mode/gym_mode_test.dart

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import 'package:clock/clock.dart';
2020
import 'package:flutter/material.dart';
2121
import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod;
22+
import 'package:flutter_riverpod/misc.dart' as riverpod;
2223
import 'package:flutter_test/flutter_test.dart';
2324
import 'package:mockito/annotations.dart';
2425
import 'package:mockito/mockito.dart';
@@ -35,6 +36,7 @@ import 'package:wger/providers/gym_state.dart';
3536
import 'package:wger/providers/network_provider.dart';
3637
import 'package:wger/providers/routines_notifier.dart';
3738
import 'package:wger/providers/routines_repository.dart';
39+
import 'package:wger/providers/trophy_repository.dart';
3840
import 'package:wger/providers/workout_session_repository.dart';
3941
import 'package:wger/screens/gym_mode.dart';
4042
import 'package:wger/screens/routine_screen.dart';
@@ -52,7 +54,7 @@ import '../../../test_data/routines.dart';
5254
import '../../fake_connectivity.dart';
5355
import 'gym_mode_test.mocks.dart';
5456

55-
@GenerateMocks([WorkoutSessionRepository, ExerciseRepository, RoutinesRepository])
57+
@GenerateMocks([WorkoutSessionRepository, ExerciseRepository, RoutinesRepository, TrophyRepository])
5658
void main() {
5759
installFakeConnectivity();
5860

@@ -89,13 +91,18 @@ void main() {
8991
).thenAnswer((_) async => testRoutine);
9092
});
9193

92-
Widget renderGymMode({locale = 'en', bool isOnline = true}) {
94+
Widget renderGymMode({
95+
locale = 'en',
96+
bool isOnline = true,
97+
List<riverpod.Override> extraOverrides = const [],
98+
}) {
9399
return riverpod.ProviderScope(
94100
overrides: [
95101
networkStatusProvider.overrideWithValue(isOnline),
96102
routinesRepositoryProvider.overrideWithValue(mockRoutinesRepo),
97103
exerciseRepositoryProvider.overrideWithValue(mockExerciseRepo),
98104
workoutSessionRepositoryProvider.overrideWithValue(mockSessionRepo),
105+
...extraOverrides,
99106
// The repetition + weight unit catalogues are tiny direct-Drift
100107
// stream providers, overriding them inline is the established
101108
// pattern (see also [exerciseCategoriesProvider] etc.).
@@ -365,6 +372,79 @@ void main() {
365372
});
366373
});
367374

375+
testWidgets('offline summary shows the local session stats', (WidgetTester tester) async {
376+
// The trophy fetch is REST-only; offline it is skipped so the locally
377+
// stored session stats (duration, volume) render right away instead of
378+
// waiting behind a doomed network request. The clock matches a session in
379+
// the test routine so there is data to show.
380+
await withClock(Clock.fixed(DateTime(2021, 5, 1, 14, 33)), () async {
381+
await tester.pumpWidget(renderGymMode(isOnline: false));
382+
await tester.pumpAndSettle();
383+
384+
// Prime the keepAlive routines stream (see the offline test above).
385+
final container = riverpod.ProviderScope.containerOf(
386+
tester.element(find.byType(TextButton)),
387+
);
388+
container.listen(routinesRiverpodProvider, (_, _) {});
389+
await tester.pumpAndSettle();
390+
391+
await tester.tap(find.byType(TextButton));
392+
await tester.pumpAndSettle();
393+
394+
// Jump straight to the summary via the menu's "End workout" shortcut.
395+
await tester.tap(find.byIcon(Icons.menu));
396+
await tester.pumpAndSettle();
397+
await tester.tap(find.text('End workout'));
398+
await tester.pumpAndSettle();
399+
400+
expect(find.byType(WorkoutSummary), findsOneWidget);
401+
expect(find.byType(StreamErrorIndicator), findsNothing);
402+
expect(find.text('Duration'), findsOneWidget);
403+
expect(find.text('Volume'), findsOneWidget);
404+
});
405+
});
406+
407+
testWidgets('summary surfaces an unexpected trophy-fetch error', (WidgetTester tester) async {
408+
// Network/server errors are swallowed by the repository, so an error that
409+
// does reach the summary is a genuine exception and must be shown, not
410+
// hidden behind the stats.
411+
final mockTrophyRepo = MockTrophyRepository();
412+
when(mockTrophyRepo.fetchTrophies(language: anyNamed('language'))).thenAnswer((_) async => []);
413+
when(
414+
mockTrophyRepo.fetchProgression(
415+
filterQuery: anyNamed('filterQuery'),
416+
language: anyNamed('language'),
417+
),
418+
).thenAnswer((_) async => []);
419+
when(
420+
mockTrophyRepo.fetchUserTrophies(
421+
filterQuery: anyNamed('filterQuery'),
422+
language: anyNamed('language'),
423+
),
424+
).thenThrow(Exception('unexpected'));
425+
426+
await withClock(Clock.fixed(DateTime(2021, 5, 1, 14, 33)), () async {
427+
await tester.pumpWidget(
428+
renderGymMode(
429+
extraOverrides: [trophyRepositoryProvider.overrideWithValue(mockTrophyRepo)],
430+
),
431+
);
432+
await tester.pumpAndSettle();
433+
await tester.tap(find.byType(TextButton));
434+
await tester.pumpAndSettle();
435+
436+
// Jump straight to the summary via the menu's "End workout" shortcut.
437+
await tester.tap(find.byIcon(Icons.menu));
438+
await tester.pumpAndSettle();
439+
await tester.tap(find.text('End workout'));
440+
await tester.pumpAndSettle();
441+
442+
expect(find.byType(WorkoutSummary), findsOneWidget);
443+
expect(find.byType(StreamErrorIndicator), findsOneWidget);
444+
expect(find.text('Duration'), findsNothing);
445+
});
446+
});
447+
368448
testWidgets(
369449
'fresh session with exercise pages off: first swipe to a log page does not crash',
370450
(WidgetTester tester) async {

0 commit comments

Comments
 (0)