mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
refactor: expandable filter field on genre and user local tracks page
This commit is contained in:
parent
dce1b88694
commit
20274b1c65
@ -15,7 +15,7 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.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/shimmers/shimmer_track_tile.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
@ -160,7 +160,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||||
final isMounted = useIsMounted();
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
final searchText = useState<String>("");
|
final searchController = useTextEditingController();
|
||||||
|
useValueListenable(searchController);
|
||||||
|
final searchFocus = useFocusNode();
|
||||||
|
final isFiltering = useState(false);
|
||||||
|
|
||||||
useAsyncEffect(
|
useAsyncEffect(
|
||||||
() async {
|
() 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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@ -213,7 +211,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
searchbar,
|
ExpandableSearchButton(
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
SortTracksDropdown(
|
SortTracksDropdown(
|
||||||
value: sortBy.value,
|
value: sortBy.value,
|
||||||
@ -231,6 +232,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ExpandableSearchField(
|
||||||
|
searchController: searchController,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
),
|
||||||
trackSnapshot.when(
|
trackSnapshot.when(
|
||||||
data: (tracks) {
|
data: (tracks) {
|
||||||
final sortedTracks = useMemoized(() {
|
final sortedTracks = useMemoized(() {
|
||||||
@ -238,14 +244,14 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
}, [sortBy.value, tracks]);
|
}, [sortBy.value, tracks]);
|
||||||
|
|
||||||
final filteredTracks = useMemoized(() {
|
final filteredTracks = useMemoized(() {
|
||||||
if (searchText.value.isEmpty) {
|
if (searchController.text.isEmpty) {
|
||||||
return sortedTracks;
|
return sortedTracks;
|
||||||
}
|
}
|
||||||
return sortedTracks
|
return sortedTracks
|
||||||
.map((e) => (
|
.map((e) => (
|
||||||
weightedRatio(
|
weightedRatio(
|
||||||
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
||||||
searchText.value,
|
searchController.text,
|
||||||
),
|
),
|
||||||
e,
|
e,
|
||||||
))
|
))
|
||||||
@ -257,7 +263,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
.map((e) => e.$2)
|
.map((e) => e.$2)
|
||||||
.toList()
|
.toList()
|
||||||
.toList();
|
.toList();
|
||||||
}, [searchText.value, sortedTracks]);
|
}, [searchController.text, sortedTracks]);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
|
@ -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<bool> 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<bool> isFiltering;
|
||||||
|
final FocusNode searchFocus;
|
||||||
|
final Widget icon;
|
||||||
|
final ValueChanged<bool>? 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.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/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/fallbacks/not_found.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
@ -168,26 +169,12 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
.state = value;
|
.state = value;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
ExpandableSearchButton(
|
||||||
tooltip: context.l10n.filter_playlists,
|
isFiltering: isFiltering,
|
||||||
icon: const Icon(SpotubeIcons.filter),
|
searchFocus: searchFocus,
|
||||||
style: IconButton.styleFrom(
|
onPressed: (value) {
|
||||||
foregroundColor: isFiltering.value
|
|
||||||
? theme.colorScheme.secondary
|
|
||||||
: null,
|
|
||||||
backgroundColor: isFiltering.value
|
|
||||||
? theme.colorScheme.secondaryContainer
|
|
||||||
: null,
|
|
||||||
minimumSize: const Size(22, 22),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
isFiltering.value = !isFiltering.value;
|
|
||||||
if (isFiltering.value) {
|
if (isFiltering.value) {
|
||||||
onFiltering?.call();
|
onFiltering?.call();
|
||||||
searchFocus.requestFocus();
|
|
||||||
} else {
|
|
||||||
searchController.clear();
|
|
||||||
searchFocus.unfocus();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -302,36 +289,10 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
AnimatedOpacity(
|
ExpandableSearchField(
|
||||||
duration: const Duration(milliseconds: 200),
|
isFiltering: isFiltering,
|
||||||
opacity: isFiltering.value ? 1 : 0,
|
searchController: searchController,
|
||||||
child: AnimatedSize(
|
searchFocus: searchFocus,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
...sortedTracks.mapIndexed((i, track) {
|
...sortedTracks.mapIndexed((i, track) {
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.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/genre/category_card.dart';
|
||||||
import 'package:spotube/components/shared/compact_search.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/shimmers/shimmer_categories.dart';
|
||||||
import 'package:spotube/components/shared/waypoint.dart';
|
import 'package:spotube/components/shared/waypoint.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
@ -18,15 +21,21 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
final recommendationMarket = ref.watch(
|
final recommendationMarket = ref.watch(
|
||||||
userPreferencesProvider.select((s) => s.recommendationMarket),
|
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||||
);
|
);
|
||||||
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
||||||
|
final isFiltering = useState(false);
|
||||||
|
|
||||||
final isMounted = useIsMounted();
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
final searchText = useState("");
|
final searchController = useTextEditingController();
|
||||||
|
final searchFocus = useFocusNode();
|
||||||
|
|
||||||
|
useValueListenable(searchController);
|
||||||
|
|
||||||
final categories = useMemoized(
|
final categories = useMemoized(
|
||||||
() {
|
() {
|
||||||
final categories = categoriesQuery.pages
|
final categories = categoriesQuery.pages
|
||||||
@ -34,12 +43,12 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
(page) => page.items ?? const Iterable.empty(),
|
(page) => page.items ?? const Iterable.empty(),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (searchText.value.isEmpty) {
|
if (searchController.text.isEmpty) {
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
return categories
|
return categories
|
||||||
.map((e) => (
|
.map((e) => (
|
||||||
weightedRatio(e.name!, searchText.value),
|
weightedRatio(e.name!, searchController.text),
|
||||||
e,
|
e,
|
||||||
))
|
))
|
||||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||||
@ -47,14 +56,7 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
.map((e) => e.$2)
|
.map((e) => e.$2)
|
||||||
.toList();
|
.toList();
|
||||||
},
|
},
|
||||||
[categoriesQuery.pages, searchText.value],
|
[categoriesQuery.pages, searchController.text],
|
||||||
);
|
|
||||||
|
|
||||||
final searchbar = CompactSearch(
|
|
||||||
onChanged: (value) {
|
|
||||||
searchText.value = value;
|
|
||||||
},
|
|
||||||
placeholder: context.l10n.genre_categories_filter,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final list = RefreshIndicator(
|
final list = RefreshIndicator(
|
||||||
@ -68,13 +70,20 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ExpandableSearchField(
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
searchController: searchController,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
itemCount: categories.length,
|
itemCount: categories.length,
|
||||||
shrinkWrap: true,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return AnimatedCrossFade(
|
return AnimatedCrossFade(
|
||||||
crossFadeState: searchText.value.isEmpty &&
|
crossFadeState: searchController.text.isEmpty &&
|
||||||
index == categories.length - 1 &&
|
index == categories.length - 1 &&
|
||||||
categoriesQuery.hasNextPage
|
categoriesQuery.hasNextPage
|
||||||
? CrossFadeState.showFirst
|
? CrossFadeState.showFirst
|
||||||
@ -86,6 +95,9 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
@ -94,7 +106,20 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 10,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user