Init repo

This commit is contained in:
PartyDonut 2024-09-15 14:12:28 +02:00
commit 764b6034e3
566 changed files with 212335 additions and 0 deletions

View file

@ -0,0 +1,790 @@
import 'package:ficonsax/ficonsax.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_search/library_search_options.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playlist_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/collections/add_to_collection.dart';
import 'package:fladder/screens/library_search/widgets/library_sort_dialogue.dart';
import 'package:fladder/screens/playlists/add_to_playlists.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/nested_bottom_appbar.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/fab_extended_anim.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
import 'package:fladder/widgets/shared/fladder_scrollbar.dart';
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:fladder/widgets/shared/pinch_poster_zoom.dart';
import 'package:fladder/widgets/shared/poster_size_slider.dart';
import 'package:fladder/widgets/shared/scroll_position.dart';
import 'package:fladder/widgets/shared/shapes.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/library_search/library_search_model.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/screens/library_search/widgets/library_filter_chips.dart';
import 'package:fladder/screens/library_search/widgets/library_views.dart';
import 'package:fladder/screens/library_search/widgets/suggestion_search_bar.dart';
import 'package:fladder/util/debouncer.dart';
import 'package:fladder/util/sliver_list_padding.dart';
import 'package:fladder/widgets/shared/pull_to_refresh.dart';
class LibrarySearchScreen extends ConsumerStatefulWidget {
final String? viewModelId;
final bool? favourites;
final List<String>? folderId;
final SortingOrder? sortOrder;
final SortingOptions? sortingOptions;
final PhotoModel? photoToView;
const LibrarySearchScreen({
this.viewModelId,
this.folderId,
this.favourites,
this.sortOrder,
this.sortingOptions,
this.photoToView,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _LibrarySearchScreenState();
}
class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
late final Key uniqueKey = Key(widget.folderId?.join(',').toString() ?? widget.viewModelId ?? UniqueKey().toString());
late final providerKey = librarySearchProvider(uniqueKey);
late final libraryProvider = ref.read(providerKey.notifier);
final SearchController searchController = SearchController();
final Debouncer debouncer = Debouncer(const Duration(seconds: 1));
final GlobalKey<RefreshIndicatorState> refreshKey = GlobalKey<RefreshIndicatorState>();
final ScrollController scrollController = ScrollController();
late double lastScale = 0;
bool loadOnStart = false;
@override
void initState() {
super.initState();
searchController.addListener(() {
debouncer.run(() {
ref.read(providerKey.notifier).setSearch(searchController.text);
});
});
Future.microtask(
() async {
libraryProvider.setDefaultOptions(widget.sortOrder, widget.sortingOptions);
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() {
if (scrollController.position.pixels > scrollController.position.maxScrollExtent * 0.65) {
libraryProvider.loadMore();
}
}
@override
Widget build(BuildContext context) {
final isEmptySearchScreen = widget.viewModelId == null && widget.favourites == null && widget.folderId == null;
final librarySearchResults = ref.watch(providerKey);
final libraryProvider = ref.read(providerKey.notifier);
final postersList = librarySearchResults.posters.hideEmptyChildren(librarySearchResults.hideEmtpyShows);
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
final libraryViewType = ref.watch(libraryViewTypeProvider);
ref.listen(
providerKey,
(previous, next) {
if (previous != next) {
refreshKey.currentState?.show();
scrollController.jumpTo(0);
}
},
);
return PopScope(
canPop: !librarySearchResults.selecteMode,
onPopInvoked: (popped) async {
if (librarySearchResults.selecteMode) {
libraryProvider.toggleSelectMode();
}
},
child: Scaffold(
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButtonLocation:
playerState == VideoPlayerState.minimized ? FloatingActionButtonLocation.centerFloat : null,
floatingActionButton: switch (playerState) {
VideoPlayerState.minimized => Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: FloatingPlayerBar(),
),
_ => HideOnScroll(
controller: scrollController,
visibleBuilder: (visible) => Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (librarySearchResults.showPlayButtons)
FloatingActionButtonAnimated(
key: Key(context.localized.playLabel),
isExtended: visible,
tooltip: context.localized.playVideos,
onPressed: () async => await libraryProvider.playLibraryItems(context, ref),
label: Text(context.localized.playLabel),
icon: const Icon(IconsaxBold.play),
),
if (librarySearchResults.showGalleryButtons)
FloatingActionButtonAnimated(
key: Key(context.localized.viewPhotos),
isExtended: visible,
alternate: true,
tooltip: context.localized.viewPhotos,
onPressed: () async => await libraryProvider.viewGallery(context),
label: Text(context.localized.viewPhotos),
icon: const Icon(IconsaxBold.gallery),
)
].addInBetween(SizedBox(height: 10)),
),
),
},
bottomNavigationBar: HideOnScroll(
controller: AdaptiveLayout.of(context).isDesktop ? null : scrollController,
child: IgnorePointer(
ignoring: librarySearchResults.fetchingItems,
child: _LibrarySearchBottomBar(
uniqueKey: uniqueKey,
refreshKey: refreshKey,
scrollController: scrollController,
libraryProvider: libraryProvider,
postersList: postersList,
),
),
),
body: Stack(
children: [
Positioned.fill(
child: Card(
elevation: 1,
child: PinchPosterZoom(
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
child: MediaQuery.removeViewInsets(
context: context,
child: ClipRRect(
borderRadius: AdaptiveLayout.of(context).layout == LayoutState.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 =>
libraryProvider.initRefresh(widget.folderId, widget.viewModelId, widget.favourites),
refreshOnStart: false,
child: CustomScrollView(
physics: const AlwaysScrollableNoImplicitScrollPhysics(),
controller: scrollController,
slivers: [
SliverAppBar(
floating: !AdaptiveLayout.of(context).isDesktop,
collapsedHeight: 80,
automaticallyImplyLeading: true,
pinned: AdaptiveLayout.of(context).isDesktop,
primary: true,
elevation: 5,
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: Icon(IconsaxBold.document_1),
);
final refreshAction = ItemActionButton(
label: Text(context.localized.forceRefresh),
action: () => refreshKey.currentState?.show(),
icon: Icon(IconsaxOutline.refresh),
);
final itemViewAction = ItemActionButton(
label: Text(context.localized.selectViewType),
icon: Icon(libraryViewType.icon),
action: () {
showAdaptiveDialog(
context: context,
builder: (context) => AlertDialog.adaptive(
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: 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;
},
child: Row(
children: [
Icon(e.icon),
const SizedBox(width: 12),
Text(
e.label(context),
)
],
),
),
)
.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 + 20;
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 (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 (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 ??
IconsaxOutline.document,
color: isFavorite ? Theme.of(context).colorScheme.primary : null,
),
),
),
),
);
}),
if (AdaptiveLayout.of(context).layout == LayoutState.phone) ...[
const SizedBox(width: 6),
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(
controller: scrollController,
libraryProvider: libraryProvider,
librarySearchResults: librarySearchResults,
uniqueKey: uniqueKey,
postersList: postersList,
libraryViewType: libraryViewType,
),
),
),
Row(),
],
),
),
),
),
),
if (AdaptiveLayout.of(context).isDesktop)
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.groupBy,
),
)
else
SliverToBoxAdapter(
child: Center(
child: Text(context.localized.noItemsToShow),
),
),
const DefautlSliverBottomPadding(),
const SliverPadding(padding: EdgeInsets.only(bottom: 80))
],
),
),
),
),
),
),
),
),
if (librarySearchResults.fetchingItems) ...[
Container(
color: Colors.black.withOpacity(0.1),
),
Center(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator.adaptive(),
Text(context.localized.fetchingLibrary, style: Theme.of(context).textTheme.titleMedium),
IconButton(
onPressed: () => libraryProvider.cancelFetch(),
icon: Icon(IconsaxOutline.close_square),
)
].addInBetween(const SizedBox(width: 16)),
),
),
),
)
],
],
),
),
);
}
}
class AlwaysScrollableNoImplicitScrollPhysics extends ScrollPhysics {
/// Creates scroll physics that always lets the user scroll.
const AlwaysScrollableNoImplicitScrollPhysics({super.parent});
@override
AlwaysScrollableNoImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) {
return AlwaysScrollableNoImplicitScrollPhysics(parent: buildParent(ancestor));
}
@override
bool get allowImplicitScrolling => false;
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) => false;
}
class _LibrarySearchBottomBar extends ConsumerWidget {
final Key uniqueKey;
final ScrollController scrollController;
final LibrarySearchNotifier libraryProvider;
final List<ItemBaseModel> postersList;
final GlobalKey<RefreshIndicatorState> refreshKey;
const _LibrarySearchBottomBar({
required this.uniqueKey,
required this.scrollController,
required this.libraryProvider,
required this.postersList,
required this.refreshKey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final librarySearchResults = ref.watch(librarySearchProvider(uniqueKey));
final actions = [
ItemActionButton(
action: () async {
await libraryProvider.setSelectedAsFavorite(true);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.addAsFavorite),
icon: const Icon(IconsaxOutline.heart_add),
),
ItemActionButton(
action: () async {
await libraryProvider.setSelectedAsFavorite(false);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.removeAsFavorite),
icon: const Icon(IconsaxOutline.heart_remove),
),
ItemActionButton(
action: () async {
await libraryProvider.setSelectedAsWatched(true);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.markAsWatched),
icon: const Icon(IconsaxOutline.eye),
),
ItemActionButton(
action: () async {
await libraryProvider.setSelectedAsWatched(false);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.markAsUnwatched),
icon: const Icon(IconsaxOutline.eye_slash),
),
if (librarySearchResults.nestedCurrentItem is BoxSetModel)
ItemActionButton(
action: () async {
await libraryProvider.removeSelectedFromCollection();
if (context.mounted) context.refreshData();
},
label: Text(context.localized.removeFromCollection),
icon: Container(
decoration:
BoxDecoration(color: Theme.of(context).colorScheme.onPrimary, borderRadius: BorderRadius.circular(6)),
child: const Padding(
padding: EdgeInsets.all(3.0),
child: Icon(IconsaxOutline.save_remove, size: 20),
),
)),
if (librarySearchResults.nestedCurrentItem is PlaylistModel)
ItemActionButton(
action: () async {
await libraryProvider.removeSelectedFromPlaylist();
if (context.mounted) context.refreshData();
},
label: Text(context.localized.removeFromPlaylist),
icon: const Icon(IconsaxOutline.save_remove),
),
ItemActionButton(
action: () async {
await addItemToCollection(context, librarySearchResults.selectedPosters);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.addToCollection),
icon: Icon(
IconsaxOutline.save_add,
size: 20,
),
),
ItemActionButton(
action: () async {
await addItemToPlaylist(context, librarySearchResults.selectedPosters);
if (context.mounted) context.refreshData();
},
label: Text(context.localized.addToPlaylist),
icon: const Icon(IconsaxOutline.save_add),
),
];
return NestedBottomAppBar(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ScrollStatePosition(
controller: scrollController,
positionBuilder: (state) => AnimatedFadeSize(
child: state != ScrollState.top
? Tooltip(
message: context.localized.scrollToTop,
child: FlatButton(
clipBehavior: Clip.antiAlias,
elevation: 0,
borderRadiusGeometry: BorderRadius.circular(6),
onTap: () => scrollController.animateTo(0,
duration: const Duration(milliseconds: 500), curve: Curves.easeInOutCubic),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
),
padding: const EdgeInsets.all(6),
child: Icon(
IconsaxOutline.arrow_up_3,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
)
: const SizedBox(),
),
),
const SizedBox(width: 6),
if (!librarySearchResults.selecteMode) ...{
const SizedBox(width: 6),
IconButton(
tooltip: context.localized.sortBy,
onPressed: () async {
final newOptions = await openSortByDialogue(
context,
libraryProvider: libraryProvider,
uniqueKey: uniqueKey,
options: (librarySearchResults.sortingOption, librarySearchResults.sortOrder),
);
if (newOptions != null) {
if (newOptions.$1 != null) {
libraryProvider.setSortBy(newOptions.$1!);
}
if (newOptions.$2 != null) {
libraryProvider.setSortOrder(newOptions.$2!);
}
}
},
icon: const Icon(IconsaxOutline.sort),
),
if (librarySearchResults.hasActiveFilters) ...{
const SizedBox(width: 6),
IconButton(
tooltip: context.localized.disableFilters,
onPressed: disableFilters(librarySearchResults, libraryProvider),
icon: const Icon(IconsaxOutline.filter_remove),
),
},
},
const SizedBox(width: 6),
IconButton(
onPressed: () => libraryProvider.toggleSelectMode(),
color: librarySearchResults.selecteMode ? Theme.of(context).colorScheme.primary : null,
icon: const Icon(IconsaxOutline.category_2),
),
const SizedBox(width: 6),
AnimatedFadeSize(
child: librarySearchResults.selecteMode
? Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16)),
child: Row(
children: [
Tooltip(
message: context.localized.selectAll,
child: IconButton(
onPressed: () => libraryProvider.selectAll(true),
icon: const Icon(IconsaxOutline.box_add),
),
),
const SizedBox(width: 6),
Tooltip(
message: context.localized.clearSelection,
child: IconButton(
onPressed: () => libraryProvider.selectAll(false),
icon: const Icon(IconsaxOutline.box_remove),
),
),
const SizedBox(width: 6),
if (librarySearchResults.selectedPosters.isNotEmpty) ...{
if (AdaptiveLayout.of(context).isDesktop)
PopupMenuButton(
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
)
else
IconButton(
onPressed: () {
showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: actions.listTileItems(context, useIcons: true),
),
);
},
icon: Icon(IconsaxOutline.more))
},
],
),
)
: const SizedBox(),
),
const Spacer(),
IconButton(
tooltip: context.localized.random,
onPressed: () => libraryProvider.openRandom(context),
icon: Card(
color: Theme.of(context).colorScheme.secondary,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Icon(
IconsaxBold.arrow_up_1,
color: Theme.of(context).colorScheme.onSecondary,
),
),
),
),
if (librarySearchResults.showGalleryButtons)
IconButton(
tooltip: context.localized.shuffleGallery,
onPressed: () => libraryProvider.viewGallery(context, shuffle: true),
icon: Card(
color: Theme.of(context).colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(2.0),
child: Icon(
IconsaxBold.shuffle,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
if (librarySearchResults.showPlayButtons)
IconButton(
tooltip: context.localized.shuffleVideos,
onPressed: librarySearchResults.activePosters.isNotEmpty
? () async {
await libraryProvider.playLibraryItems(context, ref, shuffle: true);
}
: null,
icon: const Icon(IconsaxOutline.shuffle),
),
],
),
if (AdaptiveLayout.of(context).isDesktop) SizedBox(height: 8),
],
),
);
}
void Function()? disableFilters(LibrarySearchModel librarySearchResults, LibrarySearchNotifier libraryProvider) {
return () {
libraryProvider.clearAllFilters();
refreshKey.currentState?.show();
};
}
}

View file

@ -0,0 +1,219 @@
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/library_search/library_search_model.dart';
import 'package:fladder/models/library_search/library_search_options.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/screens/library_search/widgets/library_views.dart';
import 'package:fladder/screens/shared/chips/category_chip.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/map_bool_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/shared/scroll_position.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LibraryFilterChips extends ConsumerWidget {
final Key uniqueKey;
final ScrollController controller;
final LibrarySearchModel librarySearchResults;
final LibrarySearchNotifier libraryProvider;
final List<ItemBaseModel> postersList;
final LibraryViewTypes libraryViewType;
const LibraryFilterChips({
required this.uniqueKey,
required this.controller,
required this.librarySearchResults,
required this.libraryProvider,
required this.postersList,
required this.libraryViewType,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ScrollStatePosition(
controller: controller,
positionBuilder: (state) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: libraryFilterChips(
context,
ref,
uniqueKey,
librarySearchResults: librarySearchResults,
libraryProvider: libraryProvider,
postersList: postersList,
libraryViewType: libraryViewType,
).addPadding(const EdgeInsets.symmetric(horizontal: 8)),
);
},
);
}
}
List<Widget> libraryFilterChips(
BuildContext context,
WidgetRef ref,
Key uniqueKey, {
required LibrarySearchModel librarySearchResults,
required LibrarySearchNotifier libraryProvider,
required List<ItemBaseModel> postersList,
required LibraryViewTypes libraryViewType,
}) {
Future<dynamic> openGroupDialogue() {
return showDialog(
context: context,
builder: (context) {
return Consumer(
builder: (context, ref, child) {
return AlertDialog.adaptive(
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: ListView(
shrinkWrap: true,
children: [
Text(context.localized.groupBy),
...GroupBy.values.map((groupBy) => RadioListTile.adaptive(
value: groupBy,
title: Text(groupBy.value(context)),
groupValue: ref.watch(librarySearchProvider(uniqueKey).select((value) => value.groupBy)),
onChanged: (value) {
libraryProvider.setGroupBy(groupBy);
Navigator.pop(context);
},
)),
],
),
),
);
},
);
},
);
}
return [
if (librarySearchResults.folderOverwrite.isEmpty)
CategoryChip(
label: Text(context.localized.library(2)),
items: librarySearchResults.views,
labelBuilder: (item) => Text(item.name),
onSave: (value) => libraryProvider.setViews(value),
onCancel: () => libraryProvider.setViews(librarySearchResults.views),
onClear: () => libraryProvider.setViews(librarySearchResults.views.setAll(false)),
),
CategoryChip<FladderItemType>(
label: Text(context.localized.type(librarySearchResults.types.length)),
items: librarySearchResults.types,
labelBuilder: (item) => Row(
children: [
Icon(item.icon),
const SizedBox(width: 12),
Text(item.label(context)),
],
),
onSave: (value) => libraryProvider.setTypes(value),
onClear: () => libraryProvider.setTypes(librarySearchResults.types.setAll(false)),
),
FilterChip(
label: Text(context.localized.favorites),
avatar: Icon(
librarySearchResults.favourites ? IconsaxBold.heart : IconsaxOutline.heart,
color: Theme.of(context).colorScheme.onSurface,
),
selected: librarySearchResults.favourites,
showCheckmark: false,
onSelected: (value) {
libraryProvider.toggleFavourite();
context.refreshData();
},
),
FilterChip(
label: Text(context.localized.recursive),
selected: librarySearchResults.recursive,
onSelected: (value) {
libraryProvider.toggleRecursive();
context.refreshData();
},
),
if (librarySearchResults.genres.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.genre(librarySearchResults.genres.length)),
activeIcon: IconsaxBold.hierarchy_2,
items: librarySearchResults.genres,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setGenres(value),
onCancel: () => libraryProvider.setGenres(librarySearchResults.genres),
onClear: () => libraryProvider.setGenres(librarySearchResults.genres.setAll(false)),
),
if (librarySearchResults.studios.isNotEmpty)
CategoryChip<Studio>(
label: Text(context.localized.studio(librarySearchResults.studios.length)),
activeIcon: IconsaxBold.airdrop,
items: librarySearchResults.studios,
labelBuilder: (item) => Text(item.name),
onSave: (value) => libraryProvider.setStudios(value),
onCancel: () => libraryProvider.setStudios(librarySearchResults.studios),
onClear: () => libraryProvider.setStudios(librarySearchResults.studios.setAll(false)),
),
if (librarySearchResults.tags.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.label(librarySearchResults.tags.length)),
activeIcon: Icons.label_rounded,
items: librarySearchResults.tags,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setTags(value),
onCancel: () => libraryProvider.setTags(librarySearchResults.tags),
onClear: () => libraryProvider.setTags(librarySearchResults.tags.setAll(false)),
),
FilterChip(
label: Text(context.localized.group),
selected: librarySearchResults.groupBy != GroupBy.none,
onSelected: (value) {
openGroupDialogue();
},
),
CategoryChip<ItemFilter>(
label: Text(context.localized.filter(librarySearchResults.filters.length)),
items: librarySearchResults.filters,
labelBuilder: (item) => Text(item.label(context)),
onSave: (value) => libraryProvider.setFilters(value),
onClear: () => libraryProvider.setFilters(librarySearchResults.filters.setAll(false)),
),
if (librarySearchResults.types[FladderItemType.series] == true)
FilterChip(
avatar: Icon(
librarySearchResults.hideEmtpyShows ? Icons.visibility_rounded : Icons.visibility_off_rounded,
color: Theme.of(context).colorScheme.onSurface,
),
selected: librarySearchResults.hideEmtpyShows,
showCheckmark: false,
label: Text(librarySearchResults.hideEmtpyShows ? context.localized.showEmpty : context.localized.hideEmpty),
onSelected: libraryProvider.setHideEmpty,
),
if (librarySearchResults.officialRatings.isNotEmpty)
CategoryChip<String>(
label: Text(context.localized.rating(librarySearchResults.officialRatings.length)),
activeIcon: Icons.star_rate_rounded,
items: librarySearchResults.officialRatings,
labelBuilder: (item) => Text(item),
onSave: (value) => libraryProvider.setRatings(value),
onCancel: () => libraryProvider.setRatings(librarySearchResults.officialRatings),
onClear: () => libraryProvider.setRatings(librarySearchResults.officialRatings.setAll(false)),
),
if (librarySearchResults.years.isNotEmpty)
CategoryChip<int>(
label: Text(context.localized.year(librarySearchResults.years.length)),
items: librarySearchResults.years,
labelBuilder: (item) => Text(item.toString()),
onSave: (value) => libraryProvider.setYears(value),
onCancel: () => libraryProvider.setYears(librarySearchResults.years),
onClear: () => libraryProvider.setYears(librarySearchResults.years.setAll(false)),
),
];
}

View file

@ -0,0 +1,78 @@
import 'package:fladder/models/library_search/library_search_options.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
Future<(SortingOptions? sortOptions, SortingOrder? sortingOrder)?> openSortByDialogue(
BuildContext context, {
required (SortingOptions sortOptions, SortingOrder sortingOrder) options,
required LibrarySearchNotifier libraryProvider,
required Key uniqueKey,
}) async {
SortingOptions? newSortingOptions = options.$1;
SortingOrder? newSortOrder = options.$2;
await showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, state) {
return AlertDialog.adaptive(
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(context.localized.sortBy, style: Theme.of(context).textTheme.titleLarge),
),
const SizedBox(height: 8),
...SortingOptions.values.map((e) => RadioListTile.adaptive(
value: e,
title: Text(e.label(context)),
groupValue: newSortingOptions,
onChanged: (value) {
state(
() {
newSortingOptions = value;
},
);
},
)),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(context.localized.sortOrder, style: Theme.of(context).textTheme.titleLarge),
),
const SizedBox(height: 8),
...SortingOrder.values.map(
(e) => RadioListTile.adaptive(
value: e,
title: Text(e.label(context)),
groupValue: newSortOrder,
onChanged: (value) {
state(
() {
newSortOrder = value;
},
);
},
),
),
],
),
),
);
},
);
},
);
if (newSortingOptions == null && newSortOrder == null) {
return null;
} else {
return (newSortingOptions, newSortOrder);
}
}

View file

@ -0,0 +1,387 @@
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.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_search/library_search_options.dart';
import 'package:fladder/models/playlist_model.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart';
import 'package:fladder/screens/shared/media/poster_grid.dart';
import 'package:fladder/screens/shared/media/poster_list_item.dart';
import 'package:fladder/screens/shared/media/poster_widget.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/util/string_extensions.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart';
import 'package:page_transition/page_transition.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:sticky_headers/sticky_headers/widget.dart';
final libraryViewTypeProvider = StateProvider<LibraryViewTypes>((ref) {
return LibraryViewTypes.grid;
});
enum LibraryViewTypes {
grid(icon: IconsaxOutline.grid_2),
list(icon: IconsaxOutline.grid_6),
masonry(icon: IconsaxOutline.grid_3);
const LibraryViewTypes({required this.icon});
String label(BuildContext context) => switch (this) {
LibraryViewTypes.grid => context.localized.grid,
LibraryViewTypes.list => context.localized.list,
LibraryViewTypes.masonry => context.localized.masonry,
};
final IconData icon;
}
class LibraryViews extends ConsumerWidget {
final List<ItemBaseModel> items;
final GroupBy groupByType;
final Function(ItemBaseModel)? onPressed;
final Set<ItemActions> excludeActions = const {ItemActions.openParent};
const LibraryViews({required this.items, required this.groupByType, this.onPressed, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 4),
sliver: SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: _getWidget(ref, context),
),
);
}
Widget _getWidget(WidgetRef ref, BuildContext context) {
final selected = ref.watch(librarySearchProvider(key!).select((value) => value.selectedPosters));
final posterSizeMultiplier = ref.watch(clientSettingsProvider.select((value) => value.posterSize));
final libraryProvider = ref.read(librarySearchProvider(key!).notifier);
final posterSize = MediaQuery.sizeOf(context).width /
(AdaptiveLayout.poster(context).gridRatio *
ref.watch(clientSettingsProvider.select((value) => value.posterSize)));
final decimal = posterSize - posterSize.toInt();
final sortingOptions = ref.watch(librarySearchProvider(key!).select((value) => value.sortingOption));
List<ItemAction> otherActions(ItemBaseModel item) {
return [
if (ref.watch(librarySearchProvider(key!).select((value) => value.nestedCurrentItem is BoxSetModel))) ...{
ItemActionButton(
label: Text(context.localized.removeFromCollection),
icon: Icon(IconsaxOutline.archive_slash),
action: () async {
await libraryProvider.removeFromCollection(items: [item]);
if (context.mounted) {
context.refreshData();
}
},
)
},
if (ref.watch(librarySearchProvider(key!).select((value) => value.nestedCurrentItem is PlaylistModel))) ...{
ItemActionButton(
label: Text(context.localized.removeFromPlaylist),
icon: Icon(IconsaxOutline.archive_minus),
action: () async {
await libraryProvider.removeFromPlaylist(items: [item]);
if (context.mounted) {
context.refreshData();
}
},
)
}
];
}
switch (ref.watch(libraryViewTypeProvider)) {
case LibraryViewTypes.grid:
if (groupByType != GroupBy.none) {
final groupedItems = groupItemsBy(context, items, groupByType);
return SliverList.builder(
itemCount: groupedItems.length,
itemBuilder: (context, index) {
final name = groupedItems.keys.elementAt(index);
final group = groupedItems[name];
if (group?.isEmpty ?? false || group == null) {
return Text(context.localized.empty);
}
return PosterGrid(
posters: group!,
name: name,
itemBuilder: (context, index) {
final item = group[index];
return PosterWidget(
key: Key(item.id),
poster: group[index],
maxLines: 2,
heroTag: true,
subTitle: item.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(item),
selected: selected.contains(item),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
);
} else {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: posterSize.toInt(),
mainAxisSpacing: (8 * decimal) + 8,
crossAxisSpacing: (8 * decimal) + 8,
childAspectRatio: AdaptiveLayout.poster(context).ratio,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return PosterWidget(
key: Key(item.id),
poster: item,
maxLines: 2,
heroTag: true,
subTitle: item.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(item),
selected: selected.contains(item),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
),
);
}
case LibraryViewTypes.list:
if (groupByType != GroupBy.none) {
final groupedItems = groupItemsBy(context, items, groupByType);
return SliverList.builder(
itemCount: groupedItems.length,
itemBuilder: (context, index) {
final name = groupedItems.keys.elementAt(index);
final group = groupedItems[name];
if (group?.isEmpty ?? false) {
return Text(context.localized.empty);
}
return StickyHeader(
header: Text(name, style: Theme.of(context).textTheme.headlineSmall),
content: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: group?.length,
itemBuilder: (context, index) {
final poster = group![index];
return PosterListItem(
key: Key(poster.id),
poster: poster,
subTitle: poster.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(poster),
selected: selected.contains(poster),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
),
);
},
);
}
return SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final poster = items[index];
return PosterListItem(
poster: poster,
selected: selected.contains(poster),
excludeActions: excludeActions,
otherActions: otherActions(poster),
subTitle: poster.subTitle(sortingOptions),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
);
case LibraryViewTypes.masonry:
if (groupByType != GroupBy.none) {
final groupedItems = groupItemsBy(context, items, groupByType);
return SliverList.builder(
itemCount: groupedItems.length,
itemBuilder: (context, index) {
final name = groupedItems.keys.elementAt(index);
final group = groupedItems[name];
if (group?.isEmpty ?? false) {
return Text(context.localized.empty);
}
return Padding(
padding: EdgeInsets.only(top: index == 0 ? 0 : 64.0),
child: StickyHeader(
header: Text(name, style: Theme.of(context).textTheme.headlineMedium),
overlapHeaders: true,
content: Padding(
padding: const EdgeInsets.only(top: 16.0),
child: MasonryGridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: (8 * decimal) + 8,
crossAxisSpacing: (8 * decimal) + 8,
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
(MediaQuery.sizeOf(context).width ~/ (lerpDouble(250, 75, posterSizeMultiplier) ?? 1.0))
.toDouble() *
20,
),
itemCount: group!.length,
itemBuilder: (context, index) {
final item = group[index];
return PosterWidget(
key: Key(item.id),
poster: item,
aspectRatio: item.primaryRatio,
selected: selected.contains(item),
inlineTitle: true,
heroTag: true,
subTitle: item.subTitle(sortingOptions),
excludeActions: excludeActions,
otherActions: otherActions(group[index]),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
),
)),
);
},
);
} else {
return SliverMasonryGrid.count(
mainAxisSpacing: (8 * decimal) + 8,
crossAxisSpacing: (8 * decimal) + 8,
crossAxisCount: posterSize.toInt(),
childCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return PosterWidget(
poster: item,
key: Key(item.id),
aspectRatio: item.primaryRatio,
selected: selected.contains(item),
inlineTitle: true,
heroTag: true,
excludeActions: excludeActions,
otherActions: otherActions(item),
subTitle: item.subTitle(sortingOptions),
onUserDataChanged: (id, newData) => libraryProvider.updateUserData(id, newData),
onItemRemoved: (oldItem) => libraryProvider.removeFromPosters([oldItem.id]),
onItemUpdated: (newItem) => libraryProvider.updateItem(newItem),
onPressed: (action, item) async => onItemPressed(action, key, item, ref, context),
);
},
);
}
}
}
Map<String, List<ItemBaseModel>> groupItemsBy(BuildContext context, List<ItemBaseModel> list, GroupBy groupOption) {
switch (groupOption) {
case GroupBy.dateAdded:
return groupBy(
items,
(poster) => DateFormat.yMMMMd().format(DateTime(
poster.overview.dateAdded!.year, poster.overview.dateAdded!.month, poster.overview.dateAdded!.day)));
case GroupBy.releaseDate:
return groupBy(list, (poster) => poster.overview.yearAired?.toString() ?? context.localized.unknown);
case GroupBy.rating:
return groupBy(list, (poster) => poster.overview.parentalRating ?? context.localized.noRating);
case GroupBy.tags:
return groupByList(context, list, true);
case GroupBy.genres:
return groupByList(context, list, false);
case GroupBy.name:
return groupBy(list, (poster) => poster.name[0].capitalize());
case GroupBy.type:
return groupBy(list, (poster) => poster.type.label(context));
case GroupBy.none:
return {};
}
}
Future<void> onItemPressed(
Function() action, Key? key, ItemBaseModel item, WidgetRef ref, BuildContext context) async {
final selectMode = ref.read(librarySearchProvider(key!).select((value) => value.selecteMode));
if (selectMode) {
ref.read(librarySearchProvider(key).notifier).toggleSelection(item);
return;
}
switch (item) {
case PhotoModel _:
final photoList = items.whereType<PhotoModel>().toList();
if (context.mounted) {
await Navigator.of(context, rootNavigator: true).push(
PageTransition(
child: PhotoViewerScreen(
items: photoList,
loadingItems: ref.read(librarySearchProvider(key).notifier).fetchGallery(),
indexOfSelected: photoList.indexWhere((element) => element.id == item.id),
),
type: PageTransitionType.fade),
);
}
if (context.mounted) context.refreshData();
break;
default:
action.call();
break;
}
}
}
Map<String, List<ItemBaseModel>> groupByList(BuildContext context, List<ItemBaseModel> items, bool tags) {
Map<String, int> tagsCount = {};
for (var item in items) {
for (var tag in (tags ? item.overview.tags : item.overview.genres)) {
tagsCount[tag] = (tagsCount[tag] ?? 0) + 1;
}
}
List<String> sortedTags = tagsCount.keys.toList()..sort((a, b) => tagsCount[a]!.compareTo(tagsCount[b]!));
Map<String, List<ItemBaseModel>> groupedItems = {};
for (var item in items) {
List<String> itemTags = (tags ? item.overview.tags : item.overview.genres);
itemTags.sort((a, b) => sortedTags.indexOf(a).compareTo(sortedTags.indexOf(b)));
String key = itemTags.take(2).join(', ');
key = key.isNotEmpty ? key : context.localized.none;
groupedItems[key] = [...(groupedItems[key] ?? []), item];
}
return groupedItems;
}

View file

@ -0,0 +1,184 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:ficonsax/ficonsax.dart';
import 'package:fladder/main.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:page_transition/page_transition.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/providers/library_search_provider.dart';
import 'package:fladder/util/debouncer.dart';
class SuggestionSearchBar extends ConsumerStatefulWidget {
final String? title;
final bool autoFocus;
final TextEditingController? textEditingController;
final Duration debounceDuration;
final SuggestionsController<ItemBaseModel>? suggestionsBoxController;
final Function(String value)? onSubmited;
final Function(String value)? onChanged;
final Function(ItemBaseModel value)? onItem;
const SuggestionSearchBar({
this.title,
this.autoFocus = false,
this.textEditingController,
this.debounceDuration = const Duration(milliseconds: 250),
this.suggestionsBoxController,
this.onSubmited,
this.onChanged,
this.onItem,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _SearchBarState();
}
class _SearchBarState extends ConsumerState<SuggestionSearchBar> {
late final Debouncer debouncer = Debouncer(widget.debounceDuration);
late final SuggestionsController<ItemBaseModel> suggestionsBoxController =
widget.suggestionsBoxController ?? SuggestionsController<ItemBaseModel>();
late final TextEditingController textEditingController = widget.textEditingController ?? TextEditingController();
bool isEmpty = true;
final FocusNode focusNode = FocusNode();
@override
void initState() {
super.initState();
if (widget.autoFocus) {
focusNode.requestFocus();
}
super.initState();
}
@override
Widget build(BuildContext context) {
ref.listen(librarySearchProvider(widget.key!).select((value) => value.searchQuery), (previous, next) {
if (textEditingController.text != next) {
setState(() {
textEditingController.text = next;
});
}
});
return Card(
elevation: 2,
shadowColor: Colors.transparent,
child: TypeAheadField<ItemBaseModel>(
focusNode: focusNode,
hideOnEmpty: isEmpty,
emptyBuilder: (context) => Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"${context.localized.noSuggestionsFound}...",
style: Theme.of(context).textTheme.titleMedium,
),
),
suggestionsController: suggestionsBoxController,
decorationBuilder: (context, child) => DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: FladderTheme.defaultShape.borderRadius,
),
child: child,
),
builder: (context, controller, focusNode) => TextField(
focusNode: focusNode,
controller: controller,
onSubmitted: (value) {
widget.onSubmited!(value);
suggestionsBoxController.close();
},
onChanged: (value) {
setState(() {
isEmpty = value.isEmpty;
});
},
decoration: InputDecoration(
hintText: widget.title ?? "${context.localized.search}...",
prefixIcon: Icon(IconsaxOutline.search_normal),
contentPadding: EdgeInsets.only(top: 13),
suffixIcon: controller.text.isNotEmpty
? IconButton(
onPressed: () {
widget.onSubmited?.call('');
controller.text = '';
suggestionsBoxController.close();
setState(() {
isEmpty = true;
});
},
icon: const Icon(Icons.clear))
: null,
border: InputBorder.none,
),
),
loadingBuilder: (context) => const SizedBox(
height: 50,
child: Center(child: CircularProgressIndicator(strokeCap: StrokeCap.round)),
),
onSelected: (suggestion) {
suggestionsBoxController.close();
},
itemBuilder: (context, suggestion) {
return ListTile(
onTap: () {
if (widget.onItem != null) {
widget.onItem?.call(suggestion);
} else {
Navigator.of(context)
.push(PageTransition(child: suggestion.detailScreenWidget, type: PageTransitionType.fade));
}
},
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: SizedBox(
height: 50,
child: Row(
children: [
Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
child: AspectRatio(
aspectRatio: 0.8,
child: CachedNetworkImage(
cacheManager: CustomCacheManager.instance,
imageUrl: suggestion.images?.primary?.path ?? "",
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: Text(
suggestion.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)),
if (suggestion.overview.yearAired.toString().isNotEmpty)
Flexible(
child:
Opacity(opacity: 0.45, child: Text(suggestion.overview.yearAired?.toString() ?? ""))),
],
),
),
],
),
),
);
},
suggestionsCallback: (pattern) async {
if (pattern.isEmpty) return [];
if (widget.key != null) {
return (await ref.read(librarySearchProvider(widget.key!).notifier).fetchSuggestions(pattern));
}
return [];
},
),
);
}
}