diff --git a/lib/models/podcast.dart b/lib/models/podcast.dart index ee2d7d3d..9f899ec5 100644 --- a/lib/models/podcast.dart +++ b/lib/models/podcast.dart @@ -16,6 +16,10 @@ class Podcast { final String lastPlayedAt; final PodcastState state; + /// Whether the current user has favorited this podcast. Mutable so + /// the optimistic favorite toggle can flip it in place. + bool favorite; + ImageProvider? _image; Podcast({ @@ -29,6 +33,7 @@ class Podcast { required this.subscribedAt, required this.lastPlayedAt, required this.state, + this.favorite = false, }); ImageProvider get image { @@ -40,6 +45,8 @@ class Podcast { String? id, String? title, String? author, + bool favorite = false, + PodcastState? state, }) { final faker = Faker(); return Podcast( @@ -52,7 +59,8 @@ class Podcast { imageUrl: faker.image.loremPicsum(width: 192, height: 192), subscribedAt: '2026-01-01T00:00:00Z', lastPlayedAt: '2026-01-01T00:00:00Z', - state: PodcastState(progresses: {}), + state: state ?? PodcastState(progresses: {}), + favorite: favorite, ); } @@ -68,6 +76,7 @@ class Podcast { subscribedAt: json['subscribed_at'], lastPlayedAt: json['last_played_at'], state: PodcastState.fromJson(json['state']), + favorite: json['favorite'] == true, ); } diff --git a/lib/providers/playable_provider.dart b/lib/providers/playable_provider.dart index b038d715..5dbd0805 100644 --- a/lib/providers/playable_provider.dart +++ b/lib/providers/playable_provider.dart @@ -134,10 +134,17 @@ class PlayableProvider with ChangeNotifier, StreamSubscriber { }) async { if (forceRefresh) AppState.delete(['podcast.episodes', podcastId]); - return _stateAwareFetch( + final episodes = await _stateAwareFetch( 'podcasts/$podcastId/episodes${getUpdates ? '?refresh=1' : ''}', ['podcast.episodes', podcastId], ); + + // A forced refresh repopulates the cache with fresh data — let any + // screen rendering the episode list (e.g. PodcastDetailsScreen) + // know it should rebuild. + if (forceRefresh) notifyListeners(); + + return episodes; } Future> _stateAwareFetch(String url, Object cacheKey) async { diff --git a/lib/providers/podcast_provider.dart b/lib/providers/podcast_provider.dart index 43a7e2f0..09c47a9e 100644 --- a/lib/providers/podcast_provider.dart +++ b/lib/providers/podcast_provider.dart @@ -36,6 +36,23 @@ class PodcastProvider with ChangeNotifier, StreamSubscriber { return fetchAll(); } + Future toggleFavorite(Podcast podcast) async { + // Optimistic flip + restore on failure. + podcast.favorite = !podcast.favorite; + notifyListeners(); + + try { + await post('favorites/toggle', data: { + 'type': 'podcast', + 'id': podcast.id, + }); + } catch (_) { + podcast.favorite = !podcast.favorite; + notifyListeners(); + rethrow; + } + } + Future unsubscribePodcast(Podcast podcast) async { // Optimistic removal so a Dismissible's onDismissed callback can // call this without leaving the dismissed widget in the tree diff --git a/lib/ui/screens/podcast_action_sheet.dart b/lib/ui/screens/podcast_action_sheet.dart new file mode 100644 index 00000000..90fdee3a --- /dev/null +++ b/lib/ui/screens/podcast_action_sheet.dart @@ -0,0 +1,315 @@ +import 'package:app/main.dart'; +import 'package:app/models/models.dart'; +import 'package:app/providers/providers.dart'; +import 'package:app/ui/screens/playable_action_sheet.dart'; +import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/features.dart'; +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PodcastActionSheet extends StatefulWidget { + final Podcast podcast; + + const PodcastActionSheet({Key? key, required this.podcast}) : super(key: key); + + @override + State createState() => _PodcastActionSheetState(); +} + +class _PodcastActionSheetState extends State { + var _refreshing = false; + + Future> _fetchEpisodes({bool getUpdates = false}) { + return context.read().fetchForPodcast( + widget.podcast.id, + forceRefresh: getUpdates, + getUpdates: getUpdates, + ); + } + + Future _refresh() async { + // Capture a stable context up-front so the toast can surface even + // if the user dismisses the sheet via swipe-down while the network + // call is in flight (the sheet context would be defunct by then). + final rootContext = Navigator.of(context, rootNavigator: true).context; + + setState(() => _refreshing = true); + + bool succeeded; + try { + await _fetchEpisodes(getUpdates: true); + succeeded = true; + } catch (_) { + succeeded = false; + } + + // While refreshing, every other row is disabled, so the only way + // the sheet can vanish is the user swipe-dismissing it — in which + // case `mounted` flips to false and we just skip the auto-pop. + if (mounted) { + setState(() => _refreshing = false); + Navigator.pop(context); + } + + showOverlay( + rootContext, + icon: succeeded + ? CupertinoIcons.arrow_clockwise + : CupertinoIcons.exclamationmark_triangle, + caption: succeeded ? 'Feed refreshed' : 'Refresh failed', + ); + } + + @override + Widget build(BuildContext context) { + final podcast = widget.podcast; + final podcastProvider = context.read(); + // Favoriting non-song entities only landed in koel 7.11.0. + final showFavorite = Feature.favoriteEntities.isSupported(); + // Continue if there's a known last-played episode for this podcast, + // otherwise start from the top. + final hasInProgress = podcast.state.currentEpisodeId != null; + final playLabel = hasInProgress ? 'Continue' : 'Play All'; + + return FrostedGlassBackground( + sigma: 40.0, + child: Container( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox.shrink(), + Column( + children: [ + ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 24, + cornerSmoothing: .8, + ), + child: Image( + image: podcast.image, + width: 192, + height: 192, + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + podcast.title, + textAlign: TextAlign.center, + softWrap: true, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + podcast.author, + textAlign: TextAlign.center, + softWrap: true, + style: const TextStyle(color: Colors.white54), + ), + ), + if (podcast.description.isNotEmpty) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Text( + podcast.description, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white38, + fontSize: 13, + height: 1.35, + ), + ), + ), + ], + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: IntrinsicHeight( + child: Row( + children: [ + if (showFavorite) ...[ + PlayableQuickAction( + label: podcast.favorite + ? 'Undo Favorite' + : 'Favorite', + icon: Icon(podcast.favorite + ? CupertinoIcons.star_fill + : CupertinoIcons.star), + enabled: !_refreshing, + onTap: () { + Navigator.pop(context); + // toggleFavorite rethrows on failure (after + // rolling back the optimistic flip + // internally). The sheet has just been + // popped, so swallow here to avoid an + // unhandled async error — the UI auto- + // corrects from the rollback's + // notifyListeners. + podcastProvider + .toggleFavorite(podcast) + .catchError((_) {}); + }, + ), + const PlayableQuickActionDivider(), + ], + PlayableQuickAction( + label: playLabel, + icon: const Icon(CupertinoIcons.play_fill), + enabled: !_refreshing, + onTap: () async { + Navigator.pop(context); + final List episodes; + try { + episodes = await _fetchEpisodes(); + } catch (_) { + // The sheet is gone; nowhere to surface + // an error. Swallow to avoid an unhandled + // async exception. + return; + } + if (episodes.isEmpty) return; + + final currentId = podcast.state.currentEpisodeId; + final current = currentId == null + ? null + : episodes.cast().firstWhere( + (e) => e?.id == currentId, + orElse: () => null, + ); + + if (current == null) { + await audioHandler.replaceQueue(episodes); + } else { + // Queue all episodes silently, then jump to + // the in-progress one. maybeQueueAndPlay + // restores its saved playback position. + await audioHandler.replaceQueue( + episodes, + autoPlay: false, + ); + await audioHandler.maybeQueueAndPlay(current); + } + }, + ), + const PlayableQuickActionDivider(), + PlayableQuickAction( + label: 'Shuffle', + icon: const Icon(CupertinoIcons.shuffle), + enabled: !_refreshing, + onTap: () async { + Navigator.pop(context); + final List episodes; + try { + episodes = await _fetchEpisodes(); + } catch (_) { + return; + } + if (episodes.isEmpty) return; + await audioHandler.replaceQueue( + episodes, + shuffle: true, + ); + }, + ), + ], + ), + ), + ), + const Divider(indent: 16, endIndent: 16), + ListView( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + children: [ + ListTile( + leading: SizedBox( + width: 22, + height: 22, + child: _refreshing + ? const CupertinoActivityIndicator( + color: Colors.white54, + ) + : const Icon( + CupertinoIcons.arrow_clockwise, + color: Colors.white30, + ), + ), + minLeadingWidth: 16, + title: Text( + _refreshing ? 'Refreshing…' : 'Refresh', + style: _refreshing + ? const TextStyle(color: Colors.white54) + : null, + ), + onTap: _refreshing ? null : _refresh, + ), + const Divider(indent: 16, endIndent: 16), + PlayableActionButton( + text: 'Unsubscribe', + destructive: true, + enabled: !_refreshing, + icon: const Icon(CupertinoIcons.minus_circle), + onTap: () async { + if (!await confirmUnsubscribePodcast( + context, + podcast: podcast, + )) { + return; + } + if (!context.mounted) return; + // Capture before pop so the success/error toast + // surfaces even if the sheet (or the route + // underneath) is gone by the time the DELETE + // resolves. + final rootContext = + Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + await unsubscribePodcastWithFeedback( + rootContext, + podcast: podcast, + ); + }, + hideSheetOnTap: false, + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +Future showPodcastActionSheet( + BuildContext context, { + required Podcast podcast, +}) { + return showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (_) => PodcastActionSheet(podcast: podcast), + ); +} diff --git a/lib/ui/screens/podcast_details.dart b/lib/ui/screens/podcast_details.dart index 8ddcec37..74b88622 100644 --- a/lib/ui/screens/podcast_details.dart +++ b/lib/ui/screens/podcast_details.dart @@ -55,9 +55,14 @@ class _PodcastDetailsScreen extends State { return Scaffold( body: GradientDecoratedContainer( - child: FutureBuilder( - future: buildRequest(podcastId), - builder: (_, AsyncSnapshot> snapshot) { + // Listen on PlayableProvider so a forced refresh from + // elsewhere (e.g. the podcast action sheet's Refresh row) + // triggers a rebuild — the new buildRequest call hits the + // freshly-repopulated cache and shows the updated episodes. + child: Consumer( + builder: (_, __, ___) => FutureBuilder( + future: buildRequest(podcastId), + builder: (_, AsyncSnapshot> snapshot) { if (!snapshot.hasData || snapshot.connectionState == ConnectionState.active) return const PlayableListScreenPlaceholder(); @@ -127,7 +132,8 @@ class _PodcastDetailsScreen extends State { ], ), ); - }, + }, + ), ), ), ); diff --git a/lib/ui/screens/podcasts.dart b/lib/ui/screens/podcasts.dart index cf92c417..8a6a28fa 100644 --- a/lib/ui/screens/podcasts.dart +++ b/lib/ui/screens/podcasts.dart @@ -6,6 +6,7 @@ import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; import 'package:app/router.dart'; import 'package:app/ui/placeholders/placeholders.dart'; +import 'package:app/ui/screens/podcast_action_sheet.dart'; import 'package:app/ui/widgets/widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:lucide_icons/lucide_icons.dart'; @@ -147,36 +148,22 @@ class _PodcastScreenState extends State { } -class PodcastRow extends StatefulWidget { +class PodcastRow extends StatelessWidget { final Podcast podcast; final AppRouter router; const PodcastRow({Key? key, required this.podcast, required this.router}) : super(key: key); - @override - State createState() => _PodcastRowState(); -} - -class _PodcastRowState extends State { - Offset? _lastTapPosition; - @override Widget build(BuildContext context) { - final podcast = widget.podcast; - return Card( child: InkWell( - onTap: () => widget.router.gotoPodcastDetailsScreen( + onTap: () => router.gotoPodcastDetailsScreen( context, podcastId: podcast.id, ), - onTapDown: (details) => _lastTapPosition = details.globalPosition, - onLongPress: () => showPodcastActionsMenu( - context, - podcast: podcast, - position: _lastTapPosition ?? Offset.zero, - ), + onLongPress: () => showPodcastActionSheet(context, podcast: podcast), child: ListTile( shape: Border(bottom: Divider.createBorderSide(context)), leading: AlbumArtistThumbnail.sm(entity: podcast, asHero: true), diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 628c8047..97f889a8 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -28,6 +28,7 @@ export 'now_playing.dart'; export 'playable_action_sheet.dart'; export 'playlist_details.dart'; export 'playlists.dart'; +export 'podcast_action_sheet.dart'; export 'podcast_details.dart'; export 'podcasts.dart'; export 'radio_now_playing.dart'; diff --git a/lib/ui/widgets/podcast_actions_menu.dart b/lib/ui/widgets/podcast_actions.dart similarity index 64% rename from lib/ui/widgets/podcast_actions_menu.dart rename to lib/ui/widgets/podcast_actions.dart index e8e0c541..0a0ee968 100644 --- a/lib/ui/widgets/podcast_actions_menu.dart +++ b/lib/ui/widgets/podcast_actions.dart @@ -2,42 +2,8 @@ import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/widgets/widgets.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -/// Shows the long-press context menu for a podcast — currently only -/// Unsubscribe. Unlike the other resource menus, this one isn't gated -/// on a server permission: a user can always unsubscribe from a -/// podcast they're subscribed to. -Future showPodcastActionsMenu( - BuildContext context, { - required Podcast podcast, - required Offset position, -}) async { - HapticFeedback.mediumImpact(); - - final selected = await showFrostedContextMenu( - context: context, - position: position, - items: const [ - FrostedMenuItem( - value: 'unsubscribe', - icon: CupertinoIcons.minus_circle, - label: 'Unsubscribe', - destructive: true, - ), - ], - ); - - if (!context.mounted) return; - - if (selected == 'unsubscribe') { - if (!await confirmUnsubscribePodcast(context, podcast: podcast)) return; - if (!context.mounted) return; - await unsubscribePodcastWithFeedback(context, podcast: podcast); - } -} - /// Asks the user to confirm unsubscribing from [podcast]. Returns `true` /// on confirm, `false` on cancel. Does not call any network-backed code. Future confirmUnsubscribePodcast( diff --git a/lib/ui/widgets/podcast_card.dart b/lib/ui/widgets/podcast_card.dart index d10a16bd..8a814acf 100644 --- a/lib/ui/widgets/podcast_card.dart +++ b/lib/ui/widgets/podcast_card.dart @@ -1,5 +1,6 @@ import 'package:app/models/models.dart'; import 'package:app/router.dart'; +import 'package:app/ui/screens/podcast_action_sheet.dart'; import 'package:app/ui/widgets/widgets.dart'; import 'package:flutter/material.dart'; @@ -20,26 +21,19 @@ class PodcastCard extends StatefulWidget { class _PodcastCardState extends State { var _opacity = 1.0; final _cardWidth = 144.0; - Offset? _lastTapPosition; @override Widget build(BuildContext context) { return GestureDetector( - onTapDown: (details) { - _lastTapPosition = details.globalPosition; - setState(() => _opacity = 0.4); - }, + onTapDown: (_) => setState(() => _opacity = 0.4), onTapUp: (_) => setState(() => _opacity = 1.0), onTapCancel: () => setState(() => _opacity = 1.0), onTap: () => widget.router.gotoPodcastDetailsScreen( context, podcastId: widget.podcast.id, ), - onLongPress: () => showPodcastActionsMenu( - context, - podcast: widget.podcast, - position: _lastTapPosition ?? Offset.zero, - ), + onLongPress: () => + showPodcastActionSheet(context, podcast: widget.podcast), behavior: HitTestBehavior.opaque, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), diff --git a/lib/ui/widgets/widgets.dart b/lib/ui/widgets/widgets.dart index 82ad520a..b5d1f1d8 100644 --- a/lib/ui/widgets/widgets.dart +++ b/lib/ui/widgets/widgets.dart @@ -26,7 +26,7 @@ export 'playable_list_sort_button.dart'; export 'playable_row.dart'; export 'playable_thumbnail.dart'; export 'playlist_row.dart'; -export 'podcast_actions_menu.dart'; +export 'podcast_actions.dart'; export 'podcast_card.dart'; export 'profile_avatar.dart'; export 'pull_to_refresh.dart'; diff --git a/test/models/podcast_test.dart b/test/models/podcast_test.dart new file mode 100644 index 00000000..026910a5 --- /dev/null +++ b/test/models/podcast_test.dart @@ -0,0 +1,60 @@ +import 'package:app/models/podcast.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Map baseJson() => { + 'id': 'p1', + 'title': 'A Podcast', + 'url': 'https://example.com/feed.xml', + 'link': 'https://example.com', + 'description': 'A description', + 'author': 'Author', + 'image': 'https://example.com/image.jpg', + 'subscribed_at': '2026-01-01T00:00:00Z', + 'last_played_at': '2026-01-02T00:00:00Z', + 'state': {'progresses': {}}, + }; + + group('Podcast.fromJson', () { + test('parses all fields', () { + final podcast = Podcast.fromJson(baseJson()); + + expect(podcast.id, 'p1'); + expect(podcast.title, 'A Podcast'); + expect(podcast.author, 'Author'); + expect(podcast.imageUrl, 'https://example.com/image.jpg'); + expect(podcast.subscribedAt, '2026-01-01T00:00:00Z'); + expect(podcast.lastPlayedAt, '2026-01-02T00:00:00Z'); + }); + + test('parses favorite from JSON', () { + final json = baseJson()..['favorite'] = true; + expect(Podcast.fromJson(json).favorite, isTrue); + }); + + test('defaults favorite to false when missing or non-bool', () { + expect(Podcast.fromJson(baseJson()).favorite, isFalse); + for (final value in [null, 0, 1, 'true', 'false']) { + final json = baseJson()..['favorite'] = value; + expect( + Podcast.fromJson(json).favorite, + isFalse, + reason: 'favorite should be false for $value', + ); + } + }); + }); + + group('Podcast.fake', () { + test('generates a valid podcast', () { + final podcast = Podcast.fake(); + expect(podcast.id, isNotEmpty); + expect(podcast.title, isNotEmpty); + expect(podcast.favorite, isFalse); + }); + + test('respects custom favorite flag', () { + expect(Podcast.fake(favorite: true).favorite, isTrue); + }); + }); +} diff --git a/test/ui/screens/podcast_action_sheet_test.dart b/test/ui/screens/podcast_action_sheet_test.dart new file mode 100644 index 00000000..0ddda316 --- /dev/null +++ b/test/ui/screens/podcast_action_sheet_test.dart @@ -0,0 +1,525 @@ +import 'dart:async'; + +import 'package:app/app_state.dart'; +import 'package:app/audio_handler.dart'; +import 'package:app/main.dart' as app; +import 'package:app/models/podcast.dart'; +import 'package:app/models/song.dart'; +import 'package:app/providers/playable_provider.dart'; +import 'package:app/providers/podcast_provider.dart'; +import 'package:app/ui/screens/podcast_action_sheet.dart'; +import 'package:audio_service/audio_service.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 'package:rxdart/rxdart.dart'; +import 'package:version/version.dart'; + +import '../../extensions/widget_tester_extension.dart'; +import 'podcast_action_sheet_test.mocks.dart'; + +@GenerateMocks([KoelAudioHandler, PodcastProvider, PlayableProvider]) +void main() { + late MockKoelAudioHandler audioHandlerMock; + late MockPodcastProvider podcastProviderMock; + late MockPlayableProvider playableProviderMock; + late BehaviorSubject mediaItemSubject; + + setUp(() { + AppState.clear(); + AppState.set(['app', 'apiVersion'], Version.parse('7.11.0')); + + audioHandlerMock = MockKoelAudioHandler(); + podcastProviderMock = MockPodcastProvider(); + playableProviderMock = MockPlayableProvider(); + + mediaItemSubject = BehaviorSubject.seeded(null); + when(audioHandlerMock.mediaItem).thenAnswer((_) => mediaItemSubject); + when(audioHandlerMock.queued(any)).thenAnswer((_) async => false); + when(audioHandlerMock.queueAfterCurrent(any)).thenAnswer((_) async {}); + when(audioHandlerMock.queueToBottom(any)).thenAnswer((_) async {}); + when(audioHandlerMock.replaceQueue( + any, + shuffle: anyNamed('shuffle'), + autoPlay: anyNamed('autoPlay'), + )).thenAnswer((_) async {}); + when(audioHandlerMock.maybeQueueAndPlay(any)) + .thenAnswer((_) async {}); + + app.audioHandler = audioHandlerMock; + }); + + tearDown(() { + mediaItemSubject.close(); + }); + + Future mount(WidgetTester tester, Podcast podcast) async { + // Mirror production wiring: + // - MultiProvider sits ABOVE MaterialApp so contexts captured via + // `Navigator.of(rootNavigator: true)` can find the providers. + // - The sheet is pushed via `showModalBottomSheet` rather than + // rendered as `home`, so `Navigator.pop` actually pops the + // modal route and the suite exercises the real route boundary + // that the post-pop toast logic depends on. + await tester.binding.setSurfaceSize(const Size(375, 812)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: podcastProviderMock, + ), + ChangeNotifierProvider.value( + value: playableProviderMock, + ), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Material( + child: TextButton( + onPressed: () => showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (_) => PodcastActionSheet(podcast: podcast), + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + } + + group('structure', () { + testWidgets('renders title and author', (tester) async { + await mount( + tester, + Podcast.fake(title: 'My Show', author: 'Jane Doe'), + ); + + expect(find.text('My Show'), findsOneWidget); + expect(find.text('Jane Doe'), findsOneWidget); + }); + + testWidgets('renders description, capped at 3 lines with ellipsis', + (tester) async { + // A long description so we can assert truncation actually applies. + const description = + 'A long-running show about coffee, code, and everything in ' + 'between. Each episode dives into a different topic from the ' + 'industry, with guests, interviews, and stories from listeners. ' + 'The hosts have been at it for years and have hundreds of ' + 'episodes to show for it, covering every corner of the field.'; + final podcast = Podcast.fake(title: 'My Show'); + // description is final on the model; build a podcast with it via + // the named ctor below. + final p = Podcast( + id: podcast.id, + title: podcast.title, + url: podcast.url, + link: podcast.link, + description: description, + author: podcast.author, + imageUrl: podcast.imageUrl, + subscribedAt: podcast.subscribedAt, + lastPlayedAt: podcast.lastPlayedAt, + state: podcast.state, + ); + + await mount(tester, p); + + final descFinder = find.text(description); + expect(descFinder, findsOneWidget); + final widget = tester.widget(descFinder); + expect(widget.maxLines, 3); + expect(widget.overflow, TextOverflow.ellipsis); + }); + + testWidgets('omits description when empty', (tester) async { + final base = Podcast.fake(); + final p = Podcast( + id: base.id, + title: base.title, + url: base.url, + link: base.link, + description: '', + author: base.author, + imageUrl: base.imageUrl, + subscribedAt: base.subscribedAt, + lastPlayedAt: base.lastPlayedAt, + state: base.state, + ); + + await mount(tester, p); + + // No empty Text widget gets rendered for the description slot. + // (Title + author + the action labels are still present.) + expect(find.text(''), findsNothing); + }); + + testWidgets( + 'shows "Play All" when there is no current episode', + (tester) async { + await mount(tester, Podcast.fake(title: 'A')); + + expect(find.text('Play All'), findsOneWidget); + expect(find.text('Continue'), findsNothing); + }, + ); + + testWidgets( + 'shows "Continue" when there is a current episode', + (tester) async { + final podcast = Podcast.fake( + title: 'A', + state: PodcastState( + currentEpisodeId: 'ep-mid', + progresses: {'ep-mid': 120}, + ), + ); + await mount(tester, podcast); + + expect(find.text('Continue'), findsOneWidget); + expect(find.text('Play All'), findsNothing); + }, + ); + + testWidgets('renders Favorite + Shuffle in the quick row', (tester) async { + await mount(tester, Podcast.fake()); + + expect(find.text('Favorite'), findsOneWidget); + expect(find.text('Shuffle'), findsOneWidget); + }); + + testWidgets('shows "Undo Favorite" when podcast.favorite is true', + (tester) async { + await mount(tester, Podcast.fake(favorite: true)); + + expect(find.text('Undo Favorite'), findsOneWidget); + expect(find.text('Favorite'), findsNothing); + }); + + testWidgets( + 'hides Favorite when koel version is below 7.11.0', + (tester) async { + AppState.set(['app', 'apiVersion'], Version.parse('7.10.0')); + + await mount(tester, Podcast.fake()); + + expect(find.text('Favorite'), findsNothing); + expect(find.text('Undo Favorite'), findsNothing); + // Play All / Shuffle stay. + expect(find.text('Play All'), findsOneWidget); + expect(find.text('Shuffle'), findsOneWidget); + }, + ); + + testWidgets('renders Refresh and Unsubscribe rows', (tester) async { + await mount(tester, Podcast.fake()); + + expect(find.text('Refresh'), findsOneWidget); + expect(find.text('Unsubscribe'), findsOneWidget); + }); + }); + + group('actions', () { + testWidgets( + 'tapping Favorite delegates to PodcastProvider.toggleFavorite', + (tester) async { + final podcast = Podcast.fake(); + when(podcastProviderMock.toggleFavorite(podcast)) + .thenAnswer((_) async {}); + + await mount(tester, podcast); + await tester.tap(find.text('Favorite')); + await tester.pump(); + + verify(podcastProviderMock.toggleFavorite(podcast)).called(1); + }, + ); + + testWidgets( + 'tapping Play All replaces the queue without shuffling', + (tester) async { + final podcast = Podcast.fake(); + final episodes = Song.fakeMany(3); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) async => episodes); + + await mount(tester, podcast); + await tester.tap(find.text('Play All')); + await tester.pumpAndSettle(); + + verify(audioHandlerMock.replaceQueue(episodes)).called(1); + verifyNever(audioHandlerMock.maybeQueueAndPlay(any)); + }, + ); + + testWidgets( + 'tapping Continue queues all episodes silently and resumes the current one', + (tester) async { + final podcast = Podcast.fake( + state: PodcastState( + currentEpisodeId: 'ep-mid', + progresses: {'ep-mid': 30}, + ), + ); + final ep1 = Song.fake(id: 'ep-1'); + final epMid = Song.fake(id: 'ep-mid'); + final ep3 = Song.fake(id: 'ep-3'); + final episodes = [ep1, epMid, ep3]; + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) async => episodes); + + await mount(tester, podcast); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + verify(audioHandlerMock.replaceQueue( + episodes, + autoPlay: false, + )).called(1); + verify(audioHandlerMock.maybeQueueAndPlay(epMid)).called(1); + }, + ); + + testWidgets( + 'tapping Continue falls back to Play All when the current episode is missing', + (tester) async { + final podcast = Podcast.fake( + state: PodcastState( + currentEpisodeId: 'gone', + progresses: {'gone': 30}, + ), + ); + final episodes = Song.fakeMany(2); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) async => episodes); + + await mount(tester, podcast); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + verify(audioHandlerMock.replaceQueue(episodes)).called(1); + verifyNever(audioHandlerMock.maybeQueueAndPlay(any)); + }, + ); + + testWidgets( + 'tapping Shuffle replaces the queue with shuffle', + (tester) async { + final podcast = Podcast.fake(); + final episodes = Song.fakeMany(3); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) async => episodes); + + await mount(tester, podcast); + await tester.tap(find.text('Shuffle')); + await tester.pumpAndSettle(); + + verify(audioHandlerMock.replaceQueue( + episodes, + shuffle: true, + )).called(1); + }, + ); + + testWidgets( + 'Refresh shows a spinner while in flight, then dismisses + toasts', + (tester) async { + final podcast = Podcast.fake(); + final completer = Completer>(); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) => completer.future); + + await mount(tester, podcast); + await tester.tap(find.text('Refresh')); + await tester.pump(); + + // While the fetch is pending the row replaces its label with + // "Refreshing…" and shows a spinner; the sheet stays open. + expect(find.text('Refresh'), findsNothing); + expect(find.text('Refreshing…'), findsOneWidget); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + + // Resolve. Sheet pops, success toast appears. + completer.complete([]); + await tester.pumpAndSettle(); + + verify(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: true, + getUpdates: true, + )).called(1); + expect(find.text('Refreshing…'), findsNothing); + expect(find.text('Feed refreshed'), findsOneWidget); + + // Drain showOverlay's auto-dismiss timer. + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets( + 'Refresh shows an error overlay when the fetch throws', + (tester) async { + final podcast = Podcast.fake(); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenThrow(Exception('boom')); + + await mount(tester, podcast); + await tester.tap(find.text('Refresh')); + await tester.pumpAndSettle(); + + expect(find.text('Refresh failed'), findsOneWidget); + expect(find.text('Feed refreshed'), findsNothing); + + // Drain showOverlay's auto-dismiss timer. + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets( + 'other rows are disabled while a refresh is in flight', + (tester) async { + final podcast = Podcast.fake(); + final completer = Completer>(); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) => completer.future); + + await mount(tester, podcast); + await tester.tap(find.text('Refresh')); + await tester.pump(); + + // While in flight, tapping the other actions should be a no-op + // (rows are visually dimmed and onTap is null). + await tester.tap(find.text('Favorite')); + await tester.tap(find.text('Play All')); + await tester.tap(find.text('Shuffle')); + await tester.tap(find.text('Unsubscribe')); + await tester.pump(); + + verifyNever(podcastProviderMock.toggleFavorite(any)); + verifyNever(audioHandlerMock.replaceQueue( + any, + shuffle: anyNamed('shuffle'), + autoPlay: anyNamed('autoPlay'), + )); + // Confirm dialog must NOT have opened. + expect(find.text('Unsubscribe?'), findsNothing); + + // Resolve so the test exits cleanly. + completer.complete([]); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets( + 'dismissing the sheet mid-refresh still surfaces the toast on completion', + (tester) async { + final podcast = Podcast.fake(); + final completer = Completer>(); + when(playableProviderMock.fetchForPodcast( + podcast.id, + forceRefresh: anyNamed('forceRefresh'), + getUpdates: anyNamed('getUpdates'), + )).thenAnswer((_) => completer.future); + + await mount(tester, podcast); + await tester.tap(find.text('Refresh')); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + + // Simulate the user swiping the sheet away while refresh is in + // flight: pop the sheet's route directly. + final sheetContext = tester.element(find.byType(PodcastActionSheet)); + Navigator.of(sheetContext).pop(); + await tester.pumpAndSettle(); + expect(find.byType(PodcastActionSheet), findsNothing); + + // Now resolve. Even though the sheet is gone, the toast still + // appears because we captured the root navigator's context + // before pop. + completer.complete([]); + await tester.pumpAndSettle(); + + expect(find.text('Feed refreshed'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets( + 'tapping Unsubscribe shows a confirm dialog and delegates on confirm', + (tester) async { + final podcast = Podcast.fake(title: 'Bye'); + when(podcastProviderMock.unsubscribePodcast(podcast)) + .thenAnswer((_) async {}); + + await mount(tester, podcast); + await tester.tap(find.text('Unsubscribe')); + await tester.pumpAndSettle(); + + // Confirm dialog is up. + expect(find.text('Unsubscribe?'), findsOneWidget); + + // Tap the dialog's Unsubscribe button. + await tester + .tap(find.widgetWithText(CupertinoDialogAction, 'Unsubscribe')); + await tester.pumpAndSettle(); + + verify(podcastProviderMock.unsubscribePodcast(podcast)).called(1); + expect(find.text('Unsubscribed'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + }, + ); + + testWidgets( + 'cancelling the Unsubscribe confirm leaves the podcast alone', + (tester) async { + final podcast = Podcast.fake(title: 'Stay'); + + await mount(tester, podcast); + await tester.tap(find.text('Unsubscribe')); + await tester.pumpAndSettle(); + + await tester + .tap(find.widgetWithText(CupertinoDialogAction, 'Cancel')); + await tester.pumpAndSettle(); + + verifyNever(podcastProviderMock.unsubscribePodcast(any)); + // Sheet is still open. + expect(find.byType(PodcastActionSheet), findsOneWidget); + }, + ); + }); +} diff --git a/test/ui/screens/podcast_action_sheet_test.mocks.dart b/test/ui/screens/podcast_action_sheet_test.mocks.dart new file mode 100644 index 00000000..48362cb4 --- /dev/null +++ b/test/ui/screens/podcast_action_sheet_test.mocks.dart @@ -0,0 +1,1427 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/podcast_action_sheet_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i9; +import 'dart:ui' as _i10; + +import 'package:app/audio_handler.dart' as _i7; +import 'package:app/enums.dart' as _i11; +import 'package:app/models/models.dart' as _i5; +import 'package:app/providers/providers.dart' as _i2; +import 'package:app/values/values.dart' as _i6; +import 'package:audio_service/audio_service.dart' as _i8; +import 'package:just_audio/just_audio.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +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 _FakeAudioPlayer_2 extends _i1.SmartFake implements _i3.AudioPlayer { + _FakeAudioPlayer_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeBehaviorSubject_3 extends _i1.SmartFake + implements _i4.BehaviorSubject { + _FakeBehaviorSubject_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePublishSubject_4 extends _i1.SmartFake + implements _i4.PublishSubject { + _FakePublishSubject_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeValueStream_5 extends _i1.SmartFake + implements _i4.ValueStream { + _FakeValueStream_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePodcast_6 extends _i1.SmartFake implements _i5.Podcast { + _FakePodcast_6( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakePaginationResult_7 extends _i1.SmartFake + implements _i6.PaginationResult { + _FakePaginationResult_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 _i7.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 + _i8.AudioServiceRepeatMode get repeatMode => (super.noSuchMethod( + Invocation.getter(#repeatMode), + returnValue: _i8.AudioServiceRepeatMode.none, + ) as _i8.AudioServiceRepeatMode); + + @override + _i3.AudioPlayer get player => (super.noSuchMethod( + Invocation.getter(#player), + returnValue: _FakeAudioPlayer_2( + 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(_i8.AudioServiceRepeatMode? _repeatMode) => super.noSuchMethod( + Invocation.setter( + #repeatMode, + _repeatMode, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.BehaviorSubject<_i8.PlaybackState> get playbackState => + (super.noSuchMethod( + Invocation.getter(#playbackState), + returnValue: _FakeBehaviorSubject_3<_i8.PlaybackState>( + this, + Invocation.getter(#playbackState), + ), + ) as _i4.BehaviorSubject<_i8.PlaybackState>); + + @override + _i4.BehaviorSubject> get queue => (super.noSuchMethod( + Invocation.getter(#queue), + returnValue: _FakeBehaviorSubject_3>( + this, + Invocation.getter(#queue), + ), + ) as _i4.BehaviorSubject>); + + @override + _i4.BehaviorSubject get queueTitle => (super.noSuchMethod( + Invocation.getter(#queueTitle), + returnValue: _FakeBehaviorSubject_3( + this, + Invocation.getter(#queueTitle), + ), + ) as _i4.BehaviorSubject); + + @override + _i4.BehaviorSubject<_i8.MediaItem?> get mediaItem => (super.noSuchMethod( + Invocation.getter(#mediaItem), + returnValue: _FakeBehaviorSubject_3<_i8.MediaItem?>( + this, + Invocation.getter(#mediaItem), + ), + ) as _i4.BehaviorSubject<_i8.MediaItem?>); + + @override + _i4.BehaviorSubject<_i8.AndroidPlaybackInfo> get androidPlaybackInfo => + (super.noSuchMethod( + Invocation.getter(#androidPlaybackInfo), + returnValue: _FakeBehaviorSubject_3<_i8.AndroidPlaybackInfo>( + this, + Invocation.getter(#androidPlaybackInfo), + ), + ) as _i4.BehaviorSubject<_i8.AndroidPlaybackInfo>); + + @override + _i4.BehaviorSubject<_i8.RatingStyle> get ratingStyle => (super.noSuchMethod( + Invocation.getter(#ratingStyle), + returnValue: _FakeBehaviorSubject_3<_i8.RatingStyle>( + this, + Invocation.getter(#ratingStyle), + ), + ) as _i4.BehaviorSubject<_i8.RatingStyle>); + + @override + _i4.PublishSubject get customEvent => (super.noSuchMethod( + Invocation.getter(#customEvent), + returnValue: _FakePublishSubject_4( + this, + Invocation.getter(#customEvent), + ), + ) as _i4.PublishSubject); + + @override + _i4.BehaviorSubject get customState => (super.noSuchMethod( + Invocation.getter(#customState), + returnValue: _FakeBehaviorSubject_3( + 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 _i8.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 + _i9.Future play() => (super.noSuchMethod( + Invocation.method( + #play, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future pause() => (super.noSuchMethod( + Invocation.method( + #pause, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future stop() => (super.noSuchMethod( + Invocation.method( + #stop, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future queueAndPlay(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueAndPlay, + [playable], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future maybeQueueAndPlay( + _i5.Playable? playable, { + dynamic position = 0, + }) => + (super.noSuchMethod( + Invocation.method( + #maybeQueueAndPlay, + [playable], + {#position: position}, + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future queueAfterCurrent(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueAfterCurrent, + [playable], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future playOrPause() => (super.noSuchMethod( + Invocation.method( + #playOrPause, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future skipToNext() => (super.noSuchMethod( + Invocation.method( + #skipToNext, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future seek(Duration? position) => (super.noSuchMethod( + Invocation.method( + #seek, + [position], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future skipToPrevious() => (super.noSuchMethod( + Invocation.method( + #skipToPrevious, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future queued(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queued, + [playable], + ), + returnValue: _i9.Future.value(false), + ) as _i9.Future); + + @override + _i9.Future removeQueueItemAt(int? index) => (super.noSuchMethod( + Invocation.method( + #removeQueueItemAt, + [index], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + void moveQueueItem( + int? oldIndex, + int? newIndex, + ) => + super.noSuchMethod( + Invocation.method( + #moveQueueItem, + [ + oldIndex, + newIndex, + ], + ), + returnValueForMissingStub: null, + ); + + @override + _i9.Future clearQueue() => (super.noSuchMethod( + Invocation.method( + #clearQueue, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future setVolume(double? value) => (super.noSuchMethod( + Invocation.method( + #setVolume, + [value], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future replaceQueue( + List<_i5.Playable>? playables, { + bool? shuffle = false, + bool? autoPlay = true, + }) => + (super.noSuchMethod( + Invocation.method( + #replaceQueue, + [playables], + { + #shuffle: shuffle, + #autoPlay: autoPlay, + }, + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future<_i8.AudioServiceRepeatMode> rotateRepeatMode() => + (super.noSuchMethod( + Invocation.method( + #rotateRepeatMode, + [], + ), + returnValue: _i9.Future<_i8.AudioServiceRepeatMode>.value( + _i8.AudioServiceRepeatMode.none), + ) as _i9.Future<_i8.AudioServiceRepeatMode>); + + @override + _i9.Future setRepeatMode(_i8.AudioServiceRepeatMode? repeatMode) => + (super.noSuchMethod( + Invocation.method( + #setRepeatMode, + [repeatMode], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future cleanUpUponLogout() => (super.noSuchMethod( + Invocation.method( + #cleanUpUponLogout, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future queueToBottom(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #queueToBottom, + [playable], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future removeFromQueue(_i5.Playable? playable) => + (super.noSuchMethod( + Invocation.method( + #removeFromQueue, + [playable], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future prepare() => (super.noSuchMethod( + Invocation.method( + #prepare, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future prepareFromMediaId( + String? mediaId, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromMediaId, + [ + mediaId, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future prepareFromSearch( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromSearch, + [ + query, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future prepareFromUri( + Uri? uri, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #prepareFromUri, + [ + uri, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future playFromMediaId( + String? mediaId, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromMediaId, + [ + mediaId, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future playFromSearch( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromSearch, + [ + query, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future playFromUri( + Uri? uri, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #playFromUri, + [ + uri, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future playMediaItem(_i8.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #playMediaItem, + [mediaItem], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future click([_i8.MediaButton? button = _i8.MediaButton.media]) => + (super.noSuchMethod( + Invocation.method( + #click, + [button], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future addQueueItem(_i8.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #addQueueItem, + [mediaItem], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future addQueueItems(List<_i8.MediaItem>? mediaItems) => + (super.noSuchMethod( + Invocation.method( + #addQueueItems, + [mediaItems], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future insertQueueItem( + int? index, + _i8.MediaItem? mediaItem, + ) => + (super.noSuchMethod( + Invocation.method( + #insertQueueItem, + [ + index, + mediaItem, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future updateQueue(List<_i8.MediaItem>? queue) => + (super.noSuchMethod( + Invocation.method( + #updateQueue, + [queue], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future updateMediaItem(_i8.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #updateMediaItem, + [mediaItem], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future removeQueueItem(_i8.MediaItem? mediaItem) => + (super.noSuchMethod( + Invocation.method( + #removeQueueItem, + [mediaItem], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future fastForward() => (super.noSuchMethod( + Invocation.method( + #fastForward, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future rewind() => (super.noSuchMethod( + Invocation.method( + #rewind, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future skipToQueueItem(int? index) => (super.noSuchMethod( + Invocation.method( + #skipToQueueItem, + [index], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future setRating( + _i8.Rating? rating, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #setRating, + [ + rating, + extras, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future setCaptioningEnabled(bool? enabled) => (super.noSuchMethod( + Invocation.method( + #setCaptioningEnabled, + [enabled], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future setShuffleMode(_i8.AudioServiceShuffleMode? shuffleMode) => + (super.noSuchMethod( + Invocation.method( + #setShuffleMode, + [shuffleMode], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future seekBackward(bool? begin) => (super.noSuchMethod( + Invocation.method( + #seekBackward, + [begin], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future seekForward(bool? begin) => (super.noSuchMethod( + Invocation.method( + #seekForward, + [begin], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future setSpeed(double? speed) => (super.noSuchMethod( + Invocation.method( + #setSpeed, + [speed], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future customAction( + String? name, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #customAction, + [ + name, + extras, + ], + ), + returnValue: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future onTaskRemoved() => (super.noSuchMethod( + Invocation.method( + #onTaskRemoved, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future onNotificationDeleted() => (super.noSuchMethod( + Invocation.method( + #onNotificationDeleted, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future> getChildren( + String? parentMediaId, [ + Map? options, + ]) => + (super.noSuchMethod( + Invocation.method( + #getChildren, + [ + parentMediaId, + options, + ], + ), + returnValue: _i9.Future>.value(<_i8.MediaItem>[]), + ) as _i9.Future>); + + @override + _i4.ValueStream> subscribeToChildren( + String? parentMediaId) => + (super.noSuchMethod( + Invocation.method( + #subscribeToChildren, + [parentMediaId], + ), + returnValue: _FakeValueStream_5>( + this, + Invocation.method( + #subscribeToChildren, + [parentMediaId], + ), + ), + ) as _i4.ValueStream>); + + @override + _i9.Future<_i8.MediaItem?> getMediaItem(String? mediaId) => + (super.noSuchMethod( + Invocation.method( + #getMediaItem, + [mediaId], + ), + returnValue: _i9.Future<_i8.MediaItem?>.value(), + ) as _i9.Future<_i8.MediaItem?>); + + @override + _i9.Future> search( + String? query, [ + Map? extras, + ]) => + (super.noSuchMethod( + Invocation.method( + #search, + [ + query, + extras, + ], + ), + returnValue: _i9.Future>.value(<_i8.MediaItem>[]), + ) as _i9.Future>); + + @override + _i9.Future androidAdjustRemoteVolume( + _i8.AndroidVolumeDirection? direction) => + (super.noSuchMethod( + Invocation.method( + #androidAdjustRemoteVolume, + [direction], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future androidSetRemoteVolume(int? volumeIndex) => + (super.noSuchMethod( + Invocation.method( + #androidSetRemoteVolume, + [volumeIndex], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); +} + +/// A class which mocks [PodcastProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPodcastProvider extends _i1.Mock implements _i2.PodcastProvider { + MockPodcastProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i5.Podcast> get podcasts => (super.noSuchMethod( + Invocation.getter(#podcasts), + returnValue: <_i5.Podcast>[], + ) as List<_i5.Podcast>); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + _i9.Future fetchAll() => (super.noSuchMethod( + Invocation.method( + #fetchAll, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future refresh() => (super.noSuchMethod( + Invocation.method( + #refresh, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future toggleFavorite(_i5.Podcast? podcast) => (super.noSuchMethod( + Invocation.method( + #toggleFavorite, + [podcast], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future unsubscribePodcast(_i5.Podcast? podcast) => + (super.noSuchMethod( + Invocation.method( + #unsubscribePodcast, + [podcast], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future<_i5.Podcast> add({required String? url}) => (super.noSuchMethod( + Invocation.method( + #add, + [], + {#url: url}, + ), + returnValue: _i9.Future<_i5.Podcast>.value(_FakePodcast_6( + this, + Invocation.method( + #add, + [], + {#url: url}, + ), + )), + ) as _i9.Future<_i5.Podcast>); + + @override + _i9.Future<_i5.Podcast> resolve( + String? id, { + bool? forceRefresh = false, + }) => + (super.noSuchMethod( + Invocation.method( + #resolve, + [id], + {#forceRefresh: forceRefresh}, + ), + returnValue: _i9.Future<_i5.Podcast>.value(_FakePodcast_6( + this, + Invocation.method( + #resolve, + [id], + {#forceRefresh: forceRefresh}, + ), + )), + ) as _i9.Future<_i5.Podcast>); + + @override + _i9.Future getEpisodeProgress(_i5.Episode? episode) => + (super.noSuchMethod( + Invocation.method( + #getEpisodeProgress, + [episode], + ), + returnValue: _i9.Future.value(0), + ) as _i9.Future); + + @override + void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i10.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(_i9.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [PlayableProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPlayableProvider extends _i1.Mock implements _i2.PlayableProvider { + MockPlayableProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i5.Playable> get playables => (super.noSuchMethod( + Invocation.getter(#playables), + returnValue: <_i5.Playable>[], + ) as List<_i5.Playable>); + + @override + set playables(List<_i5.Playable>? _playables) => super.noSuchMethod( + Invocation.setter( + #playables, + _playables, + ), + returnValueForMissingStub: null, + ); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + List<_i5.Playable> syncWithVault(dynamic _playables) => + (super.noSuchMethod( + Invocation.method( + #syncWithVault, + [_playables], + ), + returnValue: <_i5.Playable>[], + ) as List<_i5.Playable>); + + @override + _i5.Playable? byId(String? id) => + (super.noSuchMethod(Invocation.method( + #byId, + [id], + )) as _i5.Playable?); + + @override + _i9.Future<_i6.PaginationResult<_i5.Playable>> paginate( + _i2.PlayablePaginationConfig? config) => + (super.noSuchMethod( + Invocation.method( + #paginate, + [config], + ), + returnValue: + _i9.Future<_i6.PaginationResult<_i5.Playable>>.value( + _FakePaginationResult_7<_i5.Playable>( + this, + Invocation.method( + #paginate, + [config], + ), + )), + ) as _i9.Future<_i6.PaginationResult<_i5.Playable>>); + + @override + _i9.Future>> fetchForArtist( + dynamic artistId, { + bool? forceRefresh = false, + }) => + (super.noSuchMethod( + Invocation.method( + #fetchForArtist, + [artistId], + {#forceRefresh: forceRefresh}, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + _i9.Future<_i6.PaginationResult<_i5.Playable>> paginateByGenre( + String? genreId, { + int? page = 1, + String? sort = 'title', + _i11.SortOrder? order = _i11.SortOrder.asc, + }) => + (super.noSuchMethod( + Invocation.method( + #paginateByGenre, + [genreId], + { + #page: page, + #sort: sort, + #order: order, + }, + ), + returnValue: + _i9.Future<_i6.PaginationResult<_i5.Playable>>.value( + _FakePaginationResult_7<_i5.Playable>( + this, + Invocation.method( + #paginateByGenre, + [genreId], + { + #page: page, + #sort: sort, + #order: order, + }, + ), + )), + ) as _i9.Future<_i6.PaginationResult<_i5.Playable>>); + + @override + _i9.Future>> fetchForAlbum( + dynamic albumId, { + bool? forceRefresh = false, + }) => + (super.noSuchMethod( + Invocation.method( + #fetchForAlbum, + [albumId], + {#forceRefresh: forceRefresh}, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + _i9.Future>> fetchForPlaylist( + dynamic playlistId, { + bool? forceRefresh = false, + }) => + (super.noSuchMethod( + Invocation.method( + #fetchForPlaylist, + [playlistId], + {#forceRefresh: forceRefresh}, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + _i9.Future>> fetchForPodcast( + String? podcastId, { + bool? forceRefresh = false, + bool? getUpdates = false, + }) => + (super.noSuchMethod( + Invocation.method( + #fetchForPodcast, + [podcastId], + { + #forceRefresh: forceRefresh, + #getUpdates: getUpdates, + }, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + _i9.Future>> fetchRandom({int? limit = 500}) => + (super.noSuchMethod( + Invocation.method( + #fetchRandom, + [], + {#limit: limit}, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + _i9.Future>> fetchInOrder({ + String? sortField = 'title', + _i11.SortOrder? order = _i11.SortOrder.asc, + int? limit = 500, + }) => + (super.noSuchMethod( + Invocation.method( + #fetchInOrder, + [], + { + #sortField: sortField, + #order: order, + #limit: limit, + }, + ), + returnValue: _i9.Future>>.value( + <_i5.Playable>[]), + ) as _i9.Future>>); + + @override + List<_i5.Playable> parseFromJson(dynamic json) => + (super.noSuchMethod( + Invocation.method( + #parseFromJson, + [json], + ), + returnValue: <_i5.Playable>[], + ) as List<_i5.Playable>); + + @override + void addListener(_i10.VoidCallback? listener) => super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + ), + returnValueForMissingStub: null, + ); + + @override + void removeListener(_i10.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(_i9.StreamSubscription? sub) => super.noSuchMethod( + Invocation.method( + #subscribe, + [sub], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/ui/widgets/podcast_card_test.dart b/test/ui/widgets/podcast_card_test.dart index e9bac52f..fdb7321e 100644 --- a/test/ui/widgets/podcast_card_test.dart +++ b/test/ui/widgets/podcast_card_test.dart @@ -1,32 +1,37 @@ import 'package:app/models/models.dart'; -import 'package:app/providers/podcast_provider.dart'; import 'package:app/router.dart'; +import 'package:app/ui/widgets/album_artist_thumbnail.dart'; import 'package:app/ui/widgets/podcast_card.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; -import 'package:provider/provider.dart'; +import 'package:mockito/mockito.dart'; import '../../extensions/widget_tester_extension.dart'; import 'podcast_card_test.mocks.dart'; -@GenerateMocks([AppRouter, PodcastProvider]) +@GenerateMocks([AppRouter]) void main() { - Future mountWithProvider(WidgetTester tester, Podcast podcast) async { - await tester.pumpAppWidget( - ChangeNotifierProvider.value( - value: MockPodcastProvider(), - child: PodcastCard(podcast: podcast, router: MockAppRouter()), - ), - ); - } + testWidgets('renders title, author and thumbnail', (tester) async { + final podcast = Podcast.fake(title: 'My Podcast', author: 'Jane Doe'); + await tester.pumpAppWidget(PodcastCard(podcast: podcast)); + + expect(find.byType(AlbumArtistThumbnail), findsOneWidget); + expect(find.text('My Podcast'), findsOneWidget); + expect(find.text('Jane Doe'), findsOneWidget); + }); - testWidgets('shows Unsubscribe on long-press', (tester) async { - final podcast = Podcast.fake(title: 'My Podcast'); - await mountWithProvider(tester, podcast); + testWidgets('goes to Podcast Details when tapped', (tester) async { + final router = MockAppRouter(); + final podcast = Podcast.fake(title: 'Tap Pod'); + when(router.gotoPodcastDetailsScreen(any, podcastId: podcast.id)) + .thenAnswer((_) async => null); - await tester.longPress(find.byType(PodcastCard)); - await tester.pumpAndSettle(); + await tester.pumpAppWidget( + PodcastCard(podcast: podcast, router: router), + ); - expect(find.text('Unsubscribe'), findsOneWidget); + await tester.tap(find.text('Tap Pod')); + verify(router.gotoPodcastDetailsScreen(any, podcastId: podcast.id)) + .called(1); }); } diff --git a/test/ui/widgets/podcast_card_test.mocks.dart b/test/ui/widgets/podcast_card_test.mocks.dart index c45f5450..3d3d0651 100644 --- a/test/ui/widgets/podcast_card_test.mocks.dart +++ b/test/ui/widgets/podcast_card_test.mocks.dart @@ -3,13 +3,11 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; -import 'dart:ui' as _i7; +import 'dart:async' as _i3; -import 'package:app/models/models.dart' as _i2; -import 'package:app/providers/providers.dart' as _i6; -import 'package:app/router.dart' as _i3; -import 'package:flutter/cupertino.dart' as _i5; +import 'package:app/models/models.dart' as _i5; +import 'package:app/router.dart' as _i2; +import 'package:flutter/cupertino.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -26,27 +24,17 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakePodcast_0 extends _i1.SmartFake implements _i2.Podcast { - _FakePodcast_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - /// A class which mocks [AppRouter]. /// /// See the documentation for Mockito's code generation for more information. -class MockAppRouter extends _i1.Mock implements _i3.AppRouter { +class MockAppRouter extends _i1.Mock implements _i2.AppRouter { MockAppRouter() { _i1.throwOnMissingStub(this); } @override - _i4.Future gotoAlbumDetailsScreen( - _i5.BuildContext? context, { + _i3.Future gotoAlbumDetailsScreen( + _i4.BuildContext? context, { required dynamic albumId, }) => (super.noSuchMethod( @@ -55,13 +43,13 @@ class MockAppRouter extends _i1.Mock implements _i3.AppRouter { [context], {#albumId: albumId}, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future gotoArtistDetailsScreen( - _i5.BuildContext? context, { + _i3.Future gotoArtistDetailsScreen( + _i4.BuildContext? context, { required dynamic artistId, }) => (super.noSuchMethod( @@ -70,13 +58,13 @@ class MockAppRouter extends _i1.Mock implements _i3.AppRouter { [context], {#artistId: artistId}, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override dynamic gotoPodcastDetailsScreen( - _i5.BuildContext? context, { + _i4.BuildContext? context, { required String? podcastId, }) => super.noSuchMethod(Invocation.method( @@ -87,8 +75,8 @@ class MockAppRouter extends _i1.Mock implements _i3.AppRouter { @override dynamic gotoGenreDetailsScreen( - _i5.BuildContext? context, { - required _i2.Genre? genre, + _i4.BuildContext? context, { + required _i5.Genre? genre, }) => super.noSuchMethod(Invocation.method( #gotoGenreDetailsScreen, @@ -97,42 +85,42 @@ class MockAppRouter extends _i1.Mock implements _i3.AppRouter { )); @override - _i4.Future openNowPlayingScreen(_i5.BuildContext? context) => + _i3.Future openNowPlayingScreen(_i4.BuildContext? context) => (super.noSuchMethod( Invocation.method( #openNowPlayingScreen, [context], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future showCreatePlaylistSheet(_i5.BuildContext? context) => + _i3.Future showCreatePlaylistSheet(_i4.BuildContext? context) => (super.noSuchMethod( Invocation.method( #showCreatePlaylistSheet, [context], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future showCreatePlaylistFolderSheet(_i5.BuildContext? context) => + _i3.Future showCreatePlaylistFolderSheet(_i4.BuildContext? context) => (super.noSuchMethod( Invocation.method( #showCreatePlaylistFolderSheet, [context], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future showPlayableActionSheet( - _i5.BuildContext? context, { - required _i2.Playable? playable, + _i3.Future showPlayableActionSheet( + _i4.BuildContext? context, { + required _i5.Playable? playable, }) => (super.noSuchMethod( Invocation.method( @@ -140,172 +128,18 @@ class MockAppRouter extends _i1.Mock implements _i3.AppRouter { [context], {#playable: playable}, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future showAddPodcastSheet(_i5.BuildContext? context) => + _i3.Future showAddPodcastSheet(_i4.BuildContext? context) => (super.noSuchMethod( Invocation.method( #showAddPodcastSheet, [context], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); -} - -/// A class which mocks [PodcastProvider]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPodcastProvider extends _i1.Mock implements _i6.PodcastProvider { - MockPodcastProvider() { - _i1.throwOnMissingStub(this); - } - - @override - List<_i2.Podcast> get podcasts => (super.noSuchMethod( - Invocation.getter(#podcasts), - returnValue: <_i2.Podcast>[], - ) as List<_i2.Podcast>); - - @override - bool get hasListeners => (super.noSuchMethod( - Invocation.getter(#hasListeners), - returnValue: false, - ) as bool); - - @override - _i4.Future fetchAll() => (super.noSuchMethod( - Invocation.method( - #fetchAll, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future refresh() => (super.noSuchMethod( - Invocation.method( - #refresh, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future unsubscribePodcast(_i2.Podcast? podcast) => - (super.noSuchMethod( - Invocation.method( - #unsubscribePodcast, - [podcast], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future<_i2.Podcast> add({required String? url}) => (super.noSuchMethod( - Invocation.method( - #add, - [], - {#url: url}, - ), - returnValue: _i4.Future<_i2.Podcast>.value(_FakePodcast_0( - this, - Invocation.method( - #add, - [], - {#url: url}, - ), - )), - ) as _i4.Future<_i2.Podcast>); - - @override - _i4.Future<_i2.Podcast> resolve( - String? id, { - bool? forceRefresh = false, - }) => - (super.noSuchMethod( - Invocation.method( - #resolve, - [id], - {#forceRefresh: forceRefresh}, - ), - returnValue: _i4.Future<_i2.Podcast>.value(_FakePodcast_0( - this, - Invocation.method( - #resolve, - [id], - {#forceRefresh: forceRefresh}, - ), - )), - ) as _i4.Future<_i2.Podcast>); - - @override - _i4.Future getEpisodeProgress(_i2.Episode? episode) => - (super.noSuchMethod( - Invocation.method( - #getEpisodeProgress, - [episode], - ), - returnValue: _i4.Future.value(0), - ) as _i4.Future); - - @override - void addListener(_i7.VoidCallback? listener) => super.noSuchMethod( - Invocation.method( - #addListener, - [listener], - ), - returnValueForMissingStub: null, - ); - - @override - void removeListener(_i7.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, - ); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); } diff --git a/test/ui/widgets/podcast_row_test.dart b/test/ui/widgets/podcast_row_test.dart deleted file mode 100644 index 46862836..00000000 --- a/test/ui/widgets/podcast_row_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:app/models/models.dart'; -import 'package:app/providers/podcast_provider.dart'; -import 'package:app/ui/screens/podcasts.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; - -import '../../extensions/widget_tester_extension.dart'; -import 'podcast_card_test.mocks.dart'; - -void main() { - Future mountWithProvider(WidgetTester tester, Podcast podcast) async { - await tester.pumpAppWidget( - ChangeNotifierProvider.value( - value: MockPodcastProvider(), - child: PodcastRow(podcast: podcast, router: MockAppRouter()), - ), - ); - } - - testWidgets('shows Unsubscribe on long-press', (tester) async { - final podcast = Podcast.fake(title: 'My Podcast'); - await mountWithProvider(tester, podcast); - - await tester.longPress(find.byType(PodcastRow)); - await tester.pumpAndSettle(); - - expect(find.text('Unsubscribe'), findsOneWidget); - }); -}