diff --git a/lib/models/radio_station.dart b/lib/models/radio_station.dart index b4a84b4c..dd68a6af 100644 --- a/lib/models/radio_station.dart +++ b/lib/models/radio_station.dart @@ -18,6 +18,9 @@ class RadioStation { /// See [canEdit] for sourcing. bool canDelete; + /// Whether the current user has favorited this radio station. + bool favorite; + RadioStation({ required this.id, required this.name, @@ -27,6 +30,7 @@ class RadioStation { this.isPublic = false, this.canEdit = false, this.canDelete = false, + this.favorite = false, }); factory RadioStation.fromJson(Map json) { @@ -41,6 +45,7 @@ class RadioStation { isPublic: json['is_public'] ?? false, canEdit: permissions is Map ? permissions['edit'] == true : false, canDelete: permissions is Map ? permissions['delete'] == true : false, + favorite: json['favorite'] == true, ); } @@ -50,6 +55,7 @@ class RadioStation { String? url, bool canEdit = false, bool canDelete = false, + bool favorite = false, }) { final faker = Faker(); return RadioStation( @@ -58,6 +64,7 @@ class RadioStation { url: url ?? 'https://stream.example.com/live', canEdit: canEdit, canDelete: canDelete, + favorite: favorite, ); } } diff --git a/lib/providers/radio_player_provider.dart b/lib/providers/radio_player_provider.dart index cf7142b3..bab7d23d 100644 --- a/lib/providers/radio_player_provider.dart +++ b/lib/providers/radio_player_provider.dart @@ -139,6 +139,20 @@ class RadioPlayerProvider with ChangeNotifier { } } + /// Re-emit the OS media-session metadata for the current station. + /// Use after editing the station's name or logo so the lock-screen / + /// notification controls pick up the new values without restarting + /// the stream. + void refreshMediaItem() { + final station = _currentStation; + if (station == null) return; + + audioHandler.mediaItem.add(mediaItemForStation( + station, + streamTitle: _streamTitle, + )); + } + void _startNowPlayingPolling(RadioStation station) { _stopNowPlayingPolling(); _fetchNowPlaying(station); diff --git a/lib/providers/radio_station_provider.dart b/lib/providers/radio_station_provider.dart index fc7edfe8..284fa4e1 100644 --- a/lib/providers/radio_station_provider.dart +++ b/lib/providers/radio_station_provider.dart @@ -73,6 +73,23 @@ class RadioStationProvider with ChangeNotifier, StreamSubscriber { notifyListeners(); } + Future toggleFavorite(RadioStation station) async { + // Optimistic flip + restore on failure. + station.favorite = !station.favorite; + notifyListeners(); + + try { + await post('favorites/toggle', data: { + 'type': 'radio-station', + 'id': station.id, + }); + } catch (_) { + station.favorite = !station.favorite; + notifyListeners(); + rethrow; + } + } + Future> getNowPlaying(RadioStation station) async { return await get('radio/stations/${station.id}/now-playing'); } diff --git a/lib/ui/screens/edit_radio_station_sheet.dart b/lib/ui/screens/edit_radio_station_sheet.dart index ea418af8..7c95690c 100644 --- a/lib/ui/screens/edit_radio_station_sheet.dart +++ b/lib/ui/screens/edit_radio_station_sheet.dart @@ -14,6 +14,7 @@ Future showEditRadioStationDialog( TextEditingController(text: station.description ?? ''); var isPublic = station.isPublic; final stationProvider = context.read(); + final radioPlayer = context.read(); await showFormSheet( context, @@ -27,6 +28,11 @@ Future showEditRadioStationDialog( final url = urlController.text.trim(); if (name.isEmpty || url.isEmpty) return; + // Capture before mutation so we can detect what actually changed + // and bring the live player into sync. + final oldUrl = station.url; + final oldName = station.name; + try { await stationProvider.update( station, @@ -35,6 +41,20 @@ Future showEditRadioStationDialog( description: descController.text.trim(), isPublic: isPublic, ); + + // If we just edited the station that's currently on air, the + // player is still streaming the old URL (its setUrl was called + // at play time). Restart the stream when the URL changed; + // otherwise just refresh the OS media-session metadata so the + // lock screen / notification picks up the new name. + if (radioPlayer.currentStation?.id == station.id) { + if (url != oldUrl) { + radioPlayer.play(station).catchError((_) {}); + } else if (name != oldName) { + radioPlayer.refreshMediaItem(); + } + } + Navigator.pop(context); showOverlay(context, caption: 'Station updated'); } catch (_) { diff --git a/lib/ui/screens/playable_action_sheet.dart b/lib/ui/screens/playable_action_sheet.dart index 0280d53e..2c8191e2 100644 --- a/lib/ui/screens/playable_action_sheet.dart +++ b/lib/ui/screens/playable_action_sheet.dart @@ -325,6 +325,7 @@ class PlayableActionButton extends StatelessWidget { final Function onTap; final bool hideSheetOnTap; final bool enabled; + final bool destructive; const PlayableActionButton({ Key? key, @@ -333,16 +334,27 @@ class PlayableActionButton extends StatelessWidget { required this.onTap, this.hideSheetOnTap = true, this.enabled = true, + this.destructive = false, }) : super(key: key); @override Widget build(BuildContext context) { + final destructiveColor = CupertinoColors.systemRed.resolveFrom(context); + final Color? labelColor = !enabled + ? Colors.white30 + : destructive + ? destructiveColor + : null; + final effectiveIcon = destructive && enabled + ? Icon(icon.icon, color: destructiveColor) + : icon; + return ListTile( - leading: icon, + leading: effectiveIcon, minLeadingWidth: 16, title: Text( text, - style: enabled ? null : const TextStyle(color: Colors.white30), + style: labelColor == null ? null : TextStyle(color: labelColor), ), onTap: enabled ? () { diff --git a/lib/ui/screens/radio_station_action_sheet.dart b/lib/ui/screens/radio_station_action_sheet.dart new file mode 100644 index 00000000..42a3a795 --- /dev/null +++ b/lib/ui/screens/radio_station_action_sheet.dart @@ -0,0 +1,234 @@ +import 'package:app/models/models.dart'; +import 'package:app/providers/providers.dart'; +import 'package:app/ui/screens/edit_radio_station_sheet.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:cached_network_image/cached_network_image.dart'; +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class RadioStationActionSheet extends StatelessWidget { + final RadioStation station; + + const RadioStationActionSheet({Key? key, required this.station}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final stationProvider = context.read(); + // Favoriting non-song entities only landed in koel 7.11.0. + final showFavorite = Feature.favoriteEntities.isSupported(); + final hasDescription = + station.description != null && station.description!.isNotEmpty; + + 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: [ + _Thumbnail(station: station, dimension: 192), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + station.name, + textAlign: TextAlign.center, + softWrap: true, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + if (hasDescription) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + station.description!, + textAlign: TextAlign.center, + softWrap: true, + style: const TextStyle(color: Colors.white54), + ), + ), + ], + ], + ), + 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: station.favorite + ? 'Undo Favorite' + : 'Favorite', + icon: Icon(station.favorite + ? CupertinoIcons.star_fill + : CupertinoIcons.star), + 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. + stationProvider + .toggleFavorite(station) + .catchError((_) {}); + }, + ), + const PlayableQuickActionDivider(), + ], + Consumer( + builder: (_, radioPlayer, __) { + final isCurrent = + radioPlayer.currentStation?.id == station.id; + final isStopButton = isCurrent && + (radioPlayer.playing || radioPlayer.loading); + + return PlayableQuickAction( + label: isStopButton ? 'Stop' : 'Play', + icon: Icon(isStopButton + ? CupertinoIcons.stop_fill + : CupertinoIcons.play_fill), + onTap: () { + Navigator.pop(context); + if (isStopButton) { + radioPlayer.stop().catchError((_) {}); + } else { + radioPlayer + .play(station) + .catchError((_) {}); + } + }, + ); + }, + ), + ], + ), + ), + ), + const Divider(indent: 16, endIndent: 16), + ListView( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + children: [ + if (station.canEdit) + PlayableActionButton( + text: 'Edit…', + icon: const Icon( + CupertinoIcons.pencil, + color: Colors.white30, + ), + onTap: () { + Navigator.pop(context); + showEditRadioStationDialog(context, station: station); + }, + hideSheetOnTap: false, + ), + if (station.canDelete) ...[ + if (station.canEdit) + const Divider(indent: 16, endIndent: 16), + PlayableActionButton( + text: 'Delete', + destructive: true, + icon: const Icon(CupertinoIcons.trash), + onTap: () async { + if (!await confirmDeleteRadioStation( + context, + station: station, + )) { + return; + } + if (!context.mounted) return; + Navigator.pop(context); + deleteRadioStationWithFeedback( + context, + station: station, + ); + }, + hideSheetOnTap: false, + ), + ], + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Thumbnail extends StatelessWidget { + final RadioStation station; + final double dimension; + + const _Thumbnail({required this.station, required this.dimension}); + + @override + Widget build(BuildContext context) { + Widget fallback() => Container( + width: dimension, + height: dimension, + color: Colors.white12, + child: Icon( + CupertinoIcons.antenna_radiowaves_left_right, + size: dimension * 0.4, + color: Colors.white54, + ), + ); + + return ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 24, + cornerSmoothing: .8, + ), + child: SizedBox( + width: dimension, + height: dimension, + child: station.logo != null + ? CachedNetworkImage( + imageUrl: station.logo!, + width: dimension, + height: dimension, + fit: BoxFit.cover, + placeholder: (_, __) => fallback(), + errorWidget: (_, __, ___) => fallback(), + ) + : fallback(), + ), + ); + } +} + +Future showRadioStationActionSheet( + BuildContext context, { + required RadioStation station, +}) { + return showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (_) => RadioStationActionSheet(station: station), + ); +} diff --git a/lib/ui/screens/radio_stations.dart b/lib/ui/screens/radio_stations.dart index b99da07c..b55a3aca 100644 --- a/lib/ui/screens/radio_stations.dart +++ b/lib/ui/screens/radio_stations.dart @@ -1,6 +1,7 @@ import 'package:app/constants/constants.dart'; import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; +import 'package:app/ui/screens/radio_station_action_sheet.dart'; import 'package:app/ui/widgets/widgets.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:figma_squircle/figma_squircle.dart'; @@ -185,8 +186,6 @@ class _RadioStationRow extends StatefulWidget { } class _RadioStationRowState extends State<_RadioStationRow> { - Offset? _lastTapPosition; - @override Widget build(BuildContext context) { final station = widget.station; @@ -197,15 +196,8 @@ class _RadioStationRowState extends State<_RadioStationRow> { return InkWell( onTap: widget.onTap, - onTapDown: (details) => _lastTapPosition = details.globalPosition, - onLongPress: () => showRadioStationActionsMenu( - context, - station: station, - position: _lastTapPosition ?? Offset.zero, - onUpdated: () { - if (mounted) setState(() {}); - }, - ), + onLongPress: () => + showRadioStationActionSheet(context, station: station), child: ListTile( shape: Border(bottom: Divider.createBorderSide(context)), leading: ClipSmoothRect( diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index a43eac55..628c8047 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -31,6 +31,7 @@ export 'playlists.dart'; export 'podcast_details.dart'; export 'podcasts.dart'; export 'radio_now_playing.dart'; +export 'radio_station_action_sheet.dart'; export 'radio_stations.dart'; export 'recently_played.dart'; export 'search.dart'; diff --git a/lib/ui/widgets/radio_station_actions.dart b/lib/ui/widgets/radio_station_actions.dart new file mode 100644 index 00000000..91712b1e --- /dev/null +++ b/lib/ui/widgets/radio_station_actions.dart @@ -0,0 +1,42 @@ +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:provider/provider.dart'; + +/// Asks the user to confirm deleting [station]. Returns `true` on +/// confirm, `false` on cancel. +Future confirmDeleteRadioStation( + BuildContext context, { + required RadioStation station, +}) async { + final confirmed = await showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: const Text('Delete station?'), + content: Text('Delete "${station.name}"? This cannot be undone.'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Delete'), + ), + ], + ), + ); + return confirmed == true; +} + +/// Calls [RadioStationProvider.remove] (fire-and-forget, locally +/// optimistic) and shows a 'Station deleted' overlay. +void deleteRadioStationWithFeedback( + BuildContext context, { + required RadioStation station, +}) { + context.read().remove(station); + showOverlay(context, caption: 'Station deleted'); +} diff --git a/lib/ui/widgets/radio_station_actions_menu.dart b/lib/ui/widgets/radio_station_actions_menu.dart deleted file mode 100644 index ffdf82db..00000000 --- a/lib/ui/widgets/radio_station_actions_menu.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:app/models/models.dart'; -import 'package:app/providers/providers.dart'; -import 'package:app/ui/screens/edit_radio_station_sheet.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 radio station — Edit and/or -/// Delete, gated on [RadioStation.canEdit] / [RadioStation.canDelete]. -/// -/// Returns immediately when neither permission is granted (no haptic, -/// no menu). Otherwise fires a medium haptic and opens the menu at -/// [position] in global screen coordinates. -/// -/// Pass [onUpdated] if the caller needs to rebuild after a successful -/// edit (the provider mutates the station in place but doesn't notify -/// individual row widgets). -Future showRadioStationActionsMenu( - BuildContext context, { - required RadioStation station, - required Offset position, - VoidCallback? onUpdated, -}) async { - final canEdit = station.canEdit; - final canDelete = station.canDelete; - if (!canEdit && !canDelete) return; - - HapticFeedback.mediumImpact(); - - final selected = await showFrostedContextMenu( - context: context, - position: position, - items: [ - if (canEdit) - const FrostedMenuItem( - value: 'edit', - icon: CupertinoIcons.pencil, - label: 'Edit', - ), - if (canDelete) - const FrostedMenuItem( - value: 'delete', - icon: CupertinoIcons.trash, - label: 'Delete', - destructive: true, - ), - ], - ); - - if (!context.mounted) return; - - switch (selected) { - case 'edit': - await showEditRadioStationDialog(context, station: station); - onUpdated?.call(); - break; - case 'delete': - if (!await confirmDeleteRadioStation(context, station: station)) { - break; - } - if (!context.mounted) break; - deleteRadioStationWithFeedback(context, station: station); - break; - } -} - -/// Asks the user to confirm deleting [station]. Returns `true` on -/// confirm, `false` on cancel. -Future confirmDeleteRadioStation( - BuildContext context, { - required RadioStation station, -}) async { - final confirmed = await showCupertinoDialog( - context: context, - builder: (dialogContext) => CupertinoAlertDialog( - title: const Text('Delete station?'), - content: Text('Delete "${station.name}"? This cannot be undone.'), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Delete'), - ), - ], - ), - ); - return confirmed == true; -} - -/// Calls [RadioStationProvider.remove] (fire-and-forget, locally -/// optimistic) and shows a 'Station deleted' overlay. -void deleteRadioStationWithFeedback( - BuildContext context, { - required RadioStation station, -}) { - context.read().remove(station); - showOverlay(context, caption: 'Station deleted'); -} diff --git a/lib/ui/widgets/radio_station_card.dart b/lib/ui/widgets/radio_station_card.dart index e7691e6d..683d9f73 100644 --- a/lib/ui/widgets/radio_station_card.dart +++ b/lib/ui/widgets/radio_station_card.dart @@ -1,6 +1,6 @@ import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; -import 'package:app/ui/widgets/radio_station_actions_menu.dart'; +import 'package:app/ui/screens/radio_station_action_sheet.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:figma_squircle/figma_squircle.dart'; import 'package:flutter/cupertino.dart'; @@ -21,27 +21,17 @@ class RadioStationCard extends StatefulWidget { class _RadioStationCardState 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.onTap ?? () => context.read().play(widget.station), - onLongPress: () => showRadioStationActionsMenu( - context, - station: widget.station, - position: _lastTapPosition ?? Offset.zero, - onUpdated: () { - if (mounted) setState(() {}); - }, - ), + onLongPress: () => + showRadioStationActionSheet(context, station: widget.station), 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 03385214..82ad520a 100644 --- a/lib/ui/widgets/widgets.dart +++ b/lib/ui/widgets/widgets.dart @@ -30,7 +30,7 @@ export 'podcast_actions_menu.dart'; export 'podcast_card.dart'; export 'profile_avatar.dart'; export 'pull_to_refresh.dart'; -export 'radio_station_actions_menu.dart'; +export 'radio_station_actions.dart'; export 'radio_station_card.dart'; export 'simple_playable_list.dart'; export 'sliver_playable_list.dart'; diff --git a/test/models/radio_station_test.dart b/test/models/radio_station_test.dart index ea2cb7d4..b56e221e 100644 --- a/test/models/radio_station_test.dart +++ b/test/models/radio_station_test.dart @@ -92,6 +92,37 @@ void main() { expect(station.canEdit, isFalse); expect(station.canDelete, isFalse); }); + + test('parses favorite from JSON', () { + final json = { + 'id': 'station-fav', + 'name': 'Loved', + 'url': 'https://stream.example.com/live', + 'favorite': true, + }; + + expect(RadioStation.fromJson(json).favorite, isTrue); + }); + + test('defaults favorite to false when missing or non-bool', () { + expect( + RadioStation.fromJson({ + 'id': 's', + 'name': 'X', + 'url': 'https://stream.example.com/live', + }).favorite, + isFalse, + ); + expect( + RadioStation.fromJson({ + 'id': 's', + 'name': 'X', + 'url': 'https://stream.example.com/live', + 'favorite': null, + }).favorite, + isFalse, + ); + }); }); group('RadioStation.fake', () { diff --git a/test/ui/screens/edit_radio_station_sheet_test.dart b/test/ui/screens/edit_radio_station_sheet_test.dart new file mode 100644 index 00000000..9e4175fa --- /dev/null +++ b/test/ui/screens/edit_radio_station_sheet_test.dart @@ -0,0 +1,179 @@ +import 'package:app/models/radio_station.dart'; +import 'package:app/providers/radio_player_provider.dart'; +import 'package:app/providers/radio_station_provider.dart'; +import 'package:app/ui/screens/edit_radio_station_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../../extensions/widget_tester_extension.dart'; +import 'edit_radio_station_sheet_test.mocks.dart'; + +@GenerateMocks([RadioStationProvider, RadioPlayerProvider]) +void main() { + late MockRadioStationProvider stationProviderMock; + late MockRadioPlayerProvider radioPlayerMock; + + setUp(() { + stationProviderMock = MockRadioStationProvider(); + radioPlayerMock = MockRadioPlayerProvider(); + + when(stationProviderMock.update( + any, + name: anyNamed('name'), + url: anyNamed('url'), + description: anyNamed('description'), + isPublic: anyNamed('isPublic'), + )).thenAnswer((_) async {}); + + // Default: no station on air. + when(radioPlayerMock.currentStation).thenReturn(null); + when(radioPlayerMock.play(any)).thenAnswer((_) async {}); + }); + + Future openDialog(WidgetTester tester, RadioStation station) async { + await tester.pumpAppWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: stationProviderMock, + ), + ChangeNotifierProvider.value( + value: radioPlayerMock, + ), + ], + child: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => + showEditRadioStationDialog(context, station: station), + child: const Text('open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + Future typeIntoField( + WidgetTester tester, { + required String hint, + required String text, + }) async { + final finder = find.widgetWithText(TextField, hint).first; + await tester.enterText(finder, text); + } + + group('live edit while station is playing', () { + testWidgets( + 'changing the URL restarts the stream', + (tester) async { + final station = RadioStation( + id: 's1', + name: 'Jazz FM', + url: 'https://old.example.com/live', + ); + when(radioPlayerMock.currentStation).thenReturn(station); + + await openDialog(tester, station); + await typeIntoField( + tester, + hint: 'Stream URL', + text: 'https://new.example.com/live', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + verify(radioPlayerMock.play(station)).called(1); + verifyNever(radioPlayerMock.refreshMediaItem()); + }, + ); + + testWidgets( + 'changing only the name refreshes the media item without restart', + (tester) async { + final station = RadioStation( + id: 's1', + name: 'Jazz FM', + url: 'https://example.com/live', + ); + when(radioPlayerMock.currentStation).thenReturn(station); + + await openDialog(tester, station); + await typeIntoField(tester, hint: 'Station Name', text: 'Smooth Jazz'); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + verify(radioPlayerMock.refreshMediaItem()).called(1); + verifyNever(radioPlayerMock.play(any)); + }, + ); + + testWidgets( + 'changing only the description does nothing on the player', + (tester) async { + final station = RadioStation( + id: 's1', + name: 'Jazz FM', + url: 'https://example.com/live', + description: 'old', + ); + when(radioPlayerMock.currentStation).thenReturn(station); + + await openDialog(tester, station); + await typeIntoField( + tester, + hint: 'Description (optional)', + text: 'new description', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + verifyNever(radioPlayerMock.play(any)); + verifyNever(radioPlayerMock.refreshMediaItem()); + }, + ); + + testWidgets( + 'does nothing on the player when a different station is on air', + (tester) async { + final editing = RadioStation( + id: 's1', + name: 'Jazz FM', + url: 'https://old.example.com/live', + ); + final onAir = RadioStation.fake(id: 's2'); + when(radioPlayerMock.currentStation).thenReturn(onAir); + + await openDialog(tester, editing); + await typeIntoField( + tester, + hint: 'Stream URL', + text: 'https://new.example.com/live', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + + verifyNever(radioPlayerMock.play(any)); + verifyNever(radioPlayerMock.refreshMediaItem()); + }, + ); + }); +} diff --git a/test/ui/widgets/radio_station_card_test.mocks.dart b/test/ui/screens/edit_radio_station_sheet_test.mocks.dart similarity index 93% rename from test/ui/widgets/radio_station_card_test.mocks.dart rename to test/ui/screens/edit_radio_station_sheet_test.mocks.dart index e976cc48..149cc229 100644 --- a/test/ui/widgets/radio_station_card_test.mocks.dart +++ b/test/ui/screens/edit_radio_station_sheet_test.mocks.dart @@ -1,5 +1,5 @@ // Mocks generated by Mockito 5.4.6 from annotations -// in app/test/ui/widgets/radio_station_card_test.dart. +// in app/test/ui/screens/edit_radio_station_sheet_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes @@ -132,6 +132,17 @@ class MockRadioStationProvider extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override + _i4.Future toggleFavorite(_i2.RadioStation? station) => + (super.noSuchMethod( + Invocation.method( + #toggleFavorite, + [station], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override _i4.Future> getNowPlaying(_i2.RadioStation? station) => (super.noSuchMethod( @@ -261,6 +272,15 @@ class MockRadioPlayerProvider extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + @override + void refreshMediaItem() => super.noSuchMethod( + Invocation.method( + #refreshMediaItem, + [], + ), + returnValueForMissingStub: null, + ); + @override void dispose() => super.noSuchMethod( Invocation.method( diff --git a/test/ui/screens/radio_station_action_sheet_test.dart b/test/ui/screens/radio_station_action_sheet_test.dart new file mode 100644 index 00000000..45fa51f0 --- /dev/null +++ b/test/ui/screens/radio_station_action_sheet_test.dart @@ -0,0 +1,211 @@ +import 'package:app/app_state.dart'; +import 'package:app/models/radio_station.dart'; +import 'package:app/providers/radio_player_provider.dart'; +import 'package:app/providers/radio_station_provider.dart'; +import 'package:app/ui/screens/radio_station_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 'package:version/version.dart'; + +import '../../extensions/widget_tester_extension.dart'; +import 'radio_station_action_sheet_test.mocks.dart'; + +@GenerateMocks([RadioStationProvider, RadioPlayerProvider]) +void main() { + late MockRadioStationProvider stationProviderMock; + late MockRadioPlayerProvider radioPlayerMock; + + setUp(() { + AppState.clear(); + AppState.set(['app', 'apiVersion'], Version.parse('7.11.0')); + + stationProviderMock = MockRadioStationProvider(); + radioPlayerMock = MockRadioPlayerProvider(); + + // Default to "no current station, idle"; individual tests override. + when(radioPlayerMock.currentStation).thenReturn(null); + when(radioPlayerMock.playing).thenReturn(false); + when(radioPlayerMock.loading).thenReturn(false); + }); + + Future mount(WidgetTester tester, RadioStation station) async { + await tester.pumpAppWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: stationProviderMock, + ), + ChangeNotifierProvider.value( + value: radioPlayerMock, + ), + ], + child: RadioStationActionSheet(station: station), + ), + ); + } + + group('structure', () { + testWidgets('renders the station name', (tester) async { + await mount(tester, RadioStation.fake(name: 'Jazz FM')); + + expect(find.text('Jazz FM'), findsOneWidget); + }); + + testWidgets('renders description when present', (tester) async { + final station = RadioStation.fake(name: 'Jazz FM'); + station.description = 'Smooth jazz all day'; + await mount(tester, station); + + expect(find.text('Smooth jazz all day'), findsOneWidget); + }); + + testWidgets('renders Favorite + Play quick row by default', + (tester) async { + await mount(tester, RadioStation.fake(name: 'A')); + + expect(find.text('Favorite'), findsOneWidget); + expect(find.text('Play'), findsOneWidget); + expect(find.text('Stop'), findsNothing); + }); + + testWidgets('shows "Undo Favorite" when station.favorite is true', + (tester) async { + await mount(tester, RadioStation.fake(name: 'Loved', 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, RadioStation.fake(name: 'A')); + + expect(find.text('Favorite'), findsNothing); + expect(find.text('Undo Favorite'), findsNothing); + // Play stays. + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets('shows Edit only when canEdit is true', (tester) async { + await mount(tester, RadioStation.fake(name: 'Editable', canEdit: true)); + expect(find.text('Edit…'), findsOneWidget); + }); + + testWidgets('hides Edit when canEdit is false', (tester) async { + await mount(tester, RadioStation.fake(name: 'Read-only')); + expect(find.text('Edit…'), findsNothing); + }); + + testWidgets('shows Delete only when canDelete is true', (tester) async { + await mount( + tester, + RadioStation.fake(name: 'Deletable', canDelete: true), + ); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('hides Delete when canDelete is false', (tester) async { + await mount(tester, RadioStation.fake(name: 'Read-only')); + expect(find.text('Delete'), findsNothing); + }); + }); + + group('Play/Stop reactivity', () { + testWidgets( + 'shows Stop when this station is the current playing station', + (tester) async { + final station = RadioStation.fake(id: 'me'); + when(radioPlayerMock.currentStation).thenReturn(station); + when(radioPlayerMock.playing).thenReturn(true); + + await mount(tester, station); + + expect(find.text('Stop'), findsOneWidget); + expect(find.text('Play'), findsNothing); + }, + ); + + testWidgets( + 'shows Stop when this station is the current loading station', + (tester) async { + final station = RadioStation.fake(id: 'me'); + when(radioPlayerMock.currentStation).thenReturn(station); + when(radioPlayerMock.playing).thenReturn(false); + when(radioPlayerMock.loading).thenReturn(true); + + await mount(tester, station); + + expect(find.text('Stop'), findsOneWidget); + }, + ); + + testWidgets( + 'shows Play when a *different* station is current', + (tester) async { + final me = RadioStation.fake(id: 'me'); + final other = RadioStation.fake(id: 'other'); + when(radioPlayerMock.currentStation).thenReturn(other); + when(radioPlayerMock.playing).thenReturn(true); + + await mount(tester, me); + + expect(find.text('Play'), findsOneWidget); + expect(find.text('Stop'), findsNothing); + }, + ); + }); + + group('actions', () { + testWidgets( + 'tapping Favorite delegates to RadioStationProvider.toggleFavorite', + (tester) async { + final station = RadioStation.fake(name: 'Loved'); + when(stationProviderMock.toggleFavorite(station)) + .thenAnswer((_) async {}); + + await mount(tester, station); + await tester.tap(find.text('Favorite')); + await tester.pump(); + + verify(stationProviderMock.toggleFavorite(station)).called(1); + }, + ); + + testWidgets( + 'tapping Play delegates to RadioPlayerProvider.play', + (tester) async { + final station = RadioStation.fake(name: 'Jazz FM'); + when(radioPlayerMock.play(station)).thenAnswer((_) async {}); + + await mount(tester, station); + await tester.tap(find.text('Play')); + await tester.pump(); + + verify(radioPlayerMock.play(station)).called(1); + verifyNever(radioPlayerMock.stop()); + }, + ); + + testWidgets( + 'tapping Stop delegates to RadioPlayerProvider.stop', + (tester) async { + final station = RadioStation.fake(name: 'Jazz FM'); + when(radioPlayerMock.currentStation).thenReturn(station); + when(radioPlayerMock.playing).thenReturn(true); + when(radioPlayerMock.stop()).thenAnswer((_) async {}); + + await mount(tester, station); + await tester.tap(find.text('Stop')); + await tester.pump(); + + verify(radioPlayerMock.stop()).called(1); + verifyNever(radioPlayerMock.play(any)); + }, + ); + }); +} diff --git a/test/ui/screens/radio_station_action_sheet_test.mocks.dart b/test/ui/screens/radio_station_action_sheet_test.mocks.dart new file mode 100644 index 00000000..6d942784 --- /dev/null +++ b/test/ui/screens/radio_station_action_sheet_test.mocks.dart @@ -0,0 +1,319 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in app/test/ui/screens/radio_station_action_sheet_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/radio_player_provider.dart' as _i6; +import 'package:app/providers/radio_station_provider.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 _FakeRadioStation_0 extends _i1.SmartFake implements _i2.RadioStation { + _FakeRadioStation_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [RadioStationProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRadioStationProvider extends _i1.Mock + implements _i3.RadioStationProvider { + MockRadioStationProvider() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i2.RadioStation> get stations => (super.noSuchMethod( + Invocation.getter(#stations), + returnValue: <_i2.RadioStation>[], + ) as List<_i2.RadioStation>); + + @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<_i2.RadioStation> create({ + required String? name, + required String? url, + String? description, + bool? isPublic = false, + }) => + (super.noSuchMethod( + Invocation.method( + #create, + [], + { + #name: name, + #url: url, + #description: description, + #isPublic: isPublic, + }, + ), + returnValue: _i4.Future<_i2.RadioStation>.value(_FakeRadioStation_0( + this, + Invocation.method( + #create, + [], + { + #name: name, + #url: url, + #description: description, + #isPublic: isPublic, + }, + ), + )), + ) as _i4.Future<_i2.RadioStation>); + + @override + _i4.Future update( + _i2.RadioStation? station, { + required String? name, + required String? url, + String? description, + bool? isPublic = false, + }) => + (super.noSuchMethod( + Invocation.method( + #update, + [station], + { + #name: name, + #url: url, + #description: description, + #isPublic: isPublic, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future remove(_i2.RadioStation? station) => (super.noSuchMethod( + Invocation.method( + #remove, + [station], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future toggleFavorite(_i2.RadioStation? station) => + (super.noSuchMethod( + Invocation.method( + #toggleFavorite, + [station], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future> getNowPlaying(_i2.RadioStation? station) => + (super.noSuchMethod( + Invocation.method( + #getNowPlaying, + [station], + ), + returnValue: + _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 [RadioPlayerProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRadioPlayerProvider extends _i1.Mock + implements _i6.RadioPlayerProvider { + MockRadioPlayerProvider() { + _i1.throwOnMissingStub(this); + } + + @override + bool get playing => (super.noSuchMethod( + Invocation.getter(#playing), + returnValue: false, + ) as bool); + + @override + bool get loading => (super.noSuchMethod( + Invocation.getter(#loading), + returnValue: false, + ) as bool); + + @override + bool get active => (super.noSuchMethod( + Invocation.getter(#active), + returnValue: false, + ) as bool); + + @override + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); + + @override + _i4.Future play(_i2.RadioStation? station) => (super.noSuchMethod( + Invocation.method( + #play, + [station], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future stop() => (super.noSuchMethod( + Invocation.method( + #stop, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future togglePlayPause() => (super.noSuchMethod( + Invocation.method( + #togglePlayPause, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + void refreshMediaItem() => super.noSuchMethod( + Invocation.method( + #refreshMediaItem, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + 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 notifyListeners() => super.noSuchMethod( + Invocation.method( + #notifyListeners, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/ui/widgets/radio_station_card_test.dart b/test/ui/widgets/radio_station_card_test.dart index 10df6017..be54cfb0 100644 --- a/test/ui/widgets/radio_station_card_test.dart +++ b/test/ui/widgets/radio_station_card_test.dart @@ -1,16 +1,10 @@ import 'package:app/models/radio_station.dart'; -import 'package:app/providers/providers.dart'; import 'package:app/ui/widgets/radio_station_card.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:provider/provider.dart'; import '../../extensions/widget_tester_extension.dart'; -import 'radio_station_card_test.mocks.dart'; - -@GenerateMocks([RadioStationProvider, RadioPlayerProvider]) void main() { testWidgets('renders station name', (WidgetTester tester) async { @@ -84,69 +78,4 @@ void main() { await tester.tap(find.text('Tap FM')); expect(tapped, isTrue); }); - - group('long-press context menu', () { - Future mountWithProviders( - WidgetTester tester, - RadioStation station, - ) async { - await tester.pumpAppWidget( - MultiProvider( - providers: [ - ChangeNotifierProvider.value( - value: MockRadioStationProvider(), - ), - ChangeNotifierProvider.value( - value: MockRadioPlayerProvider(), - ), - ], - child: RadioStationCard(station: station, onTap: () {}), - ), - ); - } - - testWidgets('shows Edit when canEdit', (tester) async { - final station = RadioStation.fake(name: 'Editable', canEdit: true); - await mountWithProviders(tester, station); - - await tester.longPress(find.byType(RadioStationCard)); - await tester.pumpAndSettle(); - - expect(find.text('Edit'), findsOneWidget); - expect(find.text('Delete'), findsNothing); - }); - - testWidgets('shows Delete when canDelete', (tester) async { - final station = RadioStation.fake(name: 'Deletable', canDelete: true); - await mountWithProviders(tester, station); - - await tester.longPress(find.byType(RadioStationCard)); - await tester.pumpAndSettle(); - - expect(find.text('Delete'), findsOneWidget); - expect(find.text('Edit'), findsNothing); - }); - - testWidgets('shows both when both permissions granted', (tester) async { - final station = RadioStation.fake(canEdit: true, canDelete: true); - await mountWithProviders(tester, station); - - await tester.longPress(find.byType(RadioStationCard)); - await tester.pumpAndSettle(); - - expect(find.text('Edit'), findsOneWidget); - expect(find.text('Delete'), findsOneWidget); - }); - - testWidgets('no menu when neither permission is granted', (tester) async { - final station = RadioStation.fake(); - await mountWithProviders(tester, station); - - await tester.longPress(find.byType(RadioStationCard)); - await tester.pumpAndSettle(); - - expect(find.text('Edit'), findsNothing); - expect(find.text('Delete'), findsNothing); - }); - }); }