feat: Improve library search screen (#477)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-08-28 23:26:10 +02:00 committed by GitHub
parent 571b682b80
commit d22d340181
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2881 additions and 2026 deletions

View file

@ -9,6 +9,7 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/boxset_model.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/photos_model.dart';
import 'package:fladder/models/library_filter_model.dart';
import 'package:fladder/models/library_search/library_search_model.dart';
import 'package:fladder/models/library_search/library_search_options.dart';
import 'package:fladder/models/playlist_model.dart';
@ -44,7 +45,6 @@ import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
import 'package:fladder/widgets/shared/poster_size_slider.dart';
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
import 'package:fladder/widgets/shared/scroll_position.dart';
import 'package:fladder/widgets/shared/shapes.dart';
@RoutePage()
class LibrarySearchScreen extends ConsumerStatefulWidget {
@ -53,6 +53,9 @@ class LibrarySearchScreen extends ConsumerStatefulWidget {
final List<String>? folderId;
final SortingOrder? sortOrder;
final SortingOptions? sortingOptions;
final Map<FladderItemType, bool>? types;
final Map<String, bool>? genres;
final bool recursive;
final PhotoModel? photoToView;
const LibrarySearchScreen({
@QueryParam("parentId") this.viewModelId,
@ -60,6 +63,9 @@ class LibrarySearchScreen extends ConsumerStatefulWidget {
@QueryParam("favourites") this.favourites,
@QueryParam("sortOrder") this.sortOrder,
@QueryParam("sortOptions") this.sortingOptions,
@QueryParam("itemTypes") this.types,
@QueryParam("genres") this.genres,
@QueryParam("recursive") this.recursive = true,
this.photoToView,
super.key,
});
@ -69,7 +75,6 @@ class LibrarySearchScreen extends ConsumerStatefulWidget {
}
class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
final SearchController searchController = SearchController();
final Debouncer debouncer = Debouncer(const Duration(seconds: 1));
final GlobalKey<RefreshIndicatorState> refreshKey = GlobalKey<RefreshIndicatorState>();
final ScrollController scrollController = ScrollController();
@ -93,32 +98,24 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
@override
void initState() {
super.initState();
initLibrary();
WidgetsBinding.instance.addPostFrameCallback((value) {
initLibrary();
});
}
void initLibrary() {
searchController.addListener(() {
debouncer.run(() {
ref.read(providerKey.notifier).setSearch(searchController.text);
});
});
Future.microtask(
() async {
await refreshKey.currentState?.show();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: [],
);
if (context.mounted && widget.photoToView != null) {
libraryProvider.viewGallery(context, selected: widget.photoToView);
}
scrollController.addListener(() {
scrollPosition();
});
},
Future<void> initLibrary() async {
await refreshKey.currentState?.show();
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: [],
);
if (context.mounted && widget.photoToView != null) {
libraryProvider.viewGallery(context, selected: widget.photoToView);
}
scrollController.addListener(() {
scrollPosition();
});
}
void scrollPosition() {
@ -127,19 +124,27 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
}
}
Future<void> refreshSearch() async {
await refreshKey.currentState?.show();
scrollController.jumpTo(0);
}
@override
Widget build(BuildContext context) {
final surfaceColor = Theme.of(context).colorScheme.surface;
final isEmptySearchScreen = widget.viewModelId == null && widget.favourites == null && widget.folderId == null;
final librarySearchResults = ref.watch(providerKey);
final postersList = librarySearchResults.posters.hideEmptyChildren(librarySearchResults.hideEmptyShows);
final postersList = librarySearchResults.posters.hideEmptyChildren(librarySearchResults.filters.hideEmptyShows);
final libraryViewType = ref.watch(libraryViewTypeProvider);
final floatingAppBar = AdaptiveLayout.layoutModeOf(context) != LayoutMode.single;
ref.listen(
providerKey,
(previous, next) {
if (previous != next) {
refreshKey.currentState?.show();
scrollController.jumpTo(0);
if (previous?.filters != next.filters) {
refreshSearch();
}
},
);
@ -153,20 +158,17 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
}
},
child: NestedScaffold(
background: BackgroundImage(items: librarySearchResults.activePosters),
background: BackgroundImage(images: postersList.map((e) => e.images).nonNulls.toList()),
body: Padding(
padding: EdgeInsets.only(left: AdaptiveLayout.of(context).sideBarWidth),
child: Scaffold(
extendBody: true,
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
floatingActionButton: HideOnScroll(
controller: scrollController,
visibleBuilder: (visible) => Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (librarySearchResults.activePosters.isNotEmpty)
FloatingActionButtonAnimated(
visibleBuilder: (visible) => librarySearchResults.activePosters.isNotEmpty
? FloatingActionButtonAnimated(
key: Key(context.localized.playLabel),
isExtended: visible,
tooltip: context.localized.playVideos,
@ -192,12 +194,12 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
},
label: Text(context.localized.playLabel),
icon: const Icon(IconsaxPlusBold.play),
),
].addInBetween(const SizedBox(height: 10)),
),
)
: null,
),
bottomNavigationBar: HideOnScroll(
controller: AdaptiveLayout.of(context).isDesktop ? null : scrollController,
controller: scrollController,
canHide: !floatingAppBar,
child: IgnorePointer(
ignoring: librarySearchResults.fetchingItems,
child: _LibrarySearchBottomBar(
@ -209,319 +211,306 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
),
),
),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: PinchPosterZoom(
scaleDifference: (difference) =>
ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
child: ClipRRect(
borderRadius: AdaptiveLayout.viewSizeOf(context) == ViewSize.desktop
? BorderRadius.circular(15)
: BorderRadius.circular(0),
child: FladderScrollbar(
visible: AdaptiveLayout.of(context).inputDevice != InputDevice.pointer,
controller: scrollController,
child: PullToRefresh(
refreshKey: refreshKey,
autoFocus: false,
contextRefresh: false,
onRefresh: () async {
if (libraryProvider.mounted) {
return libraryProvider.initRefresh(
widget.folderId,
widget.viewModelId,
widget.favourites,
widget.sortOrder,
widget.sortingOptions,
);
}
},
refreshOnStart: false,
child: CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
floating: !AdaptiveLayout.of(context).isDesktop,
collapsedHeight: 80,
automaticallyImplyLeading: false,
pinned: AdaptiveLayout.of(context).isDesktop,
primary: true,
elevation: 5,
leading: context.router.backButton(),
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: AppBarShape(),
titleSpacing: 4,
leadingWidth: 48,
actions: [
const SizedBox(width: 4),
Builder(builder: (context) {
final isFavorite =
librarySearchResults.nestedCurrentItem?.userData.isFavourite == true;
final itemActions = librarySearchResults.nestedCurrentItem?.generateActions(
context,
ref,
exclude: {
ItemActions.details,
ItemActions.markPlayed,
ItemActions.markUnplayed,
},
onItemUpdated: (item) {
libraryProvider.updateParentItem(item);
},
onUserDataChanged: (userData) {
libraryProvider.updateUserDataMain(userData);
},
) ??
[];
final itemCountWidget = ItemActionButton(
label: Text(context.localized.itemCount(librarySearchResults.totalItemCount)),
icon: const Icon(IconsaxPlusBold.document_1),
);
final refreshAction = ItemActionButton(
label: Text(context.localized.forceRefresh),
action: () => refreshKey.currentState?.show(),
icon: const Icon(IconsaxPlusLinear.refresh),
);
final showSavedFiltersDialogue = ItemActionButton(
label: Text(context.localized.filter(2)),
action: () => showSavedFilters(context, librarySearchResults, libraryProvider),
icon: const Icon(IconsaxPlusLinear.refresh),
);
final itemViewAction = ItemActionButton(
label: Text(context.localized.selectViewType),
icon: Icon(libraryViewType.icon),
action: () {
showAdaptiveDialog(
context: context,
builder: (context) => AlertDialog(
content: Consumer(
builder: (context, ref, child) {
final currentType = ref.watch(libraryViewTypeProvider);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(context.localized.selectViewType,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...LibraryViewTypes.values
.map(
(e) => FilledButton.tonal(
style: FilledButtonTheme.of(context).style?.copyWith(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(
horizontal: 12, vertical: 24)),
backgroundColor: WidgetStateProperty.resolveWith(
(states) {
if (e != currentType) {
return Colors.transparent;
}
return null;
},
),
),
onPressed: () {
ref.read(libraryViewTypeProvider.notifier).state = e;
body: PinchPosterZoom(
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
child: FladderScrollbar(
visible: AdaptiveLayout.of(context).inputDevice != InputDevice.pointer,
controller: scrollController,
child: PullToRefresh(
refreshKey: refreshKey,
autoFocus: false,
contextRefresh: false,
onRefresh: () async {
final defaultFilter = const LibraryFilterModel();
if (libraryProvider.mounted) {
return libraryProvider.initRefresh(
widget.folderId,
widget.viewModelId,
defaultFilter.copyWith(
favourites: widget.favourites ?? defaultFilter.favourites,
sortOrder: widget.sortOrder ?? defaultFilter.sortOrder,
sortingOption: widget.sortingOptions ?? defaultFilter.sortingOption,
types: widget.types ?? {},
genres: widget.genres ?? {},
recursive: widget.recursive,
),
);
}
},
refreshOnStart: false,
child: CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
floating: !floatingAppBar,
collapsedHeight: 80,
automaticallyImplyLeading: false,
primary: true,
pinned: floatingAppBar,
elevation: 5,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
backgroundColor: Colors.transparent,
titleSpacing: 4,
flexibleSpace: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
surfaceColor.withValues(alpha: 0.8),
surfaceColor.withValues(alpha: 0.75),
surfaceColor.withValues(alpha: 0.5),
surfaceColor.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
),
actions: [
Builder(builder: (context) {
final isFavorite = librarySearchResults.nestedCurrentItem?.userData.isFavourite == true;
final itemActions = librarySearchResults.nestedCurrentItem?.generateActions(
context,
ref,
exclude: {
ItemActions.details,
ItemActions.markPlayed,
ItemActions.markUnplayed,
},
onItemUpdated: (item) {
libraryProvider.updateParentItem(item);
},
onUserDataChanged: (userData) {
libraryProvider.updateUserDataMain(userData);
},
) ??
[];
final itemCountWidget = ItemActionButton(
label: Text(context.localized.itemCount(librarySearchResults.totalItemCount)),
icon: const Icon(IconsaxPlusBold.document_1),
);
final refreshAction = ItemActionButton(
label: Text(context.localized.forceRefresh),
action: () => refreshKey.currentState?.show(),
icon: const Icon(IconsaxPlusLinear.refresh),
);
final showSavedFiltersDialogue = ItemActionButton(
label: Text(context.localized.filter(2)),
action: () => showSavedFilters(context, uniqueKey),
icon: const Icon(IconsaxPlusLinear.refresh),
);
final itemViewAction = ItemActionButton(
label: Text(context.localized.selectViewType),
icon: Icon(libraryViewType.icon),
action: () {
showAdaptiveDialog(
context: context,
builder: (context) => AlertDialog(
content: Consumer(
builder: (context, ref, child) {
final currentType = ref.watch(libraryViewTypeProvider);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(context.localized.selectViewType,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...LibraryViewTypes.values
.map(
(e) => FilledButton.tonal(
style: FilledButtonTheme.of(context).style?.copyWith(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 12, vertical: 24)),
backgroundColor: WidgetStateProperty.resolveWith(
(states) {
if (e != currentType) {
return Colors.transparent;
}
return null;
},
child: Row(
children: [
Icon(e.icon),
const SizedBox(width: 12),
Text(
e.label(context),
)
],
),
),
),
onPressed: () {
ref.read(libraryViewTypeProvider.notifier).state = e;
},
child: Row(
children: [
Icon(e.icon),
const SizedBox(width: 12),
Text(
e.label(context),
)
.toList()
.addInBetween(const SizedBox(height: 12)),
],
);
},
),
),
],
),
),
)
.toList()
.addInBetween(const SizedBox(height: 12)),
],
);
});
return Card(
elevation: 0,
child: Tooltip(
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(1),
child: InkWell(
onTapUp: (details) async {
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
double left = details.globalPosition.dx;
double top = details.globalPosition.dy;
await showMenu(
context: context,
position: RelativeRect.fromLTRB(left, top, 40, 100),
items: <PopupMenuEntry>[
PopupMenuItem(
child: Text(
librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(0))),
itemCountWidget.toPopupMenuItem(useIcons: true),
refreshAction.toPopupMenuItem(useIcons: true),
itemViewAction.toPopupMenuItem(useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toPopupMenuItem(),
...itemActions.popupMenuItems(useIcons: true),
],
elevation: 8.0,
);
} else {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: [
itemCountWidget.toListItem(context, useIcons: true),
refreshAction.toListItem(context, useIcons: true),
itemViewAction.toListItem(context, useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...itemActions.listTileItems(context, useIcons: true),
],
),
);
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
isFavorite
? librarySearchResults.nestedCurrentItem?.type.selectedicon
: librarySearchResults.nestedCurrentItem?.type.icon ??
IconsaxPlusLinear.document,
color: isFavorite ? Theme.of(context).colorScheme.primary : null,
),
),
),
},
),
);
}),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) ...[
const SizedBox(width: 6),
const SizedBox.square(dimension: 46, child: SettingsUserIcon()),
],
const SizedBox(width: 12)
],
title: Hero(
tag: "PrimarySearch",
child: SuggestionSearchBar(
autoFocus: isEmptySearchScreen,
key: uniqueKey,
title: librarySearchResults.searchBarTitle(context),
debounceDuration: const Duration(seconds: 1),
onItem: (value) async {
await value.navigateTo(context);
refreshKey.currentState?.show();
},
onSubmited: (value) async {
if (librarySearchResults.searchQuery != value) {
libraryProvider.setSearch(value);
refreshKey.currentState?.show();
}
},
),
),
bottom: PreferredSize(
preferredSize: const Size(0, 50),
child: Transform.translate(
offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15),
child: IgnorePointer(
ignoring: librarySearchResults.loading,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Opacity(
opacity: librarySearchResults.loading ? 0.5 : 1,
child: SingleChildScrollView(
padding: const EdgeInsets.all(8),
scrollDirection: Axis.horizontal,
child: LibraryFilterChips(
key: uniqueKey,
),
),
),
),
);
});
return Card(
elevation: 0,
child: Tooltip(
message: librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(1),
child: InkWell(
onTapUp: (details) async {
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) {
double left = details.globalPosition.dx;
double top = details.globalPosition.dy;
await showMenu(
context: context,
position: RelativeRect.fromLTRB(left, top, 40, 100),
items: <PopupMenuEntry>[
PopupMenuItem(
child: Text(librarySearchResults.nestedCurrentItem?.type.label(context) ??
context.localized.library(0))),
itemCountWidget.toPopupMenuItem(useIcons: true),
refreshAction.toPopupMenuItem(useIcons: true),
itemViewAction.toPopupMenuItem(useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toPopupMenuItem(),
...itemActions.popupMenuItems(useIcons: true),
],
),
elevation: 8.0,
);
} else {
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: [
itemCountWidget.toListItem(context, useIcons: true),
refreshAction.toListItem(context, useIcons: true),
itemViewAction.toListItem(context, useIcons: true),
if (librarySearchResults.views.hasEnabled == true)
showSavedFiltersDialogue.toPopupMenuItem(useIcons: true),
if (itemActions.isNotEmpty) ItemActionDivider().toListItem(context),
...itemActions.listTileItems(context, useIcons: true),
],
),
);
}
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
isFavorite
? librarySearchResults.nestedCurrentItem?.type.selectedicon
: librarySearchResults.nestedCurrentItem?.type.icon ??
IconsaxPlusLinear.document,
color: isFavorite ? Theme.of(context).colorScheme.primary : null,
),
),
),
),
if (AdaptiveLayout.of(context).isDesktop)
const SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PosterSizeWidget(),
],
),
);
}),
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) ...[
const SizedBox(width: 6),
const SizedBox.square(dimension: 46, child: SettingsUserIcon()),
],
const SizedBox(width: 12)
],
title: Row(
spacing: 2,
children: [
const SizedBox(width: 2),
Center(
child: SizedBox.square(
dimension: 47,
child: Card(
child: context.router.backButton(),
),
if (postersList.isNotEmpty)
SliverPadding(
padding: EdgeInsets.only(
left: MediaQuery.of(context).padding.left,
right: MediaQuery.of(context).padding.right),
sliver: LibraryViews(
key: uniqueKey,
items: postersList,
groupByType: librarySearchResults.groupBy,
),
)
else
SliverFillRemaining(
child: Center(
child: Text(context.localized.noItemsToShow),
),
),
),
Flexible(
child: Hero(
tag: "PrimarySearch",
child: SuggestionSearchBar(
autoFocus: isEmptySearchScreen,
key: uniqueKey,
title: librarySearchResults.searchBarTitle(context),
debounceDuration: const Duration(seconds: 1),
onItem: (value) async {
await value.navigateTo(context);
refreshKey.currentState?.show();
},
onSubmited: (value) async {
if (librarySearchResults.searchQuery != value) {
libraryProvider.setSearch(value);
refreshKey.currentState?.show();
}
},
),
SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.sizeOf(context).height * 0.20))
],
),
),
],
),
bottom: PreferredSize(
preferredSize: const Size(0, 50),
child: Transform.translate(
offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15),
child: IgnorePointer(
ignoring: librarySearchResults.loading,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Opacity(
opacity: librarySearchResults.loading ? 0.5 : 1,
child: SingleChildScrollView(
padding: const EdgeInsets.all(8),
scrollDirection: Axis.horizontal,
child: LibraryFilterChips(
key: uniqueKey,
),
),
),
],
),
),
),
),
),
),
if (AdaptiveLayout.of(context).isDesktop)
const SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
PosterSizeWidget(),
],
),
),
if (postersList.isNotEmpty)
SliverPadding(
padding: EdgeInsets.only(
left: MediaQuery.of(context).padding.left, right: MediaQuery.of(context).padding.right),
sliver: LibraryViews(
key: uniqueKey,
items: postersList,
groupByType: librarySearchResults.filters.groupBy,
),
)
else
SliverFillRemaining(
child: Center(
child: Text(context.localized.noItemsToShow),
),
),
SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.sizeOf(context).height * 0.20))
],
),
),
if (librarySearchResults.fetchingItems) ...[
Container(
color: Colors.black.withValues(alpha: 0.1),
),
Center(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator.adaptive(),
Text(context.localized.fetchingLibrary, style: Theme.of(context).textTheme.titleMedium),
IconButton(
onPressed: () => libraryProvider.cancelFetch(),
icon: const Icon(IconsaxPlusLinear.close_square),
)
].addInBetween(const SizedBox(width: 16)),
),
),
),
)
],
],
),
),
),
),
@ -663,7 +652,7 @@ class _LibrarySearchBottomBar extends ConsumerWidget {
context,
libraryProvider: libraryProvider,
uniqueKey: uniqueKey,
options: (librarySearchResults.sortingOption, librarySearchResults.sortOrder),
options: (librarySearchResults.filters.sortingOption, librarySearchResults.filters.sortOrder),
);
if (newOptions != null) {
if (newOptions.$1 != null) {