From 6b8ae88db4105039c6cbd40bc032a45febab7f63 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 8 Nov 2023 17:07:20 +0600 Subject: [PATCH] refactor: horizontal playbutton layout to use ListView and breakdown search page into sections --- lib/components/album/album_card.dart | 10 +- lib/components/artist/artist_album_list.dart | 49 +-- lib/components/genre/category_card.dart | 63 +--- .../horizontal_playbutton_card_view.dart | 96 ++++++ lib/pages/artist/artist.dart | 7 - lib/pages/home/genres.dart | 18 +- lib/pages/home/personalized.dart | 125 +------ lib/pages/search/search.dart | 308 ++---------------- lib/pages/search/sections/albums.dart | 39 +++ lib/pages/search/sections/artists.dart | 37 +++ lib/pages/search/sections/playlists.dart | 35 ++ lib/pages/search/sections/tracks.dart | 98 ++++++ 12 files changed, 374 insertions(+), 511 deletions(-) create mode 100644 lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart create mode 100644 lib/pages/search/sections/albums.dart create mode 100644 lib/pages/search/sections/artists.dart create mode 100644 lib/pages/search/sections/playlists.dart create mode 100644 lib/pages/search/sections/tracks.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 93b4cefc..945f8ecf 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -34,13 +33,6 @@ class AlbumCard extends HookConsumerWidget { [playlist, album.id], ); - final marginH = useBreakpointValue( - xs: 10, - sm: 10, - md: 15, - others: 20, - ); - final updating = useState(false); final spotify = ref.watch(spotifyProvider); @@ -49,7 +41,7 @@ class AlbumCard extends HookConsumerWidget { album.images, placeholder: ImagePlaceholder.collection, ), - margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), + margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlist.isFetching == true) || updating.value, diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 8fa9be87..e075cd60 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,11 +1,9 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -20,7 +18,6 @@ class ArtistAlbumList extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); final albumsQuery = useQueries.artist.albumsOf(ref, artistId); final albums = useMemoized(() { @@ -29,40 +26,16 @@ class ArtistAlbumList extends HookConsumerWidget { .toList(); }, [albumsQuery.pages]); - final hasNextPage = albumsQuery.pages.isEmpty - ? false - : (albumsQuery.pages.last.items?.length ?? 0) == 5; + final theme = Theme.of(context); - return Column( - children: [ - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - interactive: false, - controller: scrollController, - child: Waypoint( - controller: scrollController, - onTouchEdge: albumsQuery.fetchNext, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...albums.map((album) => AlbumCard(album)), - if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), - ], - ), - ), - ), - ), - ), - ], + return HorizontalPlaybuttonCardView( + hasNextPage: albumsQuery.hasNextPage, + items: albums, + onFetchMore: albumsQuery.fetchNext, + title: Text( + context.l10n.albums, + style: theme.textTheme.headlineSmall, + ), ); } } diff --git a/lib/components/genre/category_card.dart b/lib/components/genre/category_card.dart index a8d67771..d5809b5d 100644 --- a/lib/components/genre/category_card.dart +++ b/lib/components/genre/category_card.dart @@ -1,15 +1,10 @@ -import 'dart:ui'; - +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -24,7 +19,6 @@ class CategoryCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final scrollController = useScrollController(); final playlistQuery = useQueries.category.playlistsOf( ref, category.id!, @@ -33,7 +27,8 @@ class CategoryCard extends HookConsumerWidget { final playlists = useMemoized( () => playlistQuery.pages.expand( (page) { - return page.items?.where((i) => i != null) ?? const Iterable.empty(); + return page.items?.whereNotNull() ?? + const Iterable.empty(); }, ).toList(), [playlistQuery.pages], @@ -45,51 +40,11 @@ class CategoryCard extends HookConsumerWidget { return const SizedBox.shrink(); } - final mediaQuery = MediaQuery.of(context); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.name!, - style: Theme.of(context).textTheme.titleMedium, - ), - SizedBox( - height: mediaQuery.smAndDown ? 226 : 266, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: 8.0), - itemCount: playlists.length + 1, - itemBuilder: (context, index) { - if (index == playlists.length) { - if (!playlistQuery.hasNextPage) { - return const SizedBox.shrink(); - } - return Waypoint( - controller: scrollController, - onTouchEdge: playlistQuery.fetchNext, - isGrid: true, - child: const ShimmerPlaybuttonCard(), - ); - } - final playlist = playlists[index]; - return PlaylistCard(playlist); - }), - ), - ), - ], - ), + return HorizontalPlaybuttonCardView( + title: Text(category.name!), + hasNextPage: playlistQuery.hasNextPage, + items: playlists, + onFetchMore: playlistQuery.fetchNext, ); } } diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart new file mode 100644 index 00000000..a415d721 --- /dev/null +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -0,0 +1,96 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; +import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/hooks/use_breakpoint_value.dart'; + +class HorizontalPlaybuttonCardView extends HookWidget { + final Widget title; + final List items; + final VoidCallback onFetchMore; + final bool hasNextPage; + const HorizontalPlaybuttonCardView({ + required this.title, + required this.items, + required this.hasNextPage, + required this.onFetchMore, + Key? key, + }) : assert( + items is List || + items is List || + items is List, + ), + super(key: key); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final scrollController = useScrollController(); + final height = useBreakpointValue( + xs: 226, + sm: 226, + md: 236, + others: 266, + ); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: textTheme.titleMedium!, + child: title, + ), + SizedBox( + height: height, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(vertical: 8.0), + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index == items.length) { + if (!hasNextPage) { + return const SizedBox.shrink(); + } + return Waypoint( + controller: scrollController, + onTouchEdge: onFetchMore, + isGrid: true, + child: const ShimmerPlaybuttonCard(), + ); + } + final item = items[index]; + + return switch (item.runtimeType) { + PlaylistSimple => PlaylistCard(item as PlaylistSimple), + Album => AlbumCard(item as Album), + Artist => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: ArtistCard(item as Artist), + ), + _ => const SizedBox.shrink(), + }; + }), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 67a99d86..2f169583 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -410,13 +410,6 @@ class ArtistPage extends HookConsumerWidget { }, ), const SizedBox(height: 50), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.l10n.albums, - style: theme.textTheme.headlineSmall, - ), - ), ArtistAlbumList(artistId), const SizedBox(height: 20), Padding( diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart index db1c58c5..076305f2 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -85,15 +85,19 @@ class GenrePage extends HookConsumerWidget { controller: scrollController, itemCount: categories.length, itemBuilder: (context, index) { - return AnimatedCrossFade( - crossFadeState: searchController.text.isEmpty && + return AnimatedSwitcher( + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + duration: const Duration(milliseconds: 300), + child: searchController.text.isEmpty && index == categories.length - 1 && categoriesQuery.hasNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 300), - firstChild: const ShimmerCategories(), - secondChild: CategoryCard(categories[index]), + ? const ShimmerCategories() + : CategoryCard(categories[index]), ); }, ), diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart index 4f0b655f..bbffbc11 100644 --- a/lib/pages/home/personalized.dart +++ b/lib/pages/home/personalized.dart @@ -1,111 +1,16 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class PersonalizedItemCard extends HookWidget { - final Iterable? playlists; - final Iterable? albums; - final String title; - final bool hasNextPage; - final void Function() onFetchMore; - - PersonalizedItemCard({ - this.playlists, - this.albums, - required this.title, - required this.hasNextPage, - required this.onFetchMore, - Key? key, - }) : assert(playlists == null || albums == null), - super(key: key); - - final logger = getLogger(PersonalizedItemCard); - - @override - Widget build(BuildContext context) { - final scrollController = useScrollController(); - final mediaQuery = MediaQuery.of(context); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - SizedBox( - height: mediaQuery.smAndDown ? 226 : 266, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: scrollController, - interactive: false, - child: ListView.builder( - itemCount: (playlists?.length ?? albums?.length)! + 1, - padding: const EdgeInsets.symmetric(vertical: 8.0), - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - if (index == (playlists?.length ?? albums?.length)!) { - if (!hasNextPage) return const SizedBox.shrink(); - - return Waypoint( - controller: scrollController, - onTouchEdge: onFetchMore, - isGrid: true, - child: const ShimmerPlaybuttonCard(count: 1), - ); - } - - final item = playlists == null - ? albums!.elementAt(index) - : playlists!.elementAt(index); - - if (playlists == null) { - return AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album( - item as AlbumSimple, - ), - ); - } - - return PlaylistCard(item as PlaylistSimple); - }, - ), - ), - ), - ), - ], - ), - ); - } -} - class PersonalizedPage extends HookConsumerWidget { const PersonalizedPage({Key? key}) : super(key: key); @@ -133,10 +38,12 @@ class PersonalizedPage extends HookConsumerWidget { .whereType>() .expand((page) => page.items ?? const []) .where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }), + return album.artists + ?.any((artist) => userArtists.contains(artist.id!)) == + true; + }) + .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)) + .toList(), [newReleases.pages], ); @@ -149,18 +56,18 @@ class PersonalizedPage extends HookConsumerWidget { !featuredPlaylistsQuery.isLoadingNextPage) const ShimmerCategories() else - PersonalizedItemCard( - playlists: playlists, - title: context.l10n.featured, + HorizontalPlaybuttonCardView( + items: playlists.toList(), + title: Text(context.l10n.featured), hasNextPage: featuredPlaylistsQuery.hasNextPage, onFetchMore: featuredPlaylistsQuery.fetchNext, ), if (auth != null && newReleases.hasPageData && userArtistsQuery.hasData) - PersonalizedItemCard( - albums: albums, - title: context.l10n.new_releases, + HorizontalPlaybuttonCardView( + items: albums, + title: Text(context.l10n.new_releases), hasNextPage: newReleases.hasNextPage, onFetchMore: newReleases.fetchNext, ), @@ -172,9 +79,9 @@ class PersonalizedPage extends HookConsumerWidget { .cast() ?? []; if (playlists.isEmpty) return const SizedBox.shrink(); - return PersonalizedItemCard( - playlists: playlists, - title: item["name"] ?? "", + return HorizontalPlaybuttonCardView( + items: playlists, + title: Text(item["name"] ?? ""), hasNextPage: false, onFetchMore: () {}, ); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index c192eb7b..d659e8e3 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,30 +1,24 @@ import 'dart:async'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_table/track_tile.dart'; -import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/search/sections/albums.dart'; +import 'package:spotube/pages/search/sections/artists.dart'; +import 'package:spotube/pages/search/sections/playlists.dart'; +import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; final searchTermStateProvider = StateProvider((ref) => ""); @@ -38,9 +32,6 @@ class SearchPage extends HookConsumerWidget { ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); - final albumController = useScrollController(); - final playlistController = useScrollController(); - final artistController = useScrollController(); final mediaQuery = MediaQuery.of(context); final searchTerm = ref.watch(searchTermStateProvider); @@ -80,283 +71,26 @@ class SearchPage extends HookConsumerWidget { searchTerm.isNotEmpty; final resultWidget = HookBuilder( - builder: (context) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - List albums = []; - List artists = []; - List tracks = []; - List playlists = []; - final pages = [ - ...searchTrack.pages, - ...searchAlbum.pages, - ...searchPlaylist.pages, - ...searchArtist.pages, - ].expand((page) => page).toList(); - for (MapEntry page in pages.asMap().entries) { - for (var item in page.value.items ?? []) { - if (item is AlbumSimple) { - albums.add(item); - } else if (item is PlaylistSimple) { - playlists.add(item); - } else if (item is Artist) { - artists.add(item); - } else if (item is Track) { - tracks.add(item); - } - } - } - - return InterScrollbar( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tracks.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.songs, - style: theme.textTheme.titleLarge!, - ), - ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) - const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) - else - ...tracks.mapIndexed((i, track) { - return TrackTile( - index: i, - track: track, - onTap: () async { - final isTrackPlaying = - playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; - - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); - } - } - }, - ); - }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) - Center( - child: TextButton( - onPressed: searchTrack.isLoadingNextPage - ? null - : () => searchTrack.fetchNext(), - child: searchTrack.isLoadingNextPage - ? const CircularProgressIndicator() - : Text(context.l10n.load_more), - ), - ), - if (playlists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.playlists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: mediaQuery.lgAndUp - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: Waypoint( - onTouchEdge: () { - searchPlaylist.fetchNext(); - }, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - ...playlists.mapIndexed( - (i, playlist) { - if (i == playlists.length - 1 && - searchPlaylist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return PlaylistCard(playlist); - }, - ), - ], - ), - ), - ), - ), - ), - if (!searchPlaylist.hasPageData && - !searchPlaylist.hasPageError && - !searchPlaylist.isLoadingNextPage) - const CircularProgressIndicator(), - if (searchPlaylist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchPlaylist.errors.lastOrNull?.toString() ?? "", - ), - ), - const SizedBox(height: 20), - if (artists.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.artists, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: Waypoint( - controller: artistController, - onTouchEdge: () { - searchArtist.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: artistController, - child: Row( - children: [ - ...artists.mapIndexed( - (i, artist) { - if (i == artists.length - 1 && - searchArtist.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 15), - child: ArtistCard(artist), - ); - }, - ), - ], - ), - ), - ), - ), - ), - if (!searchArtist.hasPageData && - !searchArtist.hasPageError && - !searchArtist.isLoadingNextPage) - const CircularProgressIndicator(), - if (searchArtist.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchArtist.errors.lastOrNull?.toString() ?? "", - ), - ), - const SizedBox(height: 20), - if (albums.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.albums, - style: theme.textTheme.titleLarge!, - ), - ), - const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: Waypoint( - controller: albumController, - onTouchEdge: () { - searchAlbum.fetchNext(); - }, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: [ - ...albums.mapIndexed((i, album) { - if (i == albums.length - 1 && - searchAlbum.hasNextPage) { - return const ShimmerPlaybuttonCard( - count: 1); - } - return AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album( - album, - ), - ); - }), - ], - ), - ), - ), - ), - ), - if (!searchAlbum.hasPageData && - !searchAlbum.hasPageError && - !searchAlbum.isLoadingNextPage) - const CircularProgressIndicator(), - if (searchAlbum.hasPageError) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - searchAlbum.errors.lastOrNull?.toString() ?? "", - ), - ), - ], - ), + builder: (context) => InterScrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SearchTracksSection(query: searchTrack), + SearchPlaylistsSection(query: searchPlaylist), + const SizedBox(height: 20), + SearchArtistsSection(query: searchArtist), + const SizedBox(height: 20), + SearchAlbumsSection(query: searchAlbum), + ], ), ), ), - ); - }, + ), + ), ); return SafeArea( diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart new file mode 100644 index 00000000..787a1924 --- /dev/null +++ b/lib/pages/search/sections/albums.dart @@ -0,0 +1,39 @@ +import 'package:fl_query/fl_query.dart'; + +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class SearchAlbumsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchAlbumsSection({ + required this.query, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final albums = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + hasNextPage: query.hasNextPage, + items: albums, + onFetchMore: query.fetchNext, + title: Text(context.l10n.albums), + ); + } +} diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart new file mode 100644 index 00000000..7abd5250 --- /dev/null +++ b/lib/pages/search/sections/artists.dart @@ -0,0 +1,37 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; + +class SearchArtistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + + const SearchArtistsSection({ + Key? key, + required this.query, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final artists = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + hasNextPage: query.hasNextPage, + items: artists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.albums), + ); + } +} diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart new file mode 100644 index 00000000..620e914b --- /dev/null +++ b/lib/pages/search/sections/playlists.dart @@ -0,0 +1,35 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; + +class SearchPlaylistsSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchPlaylistsSection({ + required this.query, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlists = useMemoized( + () => query.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType() + .toList(), + [query.pages], + ); + + return HorizontalPlaybuttonCardView( + hasNextPage: query.hasNextPage, + items: playlists, + onFetchMore: query.fetchNext, + title: Text(context.l10n.playlists), + ); + } +} diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart new file mode 100644 index 00000000..59c6a4e1 --- /dev/null +++ b/lib/pages/search/sections/tracks.dart @@ -0,0 +1,98 @@ +import 'package:collection/collection.dart'; +import 'package:fl_query/fl_query.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/track_table/track_tile.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; + +class SearchTracksSection extends HookConsumerWidget { + final InfiniteQuery>, dynamic, int> query; + const SearchTracksSection({ + Key? key, + required this.query, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final searchTrack = query; + final tracks = useMemoized( + () => searchTrack.pages + .expand( + (page) => page.map((p) => p.items!).expand((element) => element), + ) + .whereType(), + [searchTrack.pages], + ); + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + context.l10n.songs, + style: theme.textTheme.titleLarge!, + ), + ), + if (!searchTrack.hasPageData && + !searchTrack.hasPageError && + !searchTrack.isLoadingNextPage) + const CircularProgressIndicator() + else if (searchTrack.hasPageError) + Text( + searchTrack.errors.lastOrNull?.toString() ?? "", + ) + else + ...tracks.mapIndexed((i, track) { + return TrackTile( + index: i, + track: track, + onTap: () async { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isLoadingNextPage + ? null + : () => searchTrack.fetchNext(), + child: searchTrack.isLoadingNextPage + ? const CircularProgressIndicator() + : Text(context.l10n.load_more), + ), + ) + ], + ); + } +}