From a449f752f4e5e856e006cad551149c083bd190f9 Mon Sep 17 00:00:00 2001 From: Augsorn Chanklad Date: Wed, 17 Apr 2024 20:24:34 +0700 Subject: [PATCH] Update song search screen --- lib/src/common_widgets/search_appbar.dart | 2 + .../presentation/home_screen/home_screen.dart | 4 +- .../songs_list_params_state.dart | 20 ++++++ .../songs_list_screen/songs_list_screen.dart | 57 +++++++++++++++++ lib/src/routing/app_route_context.dart | 5 ++ lib/src/routing/app_router.dart | 45 +++++++++----- .../songs_list_screen_robot.dart | 62 +++++++++++++++++++ .../songs_list_screen_test.dart | 33 ++++++++++ test/src/mocks.dart | 3 + 9 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 lib/src/features/songs/presentation/songs_list_screen/songs_list_params_state.dart create mode 100644 lib/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart create mode 100644 test/src/features/songs/presentation/songs_list_screen/songs_list_screen_robot.dart create mode 100644 test/src/features/songs/presentation/songs_list_screen/songs_list_screen_test.dart diff --git a/lib/src/common_widgets/search_appbar.dart b/lib/src/common_widgets/search_appbar.dart index c439ce28..356c49cb 100644 --- a/lib/src/common_widgets/search_appbar.dart +++ b/lib/src/common_widgets/search_appbar.dart @@ -6,6 +6,7 @@ class SearchAppBar extends StatefulWidget implements PreferredSizeWidget { static const iconClearKey = Key('icon-clear'); static const iconSearchKey = Key('icon-search'); static const textTitleKey = Key('text-title'); + static const searchInputKey = Key('search-input'); const SearchAppBar( {super.key, @@ -58,6 +59,7 @@ class _SearchAppBarState extends State { return AppBar( title: (showTextInput) ? TextField( + key: SearchAppBar.searchInputKey, controller: _textEditingController, onChanged: (value) { if (widget.onChanged != null) { diff --git a/lib/src/features/home/presentation/home_screen/home_screen.dart b/lib/src/features/home/presentation/home_screen/home_screen.dart index 6ec4681b..c587c942 100644 --- a/lib/src/features/home/presentation/home_screen/home_screen.dart +++ b/lib/src/features/home/presentation/home_screen/home_screen.dart @@ -34,7 +34,9 @@ class HomeScreen extends StatelessWidget { ShortcutMenuButton( title: 'Songs', iconData: Icons.music_note, - onPressed: () {}, + onPressed: () { + context.goSongSearchScreen(); + }, ), ShortcutMenuButton( title: 'Artists', diff --git a/lib/src/features/songs/presentation/songs_list_screen/songs_list_params_state.dart b/lib/src/features/songs/presentation/songs_list_screen/songs_list_params_state.dart new file mode 100644 index 00000000..12adc6a5 --- /dev/null +++ b/lib/src/features/songs/presentation/songs_list_screen/songs_list_params_state.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:vocadb_app/src/features/settings/data/user_settings_repository.dart'; +import 'package:vocadb_app/src/features/songs/domain/songs_list_params.dart'; + +class SongsListParamsState extends StateNotifier { + SongsListParamsState({String lang = 'Default'}) + : super(SongsListParams(lang: lang)); + void updateSongTypes(String? value) => + state = state.copyWith(songTypes: value); + void updateQuery(String value) => state = state.copyWith(query: value); + void updateSort(String value) => state = state.copyWith(sort: value); + void clearQuery() => state = state.copyWith(query: null); +} + +final songsListParamsStateProvider = StateNotifierProvider.autoDispose< + SongsListParamsState, SongsListParams>((ref) { + final preferredLang = ref.watch(userSettingsRepositoryProvider + .select((value) => value.currentPreferredLang)); + return SongsListParamsState(lang: preferredLang); +}); diff --git a/lib/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart b/lib/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart new file mode 100644 index 00000000..82ddaea2 --- /dev/null +++ b/lib/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart @@ -0,0 +1,57 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:vocadb_app/src/common_widgets/async_value_widget.dart'; +import 'package:vocadb_app/src/common_widgets/search_appbar.dart'; +import 'package:vocadb_app/src/features/songs/data/song_repository.dart'; +import 'package:vocadb_app/src/features/songs/domain/song.dart'; +import 'package:vocadb_app/src/features/songs/presentation/songs_list/songs_list_view.dart'; +import 'package:vocadb_app/src/features/songs/presentation/songs_list_screen/songs_list_params_state.dart'; + +class SongsListScreen extends ConsumerWidget { + const SongsListScreen({super.key, this.onSelectSong}); + + static const filterKey = Key('icon-filter-key'); + + final Function(Song)? onSelectSong; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: SearchAppBar( + titleText: 'Songs', + actions: [ + IconButton( + key: filterKey, + icon: const Icon(Icons.tune), + onPressed: () => {} + ), + ], + onSubmitted: (value) { + ref.read(songsListParamsStateProvider.notifier).updateQuery(value); + }, + onCleared: () { + ref.read(songsListParamsStateProvider.notifier).clearQuery(); + }, + ), + body: Consumer(builder: ((context, ref, child) { + final value = ref.watch(songsListProvider); + return AsyncValueWidget( + value: value, + data: (data) { + return SongListView( + songs: data, + onSelect: (song) => {} + ); + }); + })), + ); + } +} + +// State +final songsListProvider = FutureProvider.autoDispose>((ref) { + final params = ref.watch(songsListParamsStateProvider); + final songRepository = ref.watch(songRepositoryProvider); + return songRepository.fetchSongsList(params: params); +}); diff --git a/lib/src/routing/app_route_context.dart b/lib/src/routing/app_route_context.dart index c6a32bfb..56a6eb0a 100644 --- a/lib/src/routing/app_route_context.dart +++ b/lib/src/routing/app_route_context.dart @@ -34,6 +34,11 @@ extension AppRouteContext on BuildContext { } + Future goSongSearchScreen() async { + goNamed(AppRoute.songsList.name); + + } + Future goArtistsListFilterScreen() async { goNamed(AppRoute.artistsListFilter.name); diff --git a/lib/src/routing/app_router.dart b/lib/src/routing/app_router.dart index b4fc1b44..c2b1252e 100644 --- a/lib/src/routing/app_router.dart +++ b/lib/src/routing/app_router.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:vocadb_app/src/features/albums/domain/album.dart'; import 'package:vocadb_app/src/features/albums/presentation/album_detail_screen/album_detail_screen.dart'; +import 'package:vocadb_app/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart'; import 'package:vocadb_app/src/features/users/presentation/user_albums_screen/user_albums_filter_screen.dart'; import 'package:vocadb_app/src/features/users/presentation/user_albums_screen/user_albums_screen.dart'; import 'package:vocadb_app/src/features/artists/presentation/artist_detail_screen/artist_detail_screen.dart'; @@ -35,6 +36,7 @@ enum AppRoute { songDetail, account, signIn, + songsList, albumDetail, artistDetail, artistsList, @@ -84,22 +86,31 @@ final goRouterProvider = Provider.autoDispose( builder: (context, state) => const MainScreen(), routes: [ GoRoute( - path: 'S/:id', - name: AppRoute.songDetail.name, + path: 'S', + name: AppRoute.songsList.name, builder: (context, state) { - final songId = state.pathParameters['id']!; - return SongDetailScreen(song: Song(id: int.parse(songId))); + return SongsListScreen(onSelectSong: (song) { + context.goSongDetail(song); + }); }, + routes: [ + GoRoute( + path: ':id', + name: AppRoute.songDetail.name, + builder: (context, state) { + final songId = state.pathParameters['id']!; + return SongDetailScreen(song: Song(id: int.parse(songId))); + }, + ), + ], ), GoRoute( path: 'Ar', name: AppRoute.artistsList.name, builder: (context, state) { - return ArtistsListScreen( - onSelectArtist: (artist) { - context.goArtistDetail(artist); - } - ); + return ArtistsListScreen(onSelectArtist: (artist) { + context.goArtistDetail(artist); + }); }, routes: [ GoRoute( @@ -111,14 +122,14 @@ final goRouterProvider = Provider.autoDispose( child: const ArtistsFilterScreen(), ), ), - GoRoute( - path: ':id', - name: AppRoute.artistDetail.name, - builder: (context, state) { - final artistId = state.pathParameters['id']!; - return ArtistDetailScreen(artistId: artistId); - }, - ), + GoRoute( + path: ':id', + name: AppRoute.artistDetail.name, + builder: (context, state) { + final artistId = state.pathParameters['id']!; + return ArtistDetailScreen(artistId: artistId); + }, + ), ], ), GoRoute( diff --git a/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_robot.dart b/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_robot.dart new file mode 100644 index 00000000..0b337648 --- /dev/null +++ b/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_robot.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vocadb_app/src/common_widgets/search_appbar.dart'; +import 'package:vocadb_app/src/features/settings/data/user_settings_repository.dart'; +import 'package:vocadb_app/src/features/songs/data/song_repository.dart'; +import 'package:vocadb_app/src/features/songs/presentation/song_tile/song_tile.dart'; +import 'package:vocadb_app/src/features/songs/presentation/songs_list_screen/songs_list_screen.dart'; + +class SongsListScreenRobot { + final WidgetTester tester; + + SongsListScreenRobot(this.tester); + + Future pumpSongsListScreen( + {SongRepository? songRepository}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + if (songRepository != null) + songRepositoryProvider.overrideWithValue(songRepository), + userSettingsRepositoryProvider + .overrideWithValue(UserSettingsRepository()) + ], + child: const MaterialApp( + home: SongsListScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + } + + Future expectSongsDisplayCountAtLeast(int count) async { + final finder = find.byType(SongTile); + expect(finder, findsAtLeastNWidgets(count)); + } + + Future expectSongsDisplayCount(int count) async { + final finder = find.byType(SongTile); + expect(finder, findsNWidgets(count)); + } + + Future tapSearchIcon() async { + final finder = find.byKey(SearchAppBar.iconSearchKey); + expect(finder, findsOneWidget); + await tester.tap(finder); + await tester.pump(); + } + + Future typingSearchText(String text) async { + final finder = find.byKey(SearchAppBar.searchInputKey); + expect(finder, findsOneWidget); + await tester.enterText(finder, text); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + } +} diff --git a/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_test.dart b/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_test.dart new file mode 100644 index 00000000..bee74b32 --- /dev/null +++ b/test/src/features/songs/presentation/songs_list_screen/songs_list_screen_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:vocadb_app/src/features/songs/data/constants/fake_songs_list.dart'; +import 'package:vocadb_app/src/features/songs/domain/songs_list_params.dart'; + +import '../../../../mocks.dart'; +import 'songs_list_screen_robot.dart'; + +void main() { + testWidgets('song list screen test', (tester) async { + registerFallbackValue(FakeSongsListParams()); + + final r = SongsListScreenRobot(tester); + final songRepository = MockSongRepository(); + + when(() => songRepository.fetchSongsList( + params: any(named: 'params', that: isNotNull), + )).thenAnswer((_) => Future.value(kFakeSongsList)); + + await r.pumpSongsListScreen(songRepository: songRepository); + + await r.expectSongsDisplayCountAtLeast(3); + + expect( + verify(() => + songRepository.fetchSongsList(params: captureAny(named: 'params'))) + .captured, + [ + const SongsListParams(), + ]); + }); + +} \ No newline at end of file diff --git a/test/src/mocks.dart b/test/src/mocks.dart index 258f1e19..08d8929f 100644 --- a/test/src/mocks.dart +++ b/test/src/mocks.dart @@ -12,6 +12,7 @@ import 'package:vocadb_app/src/features/entries/data/entry_repository.dart'; import 'package:vocadb_app/src/features/releaseEvents/data/release_event_repository.dart'; import 'package:vocadb_app/src/features/settings/data/user_settings_repository.dart'; import 'package:vocadb_app/src/features/songs/data/song_repository.dart'; +import 'package:vocadb_app/src/features/songs/domain/songs_list_params.dart'; import 'package:vocadb_app/src/features/tags/data/tag_repository.dart'; import 'package:vocadb_app/src/features/users/data/user_repository.dart'; import 'package:vocadb_app/src/features/users/domain/rated_songs_list_params.dart'; @@ -36,6 +37,8 @@ class RatedSongsListParamsFake extends Fake implements RatedSongsListParams {} class FakeArtistsListParams extends Fake implements ArtistsListParams {} +class FakeSongsListParams extends Fake implements SongsListParams {} + class MockUserSettingsRepository extends Mock implements UserSettingsRepository {}