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,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 [];
},
),
);
}
}