diff --git a/lib/models/user.dart b/lib/models/user.dart index 08e5d980..09a95f02 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,11 +1,14 @@ -import 'package:app/utils/crypto.dart'; +import 'package:app/constants/constants.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; class User { dynamic id; // This might be a UUID string in the near future String name; String email; + final String? avatarUrl; + /// The user's preferred order of Home screen blocks, by block id. Empty /// when the server doesn't expose the preference (older API) or the user /// never reordered the blocks. @@ -15,15 +18,16 @@ class User { required this.id, required this.name, required this.email, + this.avatarUrl, this.homeBlocksOrder = const [], }); - CachedNetworkImageProvider get avatar { - String hash = md5(name.trim().toLowerCase()); + ImageProvider get avatar { + final avatarUrl = this.avatarUrl; - return CachedNetworkImageProvider( - 'https://www.gravatar.com/avatar/$hash?s=512&d=robohash', - ); + return avatarUrl == null + ? AppImages.defaultImage.image + : CachedNetworkImageProvider(avatarUrl); } factory User.fromJson(Map json) { @@ -34,6 +38,7 @@ class User { id: json['id'], name: json['name'], email: json['email'], + avatarUrl: json['avatar'], homeBlocksOrder: order is List ? order.whereType().toList() : const [], ); diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 423d444f..f6900ab4 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -87,11 +87,19 @@ class AuthProvider with StreamSubscriber { return null; } - var user = User.fromJson(await get('me')); - - this.setAuthUser(user); + final user = await refreshAuthUser(); _userLoggedIn.add(user); + return user; + } + + /// Reloads the signed-in user from the server. + /// + /// Unlike [tryGetAuthUser], this doesn't announce a login, so listeners such + /// as the download provider don't re-collect their data. + Future refreshAuthUser() async { + setAuthUser(User.fromJson(await get('me'))); + return authUser; } diff --git a/lib/ui/screens/home.dart b/lib/ui/screens/home.dart index e0c22da6..1c5eae32 100644 --- a/lib/ui/screens/home.dart +++ b/lib/ui/screens/home.dart @@ -76,6 +76,19 @@ class _HomeScreenState extends State { } } + Future _refresh() async { + final overviewProvider = context.read(); + + // Reload the user first, so the rebuild triggered by the overview refresh + // also picks up the user's latest Home block order. + try { + await context.read().refreshAuthUser(); + } catch (_) { + // A stale user shouldn't stop the overview from refreshing. + } + await overviewProvider.refresh(); + } + Widget _songBlock(String heading, List songs) { return HorizontalCardScroller( headingText: heading, @@ -203,7 +216,7 @@ class _HomeScreenState extends State { barBackgroundColor: AppColors.staticScreenHeaderBackground, ), child: PullToRefresh( - onRefresh: () => context.read().refresh(), + onRefresh: _refresh, child: CustomScrollView( slivers: overviewProvider.isEmpty ? [SliverToBoxAdapter(child: const EmptyHomeScreen())] diff --git a/lib/ui/screens/profile_action_sheet.dart b/lib/ui/screens/profile_action_sheet.dart new file mode 100644 index 00000000..d1391d87 --- /dev/null +++ b/lib/ui/screens/profile_action_sheet.dart @@ -0,0 +1,168 @@ +import 'package:app/constants/constants.dart'; +import 'package:app/main.dart'; +import 'package:app/models/models.dart'; +import 'package:app/providers/providers.dart'; +import 'package:app/ui/screens/login.dart'; +import 'package:app/ui/screens/playable_action_sheet.dart'; +import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/preferences.dart' as preferences; +import 'package:app/utils/route_state.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ProfileActionSheet extends StatelessWidget { + final User user; + + const ProfileActionSheet({Key? key, required this.user}) : super(key: key); + + void _logout(BuildContext context) { + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + return CupertinoAlertDialog( + title: const Text('Log out?'), + actions: [ + CupertinoDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.pop(context), + ), + CupertinoDialogAction( + child: const Text('Confirm'), + isDestructiveAction: true, + onPressed: () async { + await context.read().logout(); + await audioHandler.cleanUpUponLogout(); + RouteState.clear(); + Navigator.of( + context, + rootNavigator: true, + ).pushNamedAndRemoveUntil(LoginScreen.routeName, (_) => false); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return FrostedGlassBackground( + sigma: 40.0, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: IntrinsicHeight( + child: Row( + children: [ + // A DecorationImage has no intrinsic size, unlike Image, + // so the avatar takes the height of the text next to it. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white10), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.background, + image: DecorationImage( + image: user.avatar, + fit: BoxFit.cover, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + user.email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white54), + ), + const SizedBox(height: 2), + Text( + preferences.host ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white54), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + const Divider(indent: 16, endIndent: 16), + ListView( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + children: [ + PlayableActionButton( + text: 'Clear Downloads', + icon: const Icon( + CupertinoIcons.cloud_download, + color: Colors.white30, + ), + onTap: () => context.read().clear(), + ), + PlayableActionButton( + text: 'Log Out', + icon: const Icon(CupertinoIcons.square_arrow_right), + destructive: true, + onTap: () => _logout(context), + hideSheetOnTap: false, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +Future showProfileActionSheet( + BuildContext context, { + required User user, +}) { + return showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (_) => ProfileActionSheet(user: user), + ); +} diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 8a437479..beb48dd4 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -32,6 +32,7 @@ export 'playlists.dart'; export 'podcast_action_sheet.dart'; export 'podcast_details.dart'; export 'podcasts.dart'; +export 'profile_action_sheet.dart'; export 'radio_now_playing.dart'; export 'radio_station_action_sheet.dart'; export 'radio_stations.dart'; diff --git a/lib/ui/widgets/profile_avatar.dart b/lib/ui/widgets/profile_avatar.dart index bbc93d43..8aab88e3 100644 --- a/lib/ui/widgets/profile_avatar.dart +++ b/lib/ui/widgets/profile_avatar.dart @@ -1,92 +1,19 @@ -import 'package:app/main.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/screens/screens.dart'; -import 'package:app/ui/widgets/widgets.dart'; -import 'package:app/utils/route_state.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -enum ProfileAvatarMenuItems { - clearDownloads, - logout, -} - class ProfileAvatar extends StatelessWidget { const ProfileAvatar({Key? key}) : super(key: key); - void logout(BuildContext context) { - showCupertinoDialog( - context: context, - builder: (BuildContext context) { - return CupertinoAlertDialog( - title: const Text('Log out?'), - actions: [ - CupertinoDialogAction( - child: const Text('Cancel'), - onPressed: () => Navigator.pop(context), - ), - CupertinoDialogAction( - child: const Text('Confirm'), - isDestructiveAction: true, - onPressed: () async { - await context.read().logout(); - await audioHandler.cleanUpUponLogout(); - RouteState.clear(); - Navigator.of( - context, - rootNavigator: true, - ).pushNamedAndRemoveUntil(LoginScreen.routeName, (_) => false); - }, - ), - ], - ); - }, - ); - } - @override Widget build(BuildContext context) { - return Builder( - builder: (buttonContext) => IconButton( - icon: const Icon(CupertinoIcons.person_alt_circle, size: 24), - onPressed: () async { - final box = buttonContext.findRenderObject() as RenderBox?; - final origin = box == null - ? Offset.zero - : box.localToGlobal(Offset.zero) + Offset(0, box.size.height); - - final selected = - await showFrostedContextMenu( - context: buttonContext, - position: origin, - items: const [ - FrostedMenuItem( - value: ProfileAvatarMenuItems.clearDownloads, - icon: CupertinoIcons.cloud_download, - label: 'Clear downloads', - ), - FrostedMenuItem( - value: ProfileAvatarMenuItems.logout, - icon: CupertinoIcons.square_arrow_right, - label: 'Log out', - destructive: true, - ), - ], - ); - if (!buttonContext.mounted) return; - - switch (selected) { - case ProfileAvatarMenuItems.clearDownloads: - buttonContext.read().clear(); - break; - case ProfileAvatarMenuItems.logout: - logout(buttonContext); - break; - case null: - break; - } - }, + return IconButton( + icon: const Icon(CupertinoIcons.person_alt_circle, size: 24), + onPressed: () => showProfileActionSheet( + context, + user: context.read().authUser, ), ); } diff --git a/test/models/user_test.dart b/test/models/user_test.dart index a0741fae..d6e05b2a 100644 --- a/test/models/user_test.dart +++ b/test/models/user_test.dart @@ -1,4 +1,6 @@ +import 'package:app/constants/constants.dart'; import 'package:app/models/user.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -33,4 +35,18 @@ void main() { expect(user.homeBlocksOrder, ['random-songs', 'top-albums']); }); + + test('uses the avatar URL provided by the server', () { + final user = User.fromJson({ + ...baseJson(), + 'avatar': 'https://koel.test/img/avatars/jane.webp', + }); + + final avatar = user.avatar as CachedNetworkImageProvider; + expect(avatar.url, 'https://koel.test/img/avatars/jane.webp'); + }); + + test('falls back to the default image when the server sends no avatar', () { + expect(User.fromJson(baseJson()).avatar, AppImages.defaultImage.image); + }); } diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 3642c8ae..acb0ca46 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -90,4 +90,72 @@ void main() { expect(preferences.apiToken, isNull); }); }); + + group('tryGetAuthUser', () { + test('stores the signed-in user and announces the login', () async { + preferences.apiToken = 'tok'; + client.willReturn(json: { + 'id': 'user-1', + 'name': 'Jane', + 'email': 'jane@koel.test', + }); + final announcedUsers = []; + final subscription = AuthProvider.userLoggedInStream + .listen((user) => announcedUsers.add(user.name)); + addTearDown(subscription.cancel); + + final user = await auth.tryGetAuthUser(); + await Future.delayed(Duration.zero); + + expect(user!.name, 'Jane'); + expect(auth.authUser.name, 'Jane'); + expect(announcedUsers, ['Jane']); + }); + + test('returns null without a request when there is no token', () async { + expect(await auth.tryGetAuthUser(), isNull); + expect(client.requests, isEmpty); + }); + }); + + group('refreshAuthUser', () { + test('replaces the signed-in user with the latest one from the server', + () async { + client.willReturn(json: { + 'id': 'user-1', + 'name': 'Jane', + 'email': 'jane@koel.test', + }); + await auth.refreshAuthUser(); + + client.willReturn(json: { + 'id': 'user-1', + 'name': 'Jane Doe', + 'email': 'jane@koel.test', + }); + final user = await auth.refreshAuthUser(); + + expect(user.name, 'Jane Doe'); + expect(auth.authUser.name, 'Jane Doe'); + expect(client.requests.last.url, 'https://koel.test/api/me'); + expect(client.requests.last.method, 'GET'); + }); + + test('does not announce a login', () async { + client.willReturn(json: { + 'id': 'user-1', + 'name': 'Jane', + 'email': 'jane@koel.test', + }); + var loginAnnouncements = 0; + final subscription = + AuthProvider.userLoggedInStream.listen((_) => loginAnnouncements++); + addTearDown(subscription.cancel); + + await auth.refreshAuthUser(); + await Future.delayed(Duration.zero); + + expect(loginAnnouncements, 0); + }); + }); } diff --git a/test/ui/screens/data_loading_test.mocks.dart b/test/ui/screens/data_loading_test.mocks.dart index 855248ce..ec7751b2 100644 --- a/test/ui/screens/data_loading_test.mocks.dart +++ b/test/ui/screens/data_loading_test.mocks.dart @@ -316,6 +316,21 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { returnValue: _i4.Future<_i2.User?>.value(), ) as _i4.Future<_i2.User?>); + @override + _i4.Future<_i2.User> refreshAuthUser() => (super.noSuchMethod( + Invocation.method( + #refreshAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User>.value(_FakeUser_0( + this, + Invocation.method( + #refreshAuthUser, + [], + ), + )), + ) as _i4.Future<_i2.User>); + @override _i4.Future logout() => (super.noSuchMethod( Invocation.method( diff --git a/test/ui/screens/home_test.dart b/test/ui/screens/home_test.dart new file mode 100644 index 00000000..f3fcc610 --- /dev/null +++ b/test/ui/screens/home_test.dart @@ -0,0 +1,147 @@ +import 'package:app/models/album.dart'; +import 'package:app/models/user.dart'; +import 'package:app/providers/album_provider.dart'; +import 'package:app/providers/auth_provider.dart'; +import 'package:app/providers/overview_provider.dart'; +import 'package:app/providers/recently_played_provider.dart'; +import 'package:app/ui/screens/home.dart'; +import 'package:app/ui/screens/profile_action_sheet.dart'; +import 'package:flutter/cupertino.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 '../../helpers/api_test_setup.dart'; +import 'home_test.mocks.dart'; + +@GenerateMocks([AuthProvider, OverviewProvider, RecentlyPlayedProvider]) +void main() { + late MockAuthProvider authProviderMock; + late MockOverviewProvider overviewProviderMock; + late MockRecentlyPlayedProvider recentlyPlayedProviderMock; + late AlbumProvider albumProvider; + User? currentUser; + + final user = User(id: 'user-1', name: 'Jane', email: 'jane@koel.test'); + + setUpAll(initApiTestEnvironment); + + setUp(() { + setUpApiTest(); + + authProviderMock = MockAuthProvider(); + overviewProviderMock = MockOverviewProvider(); + recentlyPlayedProviderMock = MockRecentlyPlayedProvider(); + albumProvider = AlbumProvider(); + addTearDown(albumProvider.dispose); + currentUser = user; + + when(overviewProviderMock.isEmpty).thenReturn(false); + when(overviewProviderMock.mostPlayedSongs).thenReturn([]); + when(overviewProviderMock.recentlyAddedSongs).thenReturn([]); + when(overviewProviderMock.recentlyPlayedSongs).thenReturn([]); + when(overviewProviderMock.leastPlayedSongs).thenReturn([]); + when(overviewProviderMock.randomSongs).thenReturn([]); + when(overviewProviderMock.similarSongs).thenReturn([]); + when(overviewProviderMock.mostPlayedAlbums) + .thenReturn(albumProvider.syncWithVault([Album.fake(name: 'Top')])); + when(overviewProviderMock.recentlyAddedAlbums) + .thenReturn(albumProvider.syncWithVault([Album.fake(name: 'Latest')])); + when(overviewProviderMock.randomAlbums).thenReturn([]); + when(overviewProviderMock.mostPlayedArtists).thenReturn([]); + when(overviewProviderMock.recentlyAddedArtists).thenReturn([]); + when(overviewProviderMock.randomArtists).thenReturn([]); + final overviewListeners = []; + when(overviewProviderMock.addListener(any)).thenAnswer((invocation) => + overviewListeners.add(invocation.positionalArguments.single)); + when(overviewProviderMock.refresh()).thenAnswer( + (_) => Future.microtask(() { + for (final listener in overviewListeners) { + listener(); + } + }), + ); + when(recentlyPlayedProviderMock.playables).thenReturn([]); + when(authProviderMock.authUser).thenAnswer((_) => currentUser!); + when(authProviderMock.maybeAuthUser).thenAnswer((_) => currentUser); + when(authProviderMock.refreshAuthUser()).thenAnswer((_) async => user); + }); + + tearDown(tearDownApiTest); + + Future mount(WidgetTester tester) async { + await tester.pumpAppWidget( + MultiProvider( + providers: [ + Provider.value(value: authProviderMock), + ChangeNotifierProvider.value( + value: overviewProviderMock, + ), + ChangeNotifierProvider.value( + value: recentlyPlayedProviderMock, + ), + ChangeNotifierProvider.value(value: albumProvider), + ], + child: const HomeScreen(), + ), + ); + await tester.pumpAndSettle(); + } + + Future pullToRefresh(WidgetTester tester) async { + await tester.fling(find.text('Home'), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + } + + bool isAbove(WidgetTester tester, String upperText, String lowerText) => + tester.getTopLeft(find.text(upperText)).dy < + tester.getTopLeft(find.text(lowerText)).dy; + + testWidgets('pulling to refresh applies the block order saved on the web', + (tester) async { + final reorderedUser = User( + id: 'user-1', + name: 'Jane', + email: 'jane@koel.test', + homeBlocksOrder: ['most-played-albums'], + ); + when(authProviderMock.refreshAuthUser()).thenAnswer((_) async { + currentUser = reorderedUser; + return reorderedUser; + }); + + await mount(tester); + expect(isAbove(tester, 'Latest Albums', 'Top Albums'), isTrue); + + await pullToRefresh(tester); + + expect(isAbove(tester, 'Top Albums', 'Latest Albums'), isTrue); + }); + + testWidgets( + 'pulling to refresh still reloads the overview when the user reload fails', + (tester) async { + when(authProviderMock.refreshAuthUser()) + .thenAnswer((_) async => throw Exception('offline')); + + await mount(tester); + clearInteractions(overviewProviderMock); + + await pullToRefresh(tester); + + verify(overviewProviderMock.refresh()).called(1); + }); + + testWidgets('tapping the profile button opens the profile sheet', + (tester) async { + await mount(tester); + + await tester.tap(find.byIcon(CupertinoIcons.person_alt_circle)); + await tester.pumpAndSettle(); + + expect(find.byType(ProfileActionSheet), findsOneWidget); + expect(find.text('jane@koel.test'), findsOneWidget); + }); +} diff --git a/test/ui/screens/home_test.mocks.dart b/test/ui/screens/home_test.mocks.dart new file mode 100644 index 00000000..2a4a9b85 --- /dev/null +++ b/test/ui/screens/home_test.mocks.dart @@ -0,0 +1,439 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/home_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; + +// 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 [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<_i2.User> refreshAuthUser() => (super.noSuchMethod( + Invocation.method( + #refreshAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User>.value(_FakeUser_0( + this, + Invocation.method( + #refreshAuthUser, + [], + ), + )), + ) 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, + ); +} + +/// A class which mocks [OverviewProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockOverviewProvider extends _i1.Mock implements _i3.OverviewProvider { + MockOverviewProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i2.Playable> get mostPlayedSongs => (super.noSuchMethod( + Invocation.getter(#mostPlayedSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Playable> get recentlyAddedSongs => (super.noSuchMethod( + Invocation.getter(#recentlyAddedSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Playable> get recentlyPlayedSongs => (super.noSuchMethod( + Invocation.getter(#recentlyPlayedSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Playable> get leastPlayedSongs => (super.noSuchMethod( + Invocation.getter(#leastPlayedSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Playable> get randomSongs => (super.noSuchMethod( + Invocation.getter(#randomSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Playable> get similarSongs => (super.noSuchMethod( + Invocation.getter(#similarSongs), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + List<_i2.Album> get mostPlayedAlbums => (super.noSuchMethod( + Invocation.getter(#mostPlayedAlbums), + returnValue: <_i2.Album>[], + ) as List<_i2.Album>); + + @override + List<_i2.Album> get recentlyAddedAlbums => (super.noSuchMethod( + Invocation.getter(#recentlyAddedAlbums), + returnValue: <_i2.Album>[], + ) as List<_i2.Album>); + + @override + List<_i2.Album> get randomAlbums => (super.noSuchMethod( + Invocation.getter(#randomAlbums), + returnValue: <_i2.Album>[], + ) as List<_i2.Album>); + + @override + List<_i2.Artist> get mostPlayedArtists => (super.noSuchMethod( + Invocation.getter(#mostPlayedArtists), + returnValue: <_i2.Artist>[], + ) as List<_i2.Artist>); + + @override + List<_i2.Artist> get recentlyAddedArtists => (super.noSuchMethod( + Invocation.getter(#recentlyAddedArtists), + returnValue: <_i2.Artist>[], + ) as List<_i2.Artist>); + + @override + List<_i2.Artist> get randomArtists => (super.noSuchMethod( + Invocation.getter(#randomArtists), + returnValue: <_i2.Artist>[], + ) as List<_i2.Artist>); + + @override + bool get isEmpty => (super.noSuchMethod( + Invocation.getter(#isEmpty), + returnValue: false, + ) as bool); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + _i4.Future refresh() => (super.noSuchMethod( + Invocation.method( + #refresh, + [], + ), + 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, + ); + + @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 [RecentlyPlayedProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRecentlyPlayedProvider extends _i1.Mock + implements _i3.RecentlyPlayedProvider { + MockRecentlyPlayedProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i2.Playable> get playables => (super.noSuchMethod( + Invocation.getter(#playables), + returnValue: <_i2.Playable>[], + ) as List<_i2.Playable>); + + @override + set playables(List<_i2.Playable>? _playables) => super.noSuchMethod( + Invocation.setter( + #playables, + _playables, + ), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + _i4.Future>> fetch() => (super.noSuchMethod( + Invocation.method( + #fetch, + [], + ), + returnValue: _i4.Future>>.value( + <_i2.Playable>[]), + ) as _i4.Future>>); + + @override + void seed(List<_i2.Playable>? items) => super.noSuchMethod( + Invocation.method( + #seed, + [items], + ), + returnValueForMissingStub: null, + ); + + @override + void add(_i2.Playable? playable) => super.noSuchMethod( + Invocation.method( + #add, + [playable], + ), + returnValueForMissingStub: null, + ); + + @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, + ); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i4.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/ui/screens/profile_action_sheet_test.dart b/test/ui/screens/profile_action_sheet_test.dart new file mode 100644 index 00000000..f0706f97 --- /dev/null +++ b/test/ui/screens/profile_action_sheet_test.dart @@ -0,0 +1,211 @@ +import 'package:app/audio_handler.dart'; +import 'package:app/main.dart' as app; +import 'package:app/models/user.dart'; +import 'package:app/providers/auth_provider.dart'; +import 'package:app/providers/download_provider.dart'; +import 'package:app/ui/screens/login.dart'; +import 'package:app/ui/screens/profile_action_sheet.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.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 '../../helpers/api_test_setup.dart'; +import 'profile_action_sheet_test.mocks.dart'; + +@GenerateMocks([KoelAudioHandler, AuthProvider, DownloadProvider]) +void main() { + late MockKoelAudioHandler audioHandlerMock; + late MockAuthProvider authProviderMock; + late MockDownloadProvider downloadProviderMock; + + final user = User( + id: 'user-1', + name: 'Jane', + email: 'jane@koel.test', + avatarUrl: 'https://koel.test/img/avatars/jane.webp', + ); + + setUpAll(initApiTestEnvironment); + + setUp(() { + setUpApiTest(); + + audioHandlerMock = MockKoelAudioHandler(); + authProviderMock = MockAuthProvider(); + downloadProviderMock = MockDownloadProvider(); + + when(audioHandlerMock.cleanUpUponLogout()).thenAnswer((_) async {}); + when(authProviderMock.logout()).thenAnswer((_) async {}); + when(downloadProviderMock.clear()).thenAnswer((_) async {}); + + app.audioHandler = audioHandlerMock; + }); + + tearDown(tearDownApiTest); + + Future mount(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(375, 812)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: authProviderMock), + Provider.value(value: downloadProviderMock), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Material( + child: TextButton( + onPressed: () => showProfileActionSheet(context, user: user), + child: const Text('Open sheet'), + ), + ), + ), + routes: { + LoginScreen.routeName: (_) => const Text('LOGIN'), + }, + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + } + + group('structure', () { + testWidgets('renders the name, email and server', (tester) async { + await mount(tester); + + expect(find.text('Jane'), findsOneWidget); + expect(find.text('jane@koel.test'), findsOneWidget); + expect(find.text('https://koel.test'), findsOneWidget); + }); + + testWidgets('sizes the avatar to the height of the account details', + (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await mount(tester); + + final avatarSize = tester.getSize(find.descendant( + of: find.byType(ProfileActionSheet), + matching: find.byType(AspectRatio), + )); + final detailsHeight = tester + .getSize(find + .ancestor( + of: find.text('Jane'), + matching: find.byType(Column), + ) + .first) + .height; + + expect(avatarSize.height, detailsHeight); + expect(avatarSize.width, avatarSize.height); + }); + + testWidgets('shows the avatar from the server', (tester) async { + await mount(tester); + + final avatarImages = tester + .widgetList(find.descendant( + of: find.byType(ProfileActionSheet), + matching: find.byType(DecoratedBox), + )) + .map((box) => (box.decoration as BoxDecoration).image?.image) + .whereType(); + + expect( + avatarImages.single.url, + 'https://koel.test/img/avatars/jane.webp', + ); + }); + + testWidgets('keeps the actions above the bottom inset', (tester) async { + const bottomInset = 34.0; + tester.view.padding = FakeViewPadding( + bottom: bottomInset * tester.view.devicePixelRatio, + ); + addTearDown(tester.view.resetPadding); + + await mount(tester); + + final logOutBottom = + tester.getBottomLeft(find.widgetWithText(ListTile, 'Log Out')).dy; + expect(logOutBottom, lessThanOrEqualTo(812 - bottomInset)); + }); + + testWidgets('keeps room for the account details at 3x text size', + (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 3; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await mount(tester); + + final avatarWidth = tester + .getSize(find.descendant( + of: find.byType(ProfileActionSheet), + matching: find.byType(AspectRatio), + )) + .width; + final detailsWidth = tester + .getSize(find + .ancestor( + of: find.text('Jane'), + matching: find.byType(Column), + ) + .first) + .width; + + expect(detailsWidth, greaterThanOrEqualTo(avatarWidth)); + }); + }); + + group('actions', () { + testWidgets('tapping Clear Downloads clears downloads and closes the sheet', + (tester) async { + await mount(tester); + + await tester.tap(find.text('Clear Downloads')); + await tester.pumpAndSettle(); + + verify(downloadProviderMock.clear()).called(1); + expect(find.byType(ProfileActionSheet), findsNothing); + }); + + testWidgets('cancelling Log Out keeps the user logged in', (tester) async { + await mount(tester); + + await tester.tap(find.text('Log Out')); + await tester.pumpAndSettle(); + expect(find.text('Log out?'), findsOneWidget); + + await tester.tap(find.widgetWithText(CupertinoDialogAction, 'Cancel')); + await tester.pumpAndSettle(); + + verifyNever(authProviderMock.logout()); + expect(find.byType(ProfileActionSheet), findsOneWidget); + }); + + testWidgets('confirming Log Out logs out and returns to the login screen', + (tester) async { + await mount(tester); + + await tester.tap(find.text('Log Out')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(CupertinoDialogAction, 'Confirm')); + await tester.pumpAndSettle(); + + verify(authProviderMock.logout()).called(1); + verify(audioHandlerMock.cleanUpUponLogout()).called(1); + expect(find.text('LOGIN'), findsOneWidget); + expect(find.byType(ProfileActionSheet), findsNothing); + }); + }); +} diff --git a/test/ui/screens/profile_action_sheet_test.mocks.dart b/test/ui/screens/profile_action_sheet_test.mocks.dart new file mode 100644 index 00000000..76b885e1 --- /dev/null +++ b/test/ui/screens/profile_action_sheet_test.mocks.dart @@ -0,0 +1,1274 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/profile_action_sheet_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i8; + +import 'package:app/audio_handler.dart' as _i6; +import 'package:app/models/models.dart' as _i5; +import 'package:app/providers/providers.dart' as _i2; +import 'package:audio_service/audio_service.dart' as _i7; +import 'package:just_audio/just_audio.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i9; +import 'package:rxdart/rxdart.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 + +class _FakeDownloadProvider_0 extends _i1.SmartFake + implements _i2.DownloadProvider { + _FakeDownloadProvider_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePlayableProvider_1 extends _i1.SmartFake + implements _i2.PlayableProvider { + _FakePlayableProvider_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeDuration_2 extends _i1.SmartFake implements Duration { + _FakeDuration_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAudioPlayer_3 extends _i1.SmartFake implements _i3.AudioPlayer { + _FakeAudioPlayer_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBehaviorSubject_4 extends _i1.SmartFake + implements _i4.BehaviorSubject { + _FakeBehaviorSubject_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePublishSubject_5 extends _i1.SmartFake + implements _i4.PublishSubject { + _FakePublishSubject_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeValueStream_6 extends _i1.SmartFake + implements _i4.ValueStream { + _FakeValueStream_6( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeUser_7 extends _i1.SmartFake implements _i5.User { + _FakeUser_7( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [KoelAudioHandler]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockKoelAudioHandler extends _i1.Mock implements _i6.KoelAudioHandler { + MockKoelAudioHandler() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.DownloadProvider get downloadProvider => (super.noSuchMethod( + Invocation.getter(#downloadProvider), + returnValue: _FakeDownloadProvider_0( + this, + Invocation.getter(#downloadProvider), + ), + ) as _i2.DownloadProvider); + + @override + _i2.PlayableProvider get playableProvider => (super.noSuchMethod( + Invocation.getter(#playableProvider), + returnValue: _FakePlayableProvider_1( + this, + Invocation.getter(#playableProvider), + ), + ) as _i2.PlayableProvider); + + @override + _i7.AudioServiceRepeatMode get repeatMode => (super.noSuchMethod( + Invocation.getter(#repeatMode), + returnValue: _i7.AudioServiceRepeatMode.none, + ) as _i7.AudioServiceRepeatMode); + + @override + Duration get sourceLoadTimeout => (super.noSuchMethod( + Invocation.getter(#sourceLoadTimeout), + returnValue: _FakeDuration_2( + this, + Invocation.getter(#sourceLoadTimeout), + ), + ) as Duration); + + @override + _i3.AudioPlayer get player => (super.noSuchMethod( + Invocation.getter(#player), + returnValue: _FakeAudioPlayer_3( + this, + Invocation.getter(#player), + ), + ) as _i3.AudioPlayer); + + @override + bool get isRadioMode => (super.noSuchMethod( + Invocation.getter(#isRadioMode), + returnValue: false, + ) as bool); + + @override + int get currentQueueIndex => (super.noSuchMethod( + Invocation.getter(#currentQueueIndex), + returnValue: 0, + ) as int); + + @override + set downloadProvider(_i2.DownloadProvider? _downloadProvider) => + super.noSuchMethod( + Invocation.setter( + #downloadProvider, + _downloadProvider, + ), + returnValueForMissingStub: null, + ); + + @override + set playableProvider(_i2.PlayableProvider? _playableProvider) => + super.noSuchMethod( + Invocation.setter( + #playableProvider, + _playableProvider, + ), + returnValueForMissingStub: null, + ); + + @override + set repeatMode(_i7.AudioServiceRepeatMode? _repeatMode) => super.noSuchMethod( + Invocation.setter( + #repeatMode, + _repeatMode, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.BehaviorSubject<_i7.PlaybackState> get playbackState => + (super.noSuchMethod( + Invocation.getter(#playbackState), + returnValue: _FakeBehaviorSubject_4<_i7.PlaybackState>( + this, + Invocation.getter(#playbackState), + ), + ) as _i4.BehaviorSubject<_i7.PlaybackState>); + + @override + _i4.BehaviorSubject> get queue => (super.noSuchMethod( + Invocation.getter(#queue), + returnValue: _FakeBehaviorSubject_4>( + this, + Invocation.getter(#queue), + ), + ) as _i4.BehaviorSubject>); + + @override + _i4.BehaviorSubject get queueTitle => (super.noSuchMethod( + Invocation.getter(#queueTitle), + returnValue: _FakeBehaviorSubject_4( + this, + Invocation.getter(#queueTitle), + ), + ) as _i4.BehaviorSubject); + + @override + _i4.BehaviorSubject<_i7.MediaItem?> get mediaItem => (super.noSuchMethod( + Invocation.getter(#mediaItem), + returnValue: _FakeBehaviorSubject_4<_i7.MediaItem?>( + this, + Invocation.getter(#mediaItem), + ), + ) as _i4.BehaviorSubject<_i7.MediaItem?>); + + @override + _i4.BehaviorSubject<_i7.AndroidPlaybackInfo> get androidPlaybackInfo => + (super.noSuchMethod( + Invocation.getter(#androidPlaybackInfo), + returnValue: _FakeBehaviorSubject_4<_i7.AndroidPlaybackInfo>( + this, + Invocation.getter(#androidPlaybackInfo), + ), + ) as _i4.BehaviorSubject<_i7.AndroidPlaybackInfo>); + + @override + _i4.BehaviorSubject<_i7.RatingStyle> get ratingStyle => (super.noSuchMethod( + Invocation.getter(#ratingStyle), + returnValue: _FakeBehaviorSubject_4<_i7.RatingStyle>( + this, + Invocation.getter(#ratingStyle), + ), + ) as _i4.BehaviorSubject<_i7.RatingStyle>); + + @override + _i4.PublishSubject get customEvent => (super.noSuchMethod( + Invocation.getter(#customEvent), + returnValue: _FakePublishSubject_5( + this, + Invocation.getter(#customEvent), + ), + ) as _i4.PublishSubject); + + @override + _i4.BehaviorSubject get customState => (super.noSuchMethod( + Invocation.getter(#customState), + returnValue: _FakeBehaviorSubject_4( + this, + Invocation.getter(#customState), + ), + ) as _i4.BehaviorSubject); + + @override + dynamic init({ + required _i2.PlayableProvider? playableProvider, + required _i2.DownloadProvider? downloadProvider, + }) => + super.noSuchMethod(Invocation.method( + #init, + [], + { + #playableProvider: playableProvider, + #downloadProvider: downloadProvider, + }, + )); + + @override + void enterRadioMode(_i3.AudioPlayer? radioPlayer) => super.noSuchMethod( + Invocation.method( + #enterRadioMode, + [radioPlayer], + ), + returnValueForMissingStub: null, + ); + + @override + void exitRadioMode() => super.noSuchMethod( + Invocation.method( + #exitRadioMode, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void updateRadioPlaybackState({ + required bool? playing, + required _i7.AudioProcessingState? processingState, + }) => + super.noSuchMethod( + Invocation.method( + #updateRadioPlaybackState, + [], + { + #playing: playing, + #processingState: processingState, + }, + ), + returnValueForMissingStub: null, + ); + + @override + num? getPlaybackPositionFromState(String? playableId) => + (super.noSuchMethod(Invocation.method( + #getPlaybackPositionFromState, + [playableId], + )) as num?); + + @override + void setPlaybackPositionToState( + String? playableId, + num? position, + ) => + super.noSuchMethod( + Invocation.method( + #setPlaybackPositionToState, + [ + playableId, + position, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i8.Future play() => (super.noSuchMethod( + Invocation.method( + #play, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future pause() => (super.noSuchMethod( + Invocation.method( + #pause, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future stop() => (super.noSuchMethod( + Invocation.method( + #stop, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future queueAndPlay(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueAndPlay, + [playable], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future maybeQueueAndPlay( + _i5.Playable? playable, { + dynamic position = 0, + }) => + (super.noSuchMethod( + Invocation.method( + #maybeQueueAndPlay, + [playable], + {#position: position}, + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future queueAfterCurrent(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueAfterCurrent, + [playable], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future playOrPause() => (super.noSuchMethod( + Invocation.method( + #playOrPause, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future skipToNext() => (super.noSuchMethod( + Invocation.method( + #skipToNext, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future seek(Duration? position) => (super.noSuchMethod( + Invocation.method( + #seek, + [position], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future skipToPrevious() => (super.noSuchMethod( + Invocation.method( + #skipToPrevious, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future queued(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queued, + [playable], + ), + returnValue: _i8.Future.value(false), + ) as _i8.Future); + + @override + _i8.Future removeQueueItemAt(int? index) => (super.noSuchMethod( + Invocation.method( + #removeQueueItemAt, + [index], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + void moveQueueItem( + int? oldIndex, + int? newIndex, + ) => + super.noSuchMethod( + Invocation.method( + #moveQueueItem, + [ + oldIndex, + newIndex, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i8.Future clearQueue() => (super.noSuchMethod( + Invocation.method( + #clearQueue, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future setVolume(double? value) => (super.noSuchMethod( + Invocation.method( + #setVolume, + [value], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future replaceQueue( + List<_i5.Playable>? playables, { + bool? shuffle = false, + bool? autoPlay = true, + }) => + (super.noSuchMethod( + Invocation.method( + #replaceQueue, + [playables], + { + #shuffle: shuffle, + #autoPlay: autoPlay, + }, + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future<_i7.AudioServiceRepeatMode> rotateRepeatMode() => + (super.noSuchMethod( + Invocation.method( + #rotateRepeatMode, + [], + ), + returnValue: _i8.Future<_i7.AudioServiceRepeatMode>.value( + _i7.AudioServiceRepeatMode.none), + ) as _i8.Future<_i7.AudioServiceRepeatMode>); + + @override + _i8.Future setRepeatMode(_i7.AudioServiceRepeatMode? repeatMode) => + (super.noSuchMethod( + Invocation.method( + #setRepeatMode, + [repeatMode], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future cleanUpUponLogout() => (super.noSuchMethod( + Invocation.method( + #cleanUpUponLogout, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future queueToBottom(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueToBottom, + [playable], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future removeFromQueue(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #removeFromQueue, + [playable], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future prepare() => (super.noSuchMethod( + Invocation.method( + #prepare, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future prepareFromMediaId( + String? mediaId, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromMediaId, + [ + mediaId, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future prepareFromSearch( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromSearch, + [ + query, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future prepareFromUri( + Uri? uri, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromUri, + [ + uri, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future playFromMediaId( + String? mediaId, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromMediaId, + [ + mediaId, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future playFromSearch( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromSearch, + [ + query, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future playFromUri( + Uri? uri, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromUri, + [ + uri, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future playMediaItem(_i7.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #playMediaItem, + [mediaItem], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future click([_i7.MediaButton? button = _i7.MediaButton.media]) => + (super.noSuchMethod( + Invocation.method( + #click, + [button], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future addQueueItem(_i7.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #addQueueItem, + [mediaItem], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future addQueueItems(List<_i7.MediaItem>? mediaItems) => + (super.noSuchMethod( + Invocation.method( + #addQueueItems, + [mediaItems], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future insertQueueItem( + int? index, + _i7.MediaItem? mediaItem, + ) => + (super.noSuchMethod( + Invocation.method( + #insertQueueItem, + [ + index, + mediaItem, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future updateQueue(List<_i7.MediaItem>? queue) => + (super.noSuchMethod( + Invocation.method( + #updateQueue, + [queue], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future updateMediaItem(_i7.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #updateMediaItem, + [mediaItem], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future removeQueueItem(_i7.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #removeQueueItem, + [mediaItem], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future fastForward() => (super.noSuchMethod( + Invocation.method( + #fastForward, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future rewind() => (super.noSuchMethod( + Invocation.method( + #rewind, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future skipToQueueItem(int? index) => (super.noSuchMethod( + Invocation.method( + #skipToQueueItem, + [index], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future setRating( + _i7.Rating? rating, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #setRating, + [ + rating, + extras, + ], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future setCaptioningEnabled(bool? enabled) => (super.noSuchMethod( + Invocation.method( + #setCaptioningEnabled, + [enabled], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future setShuffleMode(_i7.AudioServiceShuffleMode? shuffleMode) => + (super.noSuchMethod( + Invocation.method( + #setShuffleMode, + [shuffleMode], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future seekBackward(bool? begin) => (super.noSuchMethod( + Invocation.method( + #seekBackward, + [begin], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future seekForward(bool? begin) => (super.noSuchMethod( + Invocation.method( + #seekForward, + [begin], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future setSpeed(double? speed) => (super.noSuchMethod( + Invocation.method( + #setSpeed, + [speed], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future customAction( + String? name, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #customAction, + [ + name, + extras, + ], + ), + returnValue: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future onTaskRemoved() => (super.noSuchMethod( + Invocation.method( + #onTaskRemoved, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future onNotificationDeleted() => (super.noSuchMethod( + Invocation.method( + #onNotificationDeleted, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future> getChildren( + String? parentMediaId, [ + Map? options, + ]) => + (super.noSuchMethod( + Invocation.method( + #getChildren, + [ + parentMediaId, + options, + ], + ), + returnValue: _i8.Future>.value(<_i7.MediaItem>[]), + ) as _i8.Future>); + + @override + _i4.ValueStream> subscribeToChildren( + String? parentMediaId) => + (super.noSuchMethod( + Invocation.method( + #subscribeToChildren, + [parentMediaId], + ), + returnValue: _FakeValueStream_6>( + this, + Invocation.method( + #subscribeToChildren, + [parentMediaId], + ), + ), + ) as _i4.ValueStream>); + + @override + _i8.Future<_i7.MediaItem?> getMediaItem(String? mediaId) => + (super.noSuchMethod( + Invocation.method( + #getMediaItem, + [mediaId], + ), + returnValue: _i8.Future<_i7.MediaItem?>.value(), + ) as _i8.Future<_i7.MediaItem?>); + + @override + _i8.Future> search( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #search, + [ + query, + extras, + ], + ), + returnValue: _i8.Future>.value(<_i7.MediaItem>[]), + ) as _i8.Future>); + + @override + _i8.Future androidAdjustRemoteVolume( + _i7.AndroidVolumeDirection? direction) => + (super.noSuchMethod( + Invocation.method( + #androidAdjustRemoteVolume, + [direction], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future androidSetRemoteVolume(int? volumeIndex) => + (super.noSuchMethod( + Invocation.method( + #androidSetRemoteVolume, + [volumeIndex], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); +} + +/// A class which mocks [AuthProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider { + MockAuthProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.User get authUser => (super.noSuchMethod( + Invocation.getter(#authUser), + returnValue: _FakeUser_7( + this, + Invocation.getter(#authUser), + ), + ) as _i5.User); + + @override + _i8.Future<_i2.TwoFactorChallenge?> login({ + required String? host, + required String? email, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method( + #login, + [], + { + #host: host, + #email: email, + #password: password, + }, + ), + returnValue: _i8.Future<_i2.TwoFactorChallenge?>.value(), + ) as _i8.Future<_i2.TwoFactorChallenge?>); + + @override + _i8.Future completeTwoFactorChallenge({ + required String? loginToken, + required String? code, + }) => + (super.noSuchMethod( + Invocation.method( + #completeTwoFactorChallenge, + [], + { + #loginToken: loginToken, + #code: code, + }, + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future loginWithOneTimeToken({ + required String? host, + required String? token, + }) => + (super.noSuchMethod( + Invocation.method( + #loginWithOneTimeToken, + [], + { + #host: host, + #token: token, + }, + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + void setAuthUser(_i5.User? user) => super.noSuchMethod( + Invocation.method( + #setAuthUser, + [user], + ), + returnValueForMissingStub: null, + ); + + @override + _i8.Future<_i5.User?> tryGetAuthUser() => (super.noSuchMethod( + Invocation.method( + #tryGetAuthUser, + [], + ), + returnValue: _i8.Future<_i5.User?>.value(), + ) as _i8.Future<_i5.User?>); + + @override + _i8.Future<_i5.User> refreshAuthUser() => (super.noSuchMethod( + Invocation.method( + #refreshAuthUser, + [], + ), + returnValue: _i8.Future<_i5.User>.value(_FakeUser_7( + this, + Invocation.method( + #refreshAuthUser, + [], + ), + )), + ) as _i8.Future<_i5.User>); + + @override + _i8.Future logout() => (super.noSuchMethod( + Invocation.method( + #logout, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i8.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [DownloadProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDownloadProvider extends _i1.Mock implements _i2.DownloadProvider { + MockDownloadProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i5.Playable> get playables => (super.noSuchMethod( + Invocation.getter(#playables), + returnValue: <_i5.Playable>[], + ) as List<_i5.Playable>); + + @override + _i8.Stream get downloadsClearedStream => (super.noSuchMethod( + Invocation.getter(#downloadsClearedStream), + returnValue: _i8.Stream.empty(), + ) as _i8.Stream); + + @override + _i8.Stream<_i5.Playable> get downloadRemovedStream => + (super.noSuchMethod( + Invocation.getter(#downloadRemovedStream), + returnValue: _i8.Stream<_i5.Playable>.empty(), + ) as _i8.Stream<_i5.Playable>); + + @override + _i8.Stream<_i2.Download> get playableDownloadedStream => (super.noSuchMethod( + Invocation.getter(#playableDownloadedStream), + returnValue: _i8.Stream<_i2.Download>.empty(), + ) as _i8.Stream<_i2.Download>); + + @override + _i8.Future get downloadsDir => (super.noSuchMethod( + Invocation.getter(#downloadsDir), + returnValue: _i8.Future.value(_i9.dummyValue( + this, + Invocation.getter(#downloadsDir), + )), + ) as _i8.Future); + + @override + _i8.Future download({required _i5.Playable? playable}) => + (super.noSuchMethod( + Invocation.method( + #download, + [], + {#playable: playable}, + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i2.Download? getForPlayable(_i5.Playable? playable) => + (super.noSuchMethod(Invocation.method( + #getForPlayable, + [playable], + )) as _i2.Download?); + + @override + bool has({required _i5.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(_i5.Playable? playable) => + super.noSuchMethod( + Invocation.method( + #persistMetadataIfNeeded, + [playable], + ), + returnValueForMissingStub: null, + ); + + @override + _i8.Future removeForPlayable(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #removeForPlayable, + [playable], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + _i8.Future clear() => (super.noSuchMethod( + Invocation.method( + #clear, + [], + ), + returnValue: _i8.Future.value(), + returnValueForMissingStub: _i8.Future.value(), + ) as _i8.Future); + + @override + void unsubscribeAll() => super.noSuchMethod( + Invocation.method( + #unsubscribeAll, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void subscribe(_i8.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/ui/screens/two_factor_challenge_test.mocks.dart b/test/ui/screens/two_factor_challenge_test.mocks.dart index b12027f6..b87f07f1 100644 --- a/test/ui/screens/two_factor_challenge_test.mocks.dart +++ b/test/ui/screens/two_factor_challenge_test.mocks.dart @@ -123,6 +123,21 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { returnValue: _i4.Future<_i2.User?>.value(), ) as _i4.Future<_i2.User?>); + @override + _i4.Future<_i2.User> refreshAuthUser() => (super.noSuchMethod( + Invocation.method( + #refreshAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User>.value(_FakeUser_0( + this, + Invocation.method( + #refreshAuthUser, + [], + ), + )), + ) as _i4.Future<_i2.User>); + @override _i4.Future logout() => (super.noSuchMethod( Invocation.method( diff --git a/test/ui/widgets/oops_box_test.mocks.dart b/test/ui/widgets/oops_box_test.mocks.dart index b18733bc..5ea6c7aa 100644 --- a/test/ui/widgets/oops_box_test.mocks.dart +++ b/test/ui/widgets/oops_box_test.mocks.dart @@ -123,6 +123,21 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider { returnValue: _i4.Future<_i2.User?>.value(), ) as _i4.Future<_i2.User?>); + @override + _i4.Future<_i2.User> refreshAuthUser() => (super.noSuchMethod( + Invocation.method( + #refreshAuthUser, + [], + ), + returnValue: _i4.Future<_i2.User>.value(_FakeUser_0( + this, + Invocation.method( + #refreshAuthUser, + [], + ), + )), + ) as _i4.Future<_i2.User>); + @override _i4.Future logout() => (super.noSuchMethod( Invocation.method(