diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 2431956e..217e2d7f 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -15,7 +15,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/compact_search.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; @@ -160,7 +160,10 @@ class UserLocalTracks extends HookConsumerWidget { playlist.containsTracks(trackSnapshot.value ?? []); final isMounted = useIsMounted(); - final searchText = useState(""); + final searchController = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); useAsyncEffect( () async { @@ -175,11 +178,6 @@ class UserLocalTracks extends HookConsumerWidget { [], ); - final searchbar = CompactSearch( - onChanged: (value) => searchText.value = value, - placeholder: context.l10n.search_local_tracks, - ); - return Column( children: [ Padding( @@ -213,7 +211,10 @@ class UserLocalTracks extends HookConsumerWidget { ), ), const Spacer(), - searchbar, + ExpandableSearchButton( + isFiltering: isFiltering, + searchFocus: searchFocus, + ), const SizedBox(width: 10), SortTracksDropdown( value: sortBy.value, @@ -231,6 +232,11 @@ class UserLocalTracks extends HookConsumerWidget { ], ), ), + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering, + ), trackSnapshot.when( data: (tracks) { final sortedTracks = useMemoized(() { @@ -238,14 +244,14 @@ class UserLocalTracks extends HookConsumerWidget { }, [sortBy.value, tracks]); final filteredTracks = useMemoized(() { - if (searchText.value.isEmpty) { + if (searchController.text.isEmpty) { return sortedTracks; } return sortedTracks .map((e) => ( weightedRatio( "${e.name} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}", - searchText.value, + searchController.text, ), e, )) @@ -257,7 +263,7 @@ class UserLocalTracks extends HookConsumerWidget { .map((e) => e.$2) .toList() .toList(); - }, [searchText.value, sortedTracks]); + }, [searchController.text, sortedTracks]); return Expanded( child: RefreshIndicator( diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart new file mode 100644 index 00000000..684e373e --- /dev/null +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; + +class ExpandableSearchField extends StatelessWidget { + final ValueNotifier isFiltering; + final TextEditingController searchController; + final FocusNode searchFocus; + + const ExpandableSearchField({ + Key? key, + required this.isFiltering, + required this.searchController, + required this.searchFocus, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isFiltering.value ? 1 : 0, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + child: SizedBox( + height: isFiltering.value ? 50 : 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + isFiltering.value = false; + searchController.clear(); + searchFocus.unfocus(); + } + }, + child: TextField( + focusNode: searchFocus, + controller: searchController, + decoration: InputDecoration( + hintText: context.l10n.search_tracks, + isDense: true, + prefixIcon: const Icon(SpotubeIcons.search), + ), + ), + ), + ), + ), + ), + ); + } +} + +class ExpandableSearchButton extends StatelessWidget { + final ValueNotifier isFiltering; + final FocusNode searchFocus; + final Widget icon; + final ValueChanged? onPressed; + + const ExpandableSearchButton({ + Key? key, + required this.isFiltering, + required this.searchFocus, + this.icon = const Icon(SpotubeIcons.filter), + this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return IconButton( + icon: icon, + style: IconButton.styleFrom( + backgroundColor: + isFiltering.value ? theme.colorScheme.secondaryContainer : null, + foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null, + minimumSize: const Size(25, 25), + ), + onPressed: () { + isFiltering.value = !isFiltering.value; + if (isFiltering.value) { + searchFocus.requestFocus(); + } else { + searchFocus.unfocus(); + } + onPressed?.call(isFiltering.value); + }, + ); + } +} diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index d9589552..f6340b33 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -10,6 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; @@ -168,26 +169,12 @@ class TracksTableView extends HookConsumerWidget { .state = value; }, ), - IconButton( - tooltip: context.l10n.filter_playlists, - icon: const Icon(SpotubeIcons.filter), - style: IconButton.styleFrom( - foregroundColor: isFiltering.value - ? theme.colorScheme.secondary - : null, - backgroundColor: isFiltering.value - ? theme.colorScheme.secondaryContainer - : null, - minimumSize: const Size(22, 22), - ), - onPressed: () { - isFiltering.value = !isFiltering.value; + ExpandableSearchButton( + isFiltering: isFiltering, + searchFocus: searchFocus, + onPressed: (value) { if (isFiltering.value) { onFiltering?.call(); - searchFocus.requestFocus(); - } else { - searchController.clear(); - searchFocus.unfocus(); } }, ), @@ -302,36 +289,10 @@ class TracksTableView extends HookConsumerWidget { ], ); }), - AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isFiltering.value ? 1 : 0, - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - child: SizedBox( - height: isFiltering.value ? 50 : 0, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - isFiltering.value = false; - searchController.clear(); - searchFocus.unfocus(); - } - }, - child: TextField( - focusNode: searchFocus, - controller: searchController, - decoration: InputDecoration( - hintText: context.l10n.search_tracks, - isDense: true, - prefixIcon: const Icon(SpotubeIcons.search), - ), - ), - ), - ), - ), - ), + ExpandableSearchField( + isFiltering: isFiltering, + searchController: searchController, + searchFocus: searchFocus, ), ...sortedTracks.mapIndexed((i, track) { return TrackTile( diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart index 8bc4fd36..2aad00f7 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/genre/category_card.dart'; import 'package:spotube/components/shared/compact_search.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/context.dart'; @@ -18,15 +21,21 @@ class GenrePage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final theme = Theme.of(context); final scrollController = useScrollController(); final recommendationMarket = ref.watch( userPreferencesProvider.select((s) => s.recommendationMarket), ); final categoriesQuery = useQueries.category.list(ref, recommendationMarket); + final isFiltering = useState(false); final isMounted = useIsMounted(); - final searchText = useState(""); + final searchController = useTextEditingController(); + final searchFocus = useFocusNode(); + + useValueListenable(searchController); + final categories = useMemoized( () { final categories = categoriesQuery.pages @@ -34,12 +43,12 @@ class GenrePage extends HookConsumerWidget { (page) => page.items ?? const Iterable.empty(), ) .toList(); - if (searchText.value.isEmpty) { + if (searchController.text.isEmpty) { return categories; } return categories .map((e) => ( - weightedRatio(e.name!, searchText.value), + weightedRatio(e.name!, searchController.text), e, )) .sorted((a, b) => b.$1.compareTo(a.$1)) @@ -47,14 +56,7 @@ class GenrePage extends HookConsumerWidget { .map((e) => e.$2) .toList(); }, - [categoriesQuery.pages, searchText.value], - ); - - final searchbar = CompactSearch( - onChanged: (value) { - searchText.value = value; - }, - placeholder: context.l10n.genre_categories_filter, + [categoriesQuery.pages, searchController.text], ); final list = RefreshIndicator( @@ -68,22 +70,32 @@ class GenrePage extends HookConsumerWidget { } }, controller: scrollController, - child: ListView.builder( - controller: scrollController, - itemCount: categories.length, - shrinkWrap: true, - itemBuilder: (context, index) { - return AnimatedCrossFade( - crossFadeState: searchText.value.isEmpty && - index == categories.length - 1 && - categoriesQuery.hasNextPage - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 300), - firstChild: const ShimmerCategories(), - secondChild: CategoryCard(categories[index]), - ); - }, + child: Column( + children: [ + ExpandableSearchField( + isFiltering: isFiltering, + searchController: searchController, + searchFocus: searchFocus, + ), + Expanded( + child: ListView.builder( + controller: scrollController, + itemCount: categories.length, + itemBuilder: (context, index) { + return AnimatedCrossFade( + crossFadeState: 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]), + ); + }, + ), + ), + ], ), ), ); @@ -94,7 +106,20 @@ class GenrePage extends HookConsumerWidget { Positioned( top: 0, right: 10, - child: searchbar, + child: ExpandableSearchButton( + isFiltering: isFiltering, + searchFocus: searchFocus, + icon: const Icon(SpotubeIcons.search), + onPressed: (value) { + if (isFiltering.value) { + scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + ), ), ], );