diff --git a/lib/ui/screens/data_loading.dart b/lib/ui/screens/data_loading.dart index 3466596b..fd776f50 100644 --- a/lib/ui/screens/data_loading.dart +++ b/lib/ui/screens/data_loading.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:app/app_state.dart'; +import 'package:app/enums.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/screens/screens.dart'; import 'package:app/ui/widgets/widgets.dart'; @@ -14,7 +18,14 @@ class DataLoadingScreen extends StatefulWidget { } class _DataLoadingScreen extends State { + static const _stillLoadingAfter = Duration(seconds: 6); + static const _loadTimeout = Duration(seconds: 30); + var _hasError = false; + var _stillLoading = false; + var _loadGeneration = 0; + Timer? _stillLoadingTimer; + Timer? _timeoutTimer; @override void initState() { @@ -22,29 +33,95 @@ class _DataLoadingScreen extends State { _loadData(); } + @override + void dispose() { + _cancelTimers(); + super.dispose(); + } + + void _cancelTimers() { + _stillLoadingTimer?.cancel(); + _timeoutTimer?.cancel(); + } + Future _loadData() async { + final generation = ++_loadGeneration; + + _stillLoadingTimer = Timer(_stillLoadingAfter, () { + if (mounted) setState(() => _stillLoading = true); + }); + _timeoutTimer = Timer(_loadTimeout, () { + if (mounted) setState(() => _hasError = true); + }); + try { await context.read().init(); - await Navigator.of(context).pushReplacementNamed(MainScreen.routeName); - } catch (e) { - print(e); - setState(() => _hasError = true); + // Ignore a superseded attempt (e.g. a stale future resolving after the + // timeout fired and the user hit Retry). + if (!mounted || _hasError || generation != _loadGeneration) return; + _cancelTimers(); + Navigator.of(context).pushReplacementNamed(MainScreen.routeName); + } catch (_) { + if (generation != _loadGeneration) return; + _cancelTimers(); + if (mounted) setState(() => _hasError = true); } } + void _retry() { + _cancelTimers(); + setState(() { + _hasError = false; + _stillLoading = false; + }); + _loadData(); + } + + bool get _hasDownloads => + context.read().playables.isNotEmpty; + + void _viewDownloads() { + _cancelTimers(); + AppState.set('mode', AppMode.offline); + Navigator.of(context).pushReplacementNamed(MainScreen.routeName); + } + @override Widget build(BuildContext context) { return Scaffold( body: GradientDecoratedContainer( child: _hasError - ? OopsBox( - showLogOutButton: true, - onRetry: () { - setState(() => _hasError = false); - _loadData(); - }, - ) - : const ContainerWithSpinner(), + ? OopsBox(showLogOutButton: true, onRetry: _retry) + : _buildLoading(), + ), + ); + } + + Widget _buildLoading() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spinner(), + if (_stillLoading) ...[ + const SizedBox(height: 28), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'This is taking longer than usual…', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70, fontSize: 15), + ), + ), + if (_hasDownloads) ...[ + const SizedBox(height: 20), + ElevatedButton( + onPressed: _viewDownloads, + child: const Text('View Downloads'), + ), + ], + ], + ], ), ); } diff --git a/test/ui/screens/data_loading_test.dart b/test/ui/screens/data_loading_test.dart new file mode 100644 index 00000000..5530196e --- /dev/null +++ b/test/ui/screens/data_loading_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:app/app_state.dart'; +import 'package:app/enums.dart'; +import 'package:app/models/models.dart'; +import 'package:app/providers/auth_provider.dart'; +import 'package:app/providers/data_provider.dart'; +import 'package:app/providers/download_provider.dart'; +import 'package:app/ui/screens/data_loading.dart'; +import 'package:app/ui/screens/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../../extensions/widget_tester_extension.dart'; +import 'data_loading_test.mocks.dart'; + +@GenerateMocks([DataProvider, DownloadProvider, AuthProvider]) +void main() { + late MockDataProvider dataProvider; + late MockDownloadProvider downloadProvider; + late MockAuthProvider authProvider; + late Completer initCompleter; + + setUp(() { + AppState.clear(); + dataProvider = MockDataProvider(); + downloadProvider = MockDownloadProvider(); + authProvider = MockAuthProvider(); + initCompleter = Completer(); + when(dataProvider.init()).thenAnswer((_) => initCompleter.future); + }); + + Future mount( + WidgetTester tester, { + List downloads = const [], + }) async { + when(downloadProvider.playables).thenReturn(downloads); + + await tester.pumpAppWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: dataProvider), + Provider.value(value: downloadProvider), + Provider.value(value: authProvider), + ], + child: const DataLoadingScreen(), + ), + routes: {MainScreen.routeName: (_) => const Text('MAIN')}, + ); + await tester.pump(); + } + + // Completes the still-hanging load so no timers outlive the test. + Future settle(WidgetTester tester) async { + if (!initCompleter.isCompleted) initCompleter.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + } + + testWidgets('does not nag while the load is still fresh', (tester) async { + await mount(tester); + + expect(find.text('This is taking longer than usual…'), findsNothing); + + await settle(tester); + }); + + testWidgets( + 'shows the still-loading message after a delay, without a downloads button', + (tester) async { + await mount(tester); + await tester.pump(const Duration(seconds: 7)); + + expect(find.text('This is taking longer than usual…'), findsOneWidget); + expect(find.text('View Downloads'), findsNothing); + + await settle(tester); + }, + ); + + testWidgets( + 'offers View Downloads after a delay and enters offline mode', + (tester) async { + await mount(tester, downloads: [Song.fake()]); + await tester.pump(const Duration(seconds: 7)); + + expect(find.text('View Downloads'), findsOneWidget); + + await tester.tap(find.text('View Downloads')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('MAIN'), findsOneWidget); + expect(AppState.get('mode'), AppMode.offline); + + await settle(tester); + }, + ); + + testWidgets('falls back to the error box after the load times out', + (tester) async { + await mount(tester); + await tester.pump(const Duration(seconds: 30)); + + expect(find.text('Oops!'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Log Out'), findsOneWidget); + + await settle(tester); + }); + + testWidgets('shows the error box when the load fails', (tester) async { + when(dataProvider.init()).thenAnswer((_) async => throw Exception('boom')); + + await mount(tester); + + expect(find.text('Oops!'), findsOneWidget); + }); + + testWidgets('a stale load cannot clobber a retry', (tester) async { + await mount(tester); + await tester.pump(const Duration(seconds: 30)); + expect(find.text('Oops!'), findsOneWidget); + + // Retry with a fresh, still-pending load. + final retryCompleter = Completer(); + when(dataProvider.init()).thenAnswer((_) => retryCompleter.future); + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(find.text('Oops!'), findsNothing); + + // The stale first attempt resolves late — it must be ignored. + initCompleter.complete(); + await tester.pump(); + expect(find.text('MAIN'), findsNothing); + + // The retry attempt resolves — it navigates. + retryCompleter.complete(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('MAIN'), findsOneWidget); + }); +} diff --git a/test/ui/screens/data_loading_test.mocks.dart b/test/ui/screens/data_loading_test.mocks.dart new file mode 100644 index 00000000..855248ce --- /dev/null +++ b/test/ui/screens/data_loading_test.mocks.dart @@ -0,0 +1,346 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/data_loading_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; +import 'dart:ui' as _i5; + +import 'package:app/models/models.dart' as _i2; +import 'package:app/providers/providers.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i6; + +// 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 + +class _FakeUser_0 extends _i1.SmartFake implements _i2.User { + _FakeUser_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [DataProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDataProvider extends _i1.Mock implements _i3.DataProvider { + MockDataProvider() { + _i1.throwOnMissingStub(this); + } + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + _i4.Future init() => (super.noSuchMethod( + Invocation.method( + #init, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i5.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, + ); +} + +/// A class which mocks [DownloadProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDownloadProvider extends _i1.Mock implements _i3.DownloadProvider { + MockDownloadProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i2.Playable> get playables => (super.noSuchMethod( + Invocation.getter(#playables), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + _i4.Stream get downloadsClearedStream => (super.noSuchMethod( + Invocation.getter(#downloadsClearedStream), + returnValue: _i4.Stream.empty(), + ) as _i4.Stream); + + @override + _i4.Stream<_i2.Playable> get downloadRemovedStream => + (super.noSuchMethod( + Invocation.getter(#downloadRemovedStream), + returnValue: _i4.Stream<_i2.Playable>.empty(), + ) as _i4.Stream<_i2.Playable>); + + @override + _i4.Stream<_i3.Download> get playableDownloadedStream => (super.noSuchMethod( + Invocation.getter(#playableDownloadedStream), + returnValue: _i4.Stream<_i3.Download>.empty(), + ) as _i4.Stream<_i3.Download>); + + @override + _i4.Future get downloadsDir => (super.noSuchMethod( + Invocation.getter(#downloadsDir), + returnValue: _i4.Future.value(_i6.dummyValue( + this, + Invocation.getter(#downloadsDir), + )), + ) as _i4.Future); + + @override + _i4.Future download({required _i2.Playable? playable}) => + (super.noSuchMethod( + Invocation.method( + #download, + [], + {#playable: playable}, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i3.Download? getForPlayable(_i2.Playable? playable) => + (super.noSuchMethod(Invocation.method( + #getForPlayable, + [playable], + )) as _i3.Download?); + + @override + bool has({required _i2.Playable? playable}) => (super.noSuchMethod( + Invocation.method( + #has, + [], + {#playable: playable}, + ), + returnValue: false, + ) as bool); + + @override + void persistMetadata() => super.noSuchMethod( + Invocation.method( + #persistMetadata, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void persistMetadataIfNeeded(_i2.Playable? playable) => + super.noSuchMethod( + Invocation.method( + #persistMetadataIfNeeded, + [playable], + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future removeForPlayable(_i2.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #removeForPlayable, + [playable], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future clear() => (super.noSuchMethod( + Invocation.method( + #clear, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i4.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [AuthProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { + MockAuthProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.User get authUser => (super.noSuchMethod( + Invocation.getter(#authUser), + returnValue: _FakeUser_0( + this, + Invocation.getter(#authUser), + ), + ) as _i2.User); + + @override + _i4.Future<_i3.TwoFactorChallenge?> login({ + required String? host, + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method( + #login, + [], + { + #host: host, + #email: email, + #password: password, + }, + ), + returnValue: _i4.Future<_i3.TwoFactorChallenge?>.value(), + ) as _i4.Future<_i3.TwoFactorChallenge?>); + + @override + _i4.Future completeTwoFactorChallenge({ + required String? loginToken, + required String? code, + }) => + (super.noSuchMethod( + Invocation.method( + #completeTwoFactorChallenge, + [], + { + #loginToken: loginToken, + #code: code, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future loginWithOneTimeToken({ + required String? host, + required String? token, + }) => + (super.noSuchMethod( + Invocation.method( + #loginWithOneTimeToken, + [], + { + #host: host, + #token: token, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void setAuthUser(_i2.User? user) => super.noSuchMethod( + Invocation.method( + #setAuthUser, + [user], + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future<_i2.User?> tryGetAuthUser() => (super.noSuchMethod( + Invocation.method( + #tryGetAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User?>.value(), + ) as _i4.Future<_i2.User?>); + + @override + _i4.Future logout() => (super.noSuchMethod( + Invocation.method( + #logout, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i4.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +}